@michaelxwb/code-flow 0.5.2 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) 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 +63 -0
  6. package/src/adapters/claude/commands/cf-sync.md +25 -0
  7. package/src/adapters/claude/commands/cf-task/align.md +23 -1
  8. package/src/adapters/claude/commands/cf-task/archive.md +7 -0
  9. package/src/adapters/claude/commands/cf-task/plan.md +16 -0
  10. package/src/adapters/claude/commands/cf-task/prd.md +24 -0
  11. package/src/adapters/claude/commands/cf-task/start.md +13 -10
  12. package/src/adapters/claude/settings.local.json +1 -11
  13. package/src/adapters/codex/AGENTS.md +11 -17
  14. package/src/adapters/codex/hooks.json +6 -4
  15. package/src/adapters/codex/skills/cf-init/SKILL.md +5 -4
  16. package/src/adapters/codex/skills/cf-spec/SKILL.md +56 -0
  17. package/src/adapters/codex/skills/cf-sync/SKILL.md +30 -0
  18. package/src/adapters/codex/skills/cf-task-align/SKILL.md +23 -1
  19. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +7 -0
  20. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +16 -0
  21. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +24 -0
  22. package/src/adapters/codex/skills/cf-task-start/SKILL.md +13 -10
  23. package/src/adapters/costrict/CLAUDE.md +11 -17
  24. package/src/adapters/costrict/commands/cf-init.md +5 -4
  25. package/src/adapters/costrict/commands/cf-spec.md +63 -0
  26. package/src/adapters/costrict/commands/cf-sync.md +25 -0
  27. package/src/adapters/costrict/commands/cf-task/align.md +23 -1
  28. package/src/adapters/costrict/commands/cf-task/archive.md +7 -0
  29. package/src/adapters/costrict/commands/cf-task/plan.md +16 -0
  30. package/src/adapters/costrict/commands/cf-task/prd.md +24 -0
  31. package/src/adapters/costrict/commands/cf-task/start.md +13 -10
  32. package/src/adapters/costrict/settings.local.json +1 -11
  33. package/src/adapters/opencode/AGENTS.md +11 -17
  34. package/src/adapters/opencode/commands/cf-init.md +4 -3
  35. package/src/adapters/opencode/commands/cf-spec.md +55 -0
  36. package/src/adapters/opencode/commands/cf-sync.md +29 -0
  37. package/src/adapters/opencode/commands/cf-task/align.md +24 -1
  38. package/src/adapters/opencode/commands/cf-task/archive.md +8 -0
  39. package/src/adapters/opencode/commands/cf-task/plan.md +17 -0
  40. package/src/adapters/opencode/commands/cf-task/prd.md +25 -0
  41. package/src/adapters/opencode/commands/cf-task/start.md +14 -10
  42. package/src/adapters/opencode/plugins/code-flow/index.js +3 -4
  43. package/src/cli.js +51 -2
  44. package/src/core/code-flow/config.yml +14 -16
  45. package/src/core/code-flow/scripts/cf_checks.py +30 -0
  46. package/src/core/code-flow/scripts/cf_core.py +36 -256
  47. package/src/core/code-flow/scripts/cf_log.py +8 -0
  48. package/src/core/code-flow/scripts/cf_post_hook.py +6 -9
  49. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +88 -0
  50. package/src/core/code-flow/scripts/cf_session_state.py +37 -0
  51. package/src/core/code-flow/scripts/cf_spec_context.py +1470 -0
  52. package/src/core/code-flow/scripts/cf_spec_gate.py +230 -0
  53. package/src/core/code-flow/scripts/cf_spec_metadata.py +304 -0
  54. package/src/core/code-flow/scripts/cf_spec_migrate.py +512 -0
  55. package/src/core/code-flow/scripts/cf_spec_resolver.py +271 -0
  56. package/src/core/code-flow/scripts/cf_spec_router.py +149 -0
  57. package/src/core/code-flow/scripts/cf_spec_session.py +118 -0
  58. package/src/core/code-flow/scripts/cf_spec_verify.py +340 -0
  59. package/src/core/code-flow/scripts/cf_stats.py +40 -5
  60. package/src/core/code-flow/scripts/cf_stop_hook.py +43 -2
  61. package/src/core/code-flow/scripts/cf_sync.py +140 -0
  62. package/src/core/code-flow/scripts/cf_task_runtime.py +184 -0
  63. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +115 -345
  64. package/src/core/code-flow/specs/backend/code-quality-performance.md +13 -1
  65. package/src/core/code-flow/specs/backend/database.md +13 -1
  66. package/src/core/code-flow/specs/backend/directory-structure.md +13 -1
  67. package/src/core/code-flow/specs/backend/logging.md +13 -1
  68. package/src/core/code-flow/specs/backend/platform-rules.md +13 -1
  69. package/src/core/code-flow/specs/frontend/component-specs.md +13 -1
  70. package/src/core/code-flow/specs/frontend/directory-structure.md +13 -1
  71. package/src/core/code-flow/specs/frontend/quality-standards.md +13 -1
  72. package/src/core/code-flow/specs/shared/design/design-frontend.md +10 -0
  73. package/src/core/code-flow/specs/shared/design/design-full.md +10 -0
  74. package/src/core/code-flow/specs/shared/design/design-lite.md +10 -0
  75. package/src/core/code-flow/specs/shared/prd-template.md +14 -3
  76. package/src/migrate/spec-workflow.js +238 -0
  77. package/src/adapters/claude/commands/cf-inject.md +0 -55
  78. package/src/adapters/codex/skills/cf-inject/SKILL.md +0 -56
  79. package/src/adapters/costrict/commands/cf-inject.md +0 -55
  80. package/src/adapters/opencode/commands/cf-inject.md +0 -59
  81. package/src/core/code-flow/scripts/cf_inject_hook.py +0 -178
  82. package/src/core/code-flow/scripts/cf_session_hook.py +0 -42
@@ -0,0 +1,1470 @@
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
+ index += 1
253
+ if "R" in code or "C" in code:
254
+ # porcelain -z rename/copy emits two records: "XY <new>" then "<old>"
255
+ if index >= len(records) or not records[index]:
256
+ raise ContextError("invalid_git_status", "git.status", f"{code} {path}", root)
257
+ old_path = records[index]
258
+ index += 1
259
+ changes[path] = _status_name(code)
260
+ changes[old_path] = "deleted"
261
+ continue
262
+ changes[path] = _status_name(code)
263
+ return changes
264
+
265
+
266
+ def _is_excluded(path: str, patterns: Sequence[str]) -> bool:
267
+ return any(fnmatchcase(path, pattern) for pattern in patterns)
268
+
269
+
270
+ def _path_hash(root: str, path: str) -> str:
271
+ target = Path(root) / path
272
+ digest = hashlib.sha256()
273
+ if target.is_file():
274
+ with target.open("rb") as handle:
275
+ for chunk in iter(lambda: handle.read(65536), b""):
276
+ digest.update(chunk)
277
+ return digest.hexdigest()
278
+
279
+
280
+ def _active_data(active: ActiveTask) -> dict[str, object]:
281
+ changes = {
282
+ path: {"status": item.status, "content_sha256": item.content_sha256}
283
+ for path, item in sorted(active.baseline.preexisting_changes.items())
284
+ }
285
+ return {
286
+ "version": active.version,
287
+ "task_dir": active.task_dir,
288
+ "task_id": active.task_id,
289
+ "status": active.status,
290
+ "context_sha256": active.context_sha256,
291
+ "baseline": {
292
+ "head": active.baseline.head,
293
+ "captured_at": active.baseline.captured_at,
294
+ "preexisting_changes": changes,
295
+ },
296
+ "owned_paths": list(active.owned_paths),
297
+ "excluded_paths": list(active.excluded_paths),
298
+ }
299
+
300
+
301
+ def _snapshot_from_data(value: object, path: str) -> PathSnapshot:
302
+ data = _mapping(value, "preexisting_changes[]", path)
303
+ return PathSnapshot(
304
+ _string(data.get("status"), "status", path),
305
+ _string(data.get("content_sha256"), "content_sha256", path),
306
+ )
307
+
308
+
309
+ def _active_from_data(value: object, path: str) -> ActiveTask:
310
+ data = _mapping(value, "active", path)
311
+ baseline_data = _mapping(data.get("baseline"), "baseline", path)
312
+ raw_changes = _mapping(
313
+ baseline_data.get("preexisting_changes"), "baseline.preexisting_changes", path
314
+ )
315
+ version = data.get("version")
316
+ head = baseline_data.get("head")
317
+ if version != 1 or (head is not None and not isinstance(head, str)):
318
+ raise ContextError("invalid_active_marker", "version/baseline.head", "schema 不受支持", path)
319
+ status = _string(data.get("status"), "status", path)
320
+ if status not in ACTIVE_STATUSES:
321
+ raise ContextError("invalid_active_marker", "status", status, path)
322
+ baseline = ActiveBaseline(
323
+ head,
324
+ _string(baseline_data.get("captured_at"), "captured_at", path),
325
+ {key: _snapshot_from_data(item, path) for key, item in raw_changes.items()},
326
+ )
327
+ return ActiveTask(
328
+ 1,
329
+ _string(data.get("task_dir"), "task_dir", path),
330
+ _string(data.get("task_id"), "task_id", path),
331
+ status,
332
+ _string(data.get("context_sha256"), "context_sha256", path),
333
+ baseline,
334
+ tuple(_string(item, "owned_paths[]", path) for item in _sequence(data.get("owned_paths"), "owned_paths", path)),
335
+ tuple(_string(item, "excluded_paths[]", path) for item in _sequence(data.get("excluded_paths"), "excluded_paths", path)),
336
+ )
337
+
338
+
339
+ def save_active_task(root: str, active: ActiveTask) -> None:
340
+ marker, unused_lock = _active_paths(root)
341
+ del unused_lock
342
+ marker.parent.mkdir(parents=True, exist_ok=True)
343
+ _atomic_text(marker, json.dumps(_active_data(active), ensure_ascii=False, indent=2) + "\n")
344
+
345
+
346
+ def load_active_task(root: str) -> ActiveTask:
347
+ marker, unused_lock = _active_paths(root)
348
+ del unused_lock
349
+ try:
350
+ value = json.loads(marker.read_text(encoding="utf-8"))
351
+ return _active_from_data(value, str(marker))
352
+ except ContextError:
353
+ raise
354
+ except (OSError, json.JSONDecodeError) as exc:
355
+ raise ContextError("invalid_active_marker", "active", str(exc), str(marker)) from exc
356
+
357
+
358
+ def resync_active_hash(root: str, task_dir: str, current_sha256: str) -> bool:
359
+ """Re-sync the active marker's context hash after toolchain-initiated
360
+ context edits (bind/decision/refresh) so they never surface as
361
+ unrecoverable active_context_drift. No-op without an active marker or when
362
+ the hash already matches; never raises on a missing marker."""
363
+ marker = Path(root) / ".code-flow" / ".active-task.json"
364
+ if not marker.exists():
365
+ return False
366
+ active = load_active_task(root)
367
+ if active.context_sha256 == current_sha256:
368
+ return False
369
+ save_active_task(root, replace(active, context_sha256=current_sha256))
370
+ return True
371
+
372
+
373
+ def _find_root(task_dir: str) -> Optional[str]:
374
+ """Walk up from a task dir to the project root that owns its marker."""
375
+ current = Path(task_dir).resolve()
376
+ for candidate in (current, *current.parents):
377
+ if (candidate / ".code-flow" / ".active-task.json").exists():
378
+ return str(candidate)
379
+ return None
380
+
381
+
382
+ def _resync_after_save(args: argparse.Namespace, context_path: Path) -> bool:
383
+ """Post-save marker re-sync used by CLI commands that persist the context."""
384
+ root = getattr(args, "root", "") or _find_root(str(context_path.parent)) or ""
385
+ if not root:
386
+ return False
387
+ current = context_sha256(load_context(str(context_path)))
388
+ return resync_active_hash(root, str(context_path.parent), current)
389
+
390
+
391
+ def _lock_stale(lock: Path) -> bool:
392
+ """A lock is stale when its file is unreadable, carries no valid PID, or the
393
+ owning process is gone. A lock we cannot prove dead is never deleted (PID
394
+ reuse is accepted as a rare race)."""
395
+ try:
396
+ data = json.loads(lock.read_text(encoding="utf-8"))
397
+ except (OSError, json.JSONDecodeError):
398
+ return True
399
+ pid = data.get("pid")
400
+ if not isinstance(pid, int) or pid <= 0:
401
+ return True
402
+ try:
403
+ os.kill(pid, 0)
404
+ except ProcessLookupError:
405
+ return True
406
+ except (PermissionError, OSError):
407
+ return False
408
+ return False
409
+
410
+
411
+ def _acquire_active_lock(root: str) -> Path:
412
+ marker, lock = _active_paths(root)
413
+ lock.parent.mkdir(parents=True, exist_ok=True)
414
+ descriptor = -1
415
+ for _attempt in range(2):
416
+ try:
417
+ descriptor = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
418
+ break
419
+ except FileExistsError:
420
+ if not _lock_stale(lock):
421
+ raise ContextError("active_lock_exists", "active.lock", "运行 doctor 检查残留 lock", str(lock)) from None
422
+ try:
423
+ lock.unlink()
424
+ except OSError as exc:
425
+ raise ContextError("active_lock_exists", "active.lock", f"残留 lock 清理失败: {exc}", str(lock)) from exc
426
+ else:
427
+ raise ContextError("active_lock_exists", "active.lock", "残留 lock 无法清理", str(lock))
428
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
429
+ handle.write(json.dumps({"pid": os.getpid(), "marker": str(marker)}))
430
+ return lock
431
+
432
+
433
+ def _release_active_lock(lock: Path) -> None:
434
+ try:
435
+ lock.unlink()
436
+ except FileNotFoundError:
437
+ return
438
+
439
+
440
+ def _business_changes(root: str, excludes: Sequence[str]) -> Mapping[str, str]:
441
+ return {
442
+ path: status
443
+ for path, status in _git_changes(root).items()
444
+ if not _is_excluded(path, excludes)
445
+ }
446
+
447
+
448
+ def _new_active(
449
+ root: str,
450
+ task_dir: str,
451
+ task_id: str,
452
+ context_sha256: str,
453
+ owned_preexisting: Sequence[str],
454
+ ) -> ActiveTask:
455
+ changes = _business_changes(root, DEFAULT_ACTIVE_EXCLUDES)
456
+ owned = tuple(sorted(set(owned_preexisting)))
457
+ unknown = tuple(path for path in owned if path not in changes)
458
+ unowned = tuple(path for path in changes if path not in owned)
459
+ if unknown:
460
+ raise ContextError("unknown_owned_path", "owned_paths", ", ".join(unknown), root)
461
+ if unowned:
462
+ raise ContextError("unowned_changes", "owned_paths", ", ".join(unowned), root)
463
+ snapshots = {
464
+ path: PathSnapshot(status, _path_hash(root, path)) for path, status in changes.items()
465
+ }
466
+ baseline = ActiveBaseline(_git_head(root), _now(), snapshots)
467
+ return ActiveTask(
468
+ 1, task_dir, task_id, "activating", context_sha256, baseline, owned, DEFAULT_ACTIVE_EXCLUDES
469
+ )
470
+
471
+
472
+ def start_active_task(
473
+ root: str,
474
+ task_dir: str,
475
+ task_id: str,
476
+ context_sha256: str,
477
+ owned_preexisting: Sequence[str] = (),
478
+ ) -> ActiveTask:
479
+ marker, unused_lock = _active_paths(root)
480
+ del unused_lock
481
+ lock = _acquire_active_lock(root)
482
+ try:
483
+ if marker.exists():
484
+ raise ContextError("active_exists", "active", "当前 worktree 已有 active TASK", str(marker))
485
+ active = _new_active(root, task_dir, task_id, context_sha256, owned_preexisting)
486
+ save_active_task(root, active)
487
+ active = replace(active, status="active")
488
+ save_active_task(root, active)
489
+ return active
490
+ finally:
491
+ _release_active_lock(lock)
492
+
493
+
494
+ def _transition_active(root: str, expected: Sequence[str], target: str) -> ActiveTask:
495
+ lock = _acquire_active_lock(root)
496
+ try:
497
+ active = load_active_task(root)
498
+ if active.status not in expected:
499
+ raise ContextError("invalid_active_transition", "status", f"{active.status} -> {target}", root)
500
+ current_head = _git_head(root)
501
+ if active.baseline.head != current_head:
502
+ # Mid-task commits are a normal workflow; re-baseline instead of
503
+ # bricking pause/resume/complete on any unrelated commit.
504
+ active = replace(active, baseline=replace(active.baseline, head=current_head))
505
+ updated = replace(active, status=target)
506
+ save_active_task(root, updated)
507
+ return updated
508
+ finally:
509
+ _release_active_lock(lock)
510
+
511
+
512
+ def pause_active_task(root: str) -> ActiveTask:
513
+ return _transition_active(root, ("active",), "paused")
514
+
515
+
516
+ def block_active_task(root: str) -> ActiveTask:
517
+ return _transition_active(root, ("active",), "blocked")
518
+
519
+
520
+ def resume_active_task(root: str) -> ActiveTask:
521
+ return _transition_active(root, ("paused", "blocked"), "active")
522
+
523
+
524
+ def current_owned_paths(root: str, active: ActiveTask) -> tuple[str, ...]:
525
+ changes = _business_changes(root, active.excluded_paths)
526
+ return tuple(sorted(set(active.owned_paths).union(changes)))
527
+
528
+
529
+ def complete_active_task(root: str, gate_passed: bool) -> ActiveTask:
530
+ if not gate_passed:
531
+ raise ContextError("gate_failed", "gate", "Done Gate 未通过", root)
532
+ completed = _transition_active(root, ("active",), "completed")
533
+ marker, unused_lock = _active_paths(root)
534
+ del unused_lock
535
+ try:
536
+ marker.unlink()
537
+ except OSError as exc:
538
+ raise ContextError("active_cleanup_failed", "active", str(exc), str(marker)) from exc
539
+ return completed
540
+
541
+
542
+ def doctor_active_task(
543
+ root: str, expected_context_sha256: str, abandon: bool = False, resync: bool = False
544
+ ) -> DoctorResult:
545
+ marker, lock = _active_paths(root)
546
+ try:
547
+ active = load_active_task(root)
548
+ except ContextError as exc:
549
+ raise ContextError("recovery_required", "active", exc.message, str(marker)) from exc
550
+ if abandon:
551
+ marker.unlink()
552
+ _release_active_lock(lock)
553
+ return DoctorResult("abandoned", replace(active, status="completed"))
554
+ if resync:
555
+ # Self-heal: the context file is authoritative and the marker hash is a
556
+ # cache. Accept the current context when the marker is the only
557
+ # inconsistency; bind/decision/refresh already re-sync automatically,
558
+ # so this covers legacy markers and manual context edits.
559
+ repaired = replace(active, status="active", context_sha256=expected_context_sha256)
560
+ save_active_task(root, repaired)
561
+ _release_active_lock(lock)
562
+ return DoctorResult("resynced", repaired)
563
+ proven = active.context_sha256 == expected_context_sha256
564
+ proven = proven and active.baseline.head == _git_head(root)
565
+ if active.status != "activating" or not proven:
566
+ raise ContextError("recovery_required", "active", "状态或 hash 无法证明可自动恢复", str(marker))
567
+ repaired = replace(active, status="active")
568
+ save_active_task(root, repaired)
569
+ _release_active_lock(lock)
570
+ return DoctorResult("resumed", repaired)
571
+
572
+
573
+ def _mapping(value: object, field: str, path: str) -> Mapping[str, object]:
574
+ if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
575
+ raise ContextError("invalid_context", field, "必须是字符串 key mapping", path)
576
+ return value
577
+
578
+
579
+ def _sequence(value: object, field: str, path: str) -> Sequence[object]:
580
+ if not isinstance(value, list):
581
+ raise ContextError("invalid_context", field, "必须是列表", path)
582
+ return value
583
+
584
+
585
+ def _string(value: object, field: str, path: str) -> str:
586
+ if not isinstance(value, str) or not value.strip():
587
+ raise ContextError("invalid_context", field, "必须是非空字符串", path)
588
+ return value.strip()
589
+
590
+
591
+ def _decision_string(value: object, field: str, path: str) -> str:
592
+ if not isinstance(value, str) or not value.strip():
593
+ raise ContextError("invalid_decision", field, "必须是非空字符串", path)
594
+ return value.strip()
595
+
596
+
597
+ def _timestamp(value: str, field: str) -> None:
598
+ try:
599
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
600
+ except ValueError as exc:
601
+ raise ContextError("invalid_decision", field, "必须是 RFC3339 时间") from exc
602
+ if parsed.tzinfo is None:
603
+ raise ContextError("invalid_decision", field, "必须包含时区")
604
+
605
+
606
+ def _validate_decision(decision: Decision) -> None:
607
+ if decision.kind not in DECISION_KINDS:
608
+ raise ContextError("invalid_decision", "decision.kind", f"不支持 {decision.kind!r}")
609
+ for field in ("reason", "confirmed_by", "confirmed_at", "source"):
610
+ if not getattr(decision, field).strip():
611
+ raise ContextError("invalid_decision", f"decision.{field}", "必须是非空字符串")
612
+ identity = decision.confirmed_by.lower().split(":", 1)[0]
613
+ if identity in _AGENT_IDENTITIES:
614
+ raise ContextError("agent_confirmation_forbidden", "decision.confirmed_by", "Agent 不能自行确认")
615
+ _timestamp(decision.confirmed_at, "decision.confirmed_at")
616
+ if decision.kind == "waived" and not decision.expires_at:
617
+ raise ContextError("invalid_decision", "decision.expires_at", "waived 必须有失效时间")
618
+ if decision.expires_at:
619
+ _timestamp(decision.expires_at, "decision.expires_at")
620
+
621
+
622
+ def _decision_from_data(value: object, path: str) -> Optional[Decision]:
623
+ if value is None:
624
+ return None
625
+ data = _mapping(value, "decision", path)
626
+ expires = data.get("expires_at")
627
+ if expires is not None and not isinstance(expires, str):
628
+ raise ContextError("invalid_decision", "decision.expires_at", "必须是字符串或 null", path)
629
+ decision = Decision(
630
+ _decision_string(data.get("kind"), "decision.kind", path),
631
+ _decision_string(data.get("reason"), "decision.reason", path),
632
+ _decision_string(data.get("confirmed_by"), "decision.confirmed_by", path),
633
+ _decision_string(data.get("confirmed_at"), "decision.confirmed_at", path),
634
+ _decision_string(data.get("source"), "decision.source", path),
635
+ expires,
636
+ )
637
+ _validate_decision(decision)
638
+ return decision
639
+
640
+
641
+ def _decision_data(decision: Optional[Decision]) -> Optional[dict[str, object]]:
642
+ if decision is None:
643
+ return None
644
+ return {
645
+ "kind": decision.kind,
646
+ "reason": decision.reason,
647
+ "confirmed_by": decision.confirmed_by,
648
+ "confirmed_at": decision.confirmed_at,
649
+ "source": decision.source,
650
+ "expires_at": decision.expires_at,
651
+ }
652
+
653
+
654
+ def _artifact_from_data(value: object, path: str) -> ArtifactRef:
655
+ data = _mapping(value, "refs[]", path)
656
+ return ArtifactRef(
657
+ _string(data.get("artifact"), "refs[].artifact", path),
658
+ _string(data.get("section_id"), "refs[].section_id", path),
659
+ _string(data.get("item_id"), "refs[].item_id", path),
660
+ _string(data.get("artifact_sha256"), "refs[].artifact_sha256", path),
661
+ )
662
+
663
+
664
+ def _artifact_data(reference: ArtifactRef) -> dict[str, str]:
665
+ return {
666
+ "artifact": reference.artifact,
667
+ "section_id": reference.section_id,
668
+ "item_id": reference.item_id,
669
+ "artifact_sha256": reference.artifact_sha256,
670
+ }
671
+
672
+
673
+ def _stage_from_data(value: object, field: str, path: str) -> RuleStageStatus:
674
+ data = _mapping(value, field, path)
675
+ status = _string(data.get("status"), f"{field}.status", path)
676
+ if status not in STAGE_STATUSES:
677
+ raise ContextError("invalid_context", f"{field}.status", f"不支持 {status!r}", path)
678
+ refs = tuple(_artifact_from_data(item, path) for item in _sequence(data.get("refs", []), f"{field}.refs", path))
679
+ evidence = tuple(_mapping(item, f"{field}.evidence[]", path) for item in _sequence(data.get("evidence", []), f"{field}.evidence", path))
680
+ decision = _decision_from_data(data.get("decision"), path)
681
+ if status == "applied" and not refs:
682
+ raise ContextError("invalid_context", f"{field}.refs", "applied 必须有 artifact ref", path)
683
+ if status in ("not_applicable", "waived") and decision is None:
684
+ raise ContextError("invalid_context", f"{field}.decision", f"{status} 必须有确认记录", path)
685
+ return RuleStageStatus(status, refs, decision, evidence)
686
+
687
+
688
+ def _stage_data(status: RuleStageStatus) -> dict[str, object]:
689
+ return {
690
+ "status": status.status,
691
+ "refs": [_artifact_data(item) for item in status.refs],
692
+ "decision": _decision_data(status.decision),
693
+ "evidence": [dict(item) for item in status.evidence],
694
+ }
695
+
696
+
697
+ def _rule_from_data(value: object, path: str) -> RuleBinding:
698
+ data = _mapping(value, "rules[]", path)
699
+ stages = _mapping(data.get("stage_status"), "rules[].stage_status", path)
700
+ parsed = {stage: _stage_from_data(item, f"stage_status.{stage}", path) for stage, item in stages.items()}
701
+ return RuleBinding(
702
+ _string(data.get("ref"), "rules[].ref", path),
703
+ _string(data.get("summary"), "rules[].summary", path),
704
+ _string(data.get("text_sha256"), "rules[].text_sha256", path),
705
+ _string(data.get("enforcement"), "rules[].enforcement", path),
706
+ _string(data.get("verifier_ref"), "rules[].verifier_ref", path),
707
+ parsed,
708
+ )
709
+
710
+
711
+ def _rule_data(rule: RuleBinding) -> dict[str, object]:
712
+ return {
713
+ "ref": rule.ref,
714
+ "summary": rule.summary,
715
+ "text_sha256": rule.text_sha256,
716
+ "enforcement": rule.enforcement,
717
+ "verifier_ref": rule.verifier_ref,
718
+ "stage_status": {stage: _stage_data(status) for stage, status in rule.stage_status.items()},
719
+ }
720
+
721
+
722
+ def _hashes_from_data(value: object, path: str) -> SpecHashes:
723
+ data = _mapping(value, "hashes", path)
724
+ return SpecHashes(
725
+ _string(data.get("file_sha256"), "hashes.file_sha256", path),
726
+ _string(data.get("metadata_sha256"), "hashes.metadata_sha256", path),
727
+ _string(data.get("rules_sha256"), "hashes.rules_sha256", path),
728
+ )
729
+
730
+
731
+ def _binding_from_data(value: object, path: str) -> SpecBinding:
732
+ data = _mapping(value, "bindings[]", path)
733
+ stages = tuple(_string(item, "bindings[].stages[]", path) for item in _sequence(data.get("stages"), "bindings[].stages", path))
734
+ rules = tuple(_rule_from_data(item, path) for item in _sequence(data.get("rules"), "bindings[].rules", path))
735
+ return SpecBinding(
736
+ _string(data.get("spec_id"), "bindings[].spec_id", path),
737
+ _string(data.get("path"), "bindings[].path", path),
738
+ _hashes_from_data(data.get("hashes"), path),
739
+ _string(data.get("selected_by"), "bindings[].selected_by", path),
740
+ _string(data.get("reason"), "bindings[].reason", path),
741
+ _string(data.get("enforcement"), "bindings[].enforcement", path),
742
+ stages,
743
+ rules,
744
+ _string(data.get("status", "active"), "bindings[].status", path),
745
+ )
746
+
747
+
748
+ def _binding_data(binding: SpecBinding) -> dict[str, object]:
749
+ return {
750
+ "spec_id": binding.spec_id,
751
+ "path": binding.path,
752
+ "status": binding.status,
753
+ "hashes": {
754
+ "file_sha256": binding.hashes.file_sha256,
755
+ "metadata_sha256": binding.hashes.metadata_sha256,
756
+ "rules_sha256": binding.hashes.rules_sha256,
757
+ },
758
+ "selected_by": binding.selected_by,
759
+ "reason": binding.reason,
760
+ "enforcement": binding.enforcement,
761
+ "stages": list(binding.stages),
762
+ "rules": [_rule_data(rule) for rule in binding.rules],
763
+ }
764
+
765
+
766
+ def context_to_data(context: SpecContext) -> dict[str, object]:
767
+ return {
768
+ "version": context.version,
769
+ "task": context.task,
770
+ "enforcement": context.enforcement,
771
+ "updated_at": context.updated_at,
772
+ "sources": [{"type": item.type, "ref": item.ref} for item in context.sources],
773
+ "bindings": [_binding_data(binding) for binding in context.bindings],
774
+ }
775
+
776
+
777
+ def context_to_identity(context: SpecContext) -> dict[str, object]:
778
+ """Stable identity projection for marker drift detection.
779
+
780
+ Excludes volatile runtime state (`updated_at`, `stage_status` including
781
+ evidence timestamps) so the active marker only invalidates when the bound
782
+ rules themselves change (rule/spec text, bindings, sources).
783
+ """
784
+ return {
785
+ "version": context.version,
786
+ "task": context.task,
787
+ "enforcement": context.enforcement,
788
+ "sources": [{"type": item.type, "ref": item.ref} for item in context.sources],
789
+ "bindings": [
790
+ {
791
+ "spec_id": binding.spec_id,
792
+ "path": binding.path,
793
+ "status": binding.status,
794
+ "hashes": {
795
+ "file_sha256": binding.hashes.file_sha256,
796
+ "metadata_sha256": binding.hashes.metadata_sha256,
797
+ "rules_sha256": binding.hashes.rules_sha256,
798
+ },
799
+ "selected_by": binding.selected_by,
800
+ "reason": binding.reason,
801
+ "enforcement": binding.enforcement,
802
+ "stages": list(binding.stages),
803
+ "rules": [
804
+ {
805
+ "ref": rule.ref,
806
+ "summary": rule.summary,
807
+ "text_sha256": rule.text_sha256,
808
+ "enforcement": rule.enforcement,
809
+ "verifier_ref": rule.verifier_ref,
810
+ }
811
+ for rule in binding.rules
812
+ ],
813
+ }
814
+ for binding in context.bindings
815
+ ],
816
+ }
817
+
818
+
819
+ def context_sha256(context: SpecContext) -> str:
820
+ """Deterministic identity hash binding the active marker to the bound rules.
821
+
822
+ Computed from the stable identity projection (never volatile runtime state),
823
+ so evidence timestamps and stage statuses cannot drift the marker.
824
+ """
825
+ data = yaml.safe_dump(context_to_identity(context), sort_keys=True, allow_unicode=True).encode()
826
+ return hashlib.sha256(data).hexdigest()
827
+
828
+
829
+ def new_context(task: str, sources: Sequence[tuple[str, str]]) -> SpecContext:
830
+ if not task.strip():
831
+ raise ContextError("invalid_context", "task", "必须是非空字符串")
832
+ parsed = tuple(ContextSource(_string(kind, "sources[].type", ""), _string(ref, "sources[].ref", "")) for kind, ref in sources)
833
+ return SpecContext(CONTEXT_VERSION, task.strip(), "required", _now(), parsed, ())
834
+
835
+
836
+ def load_context(path: str) -> SpecContext:
837
+ try:
838
+ loaded = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
839
+ except (OSError, UnicodeError, yaml.YAMLError) as exc:
840
+ raise ContextError("context_read_error", "file", str(exc), path) from exc
841
+ data = _mapping(loaded, "context", path)
842
+ version = data.get("version")
843
+ if version != CONTEXT_VERSION:
844
+ raise ContextError("unsupported_version", "version", f"仅支持 {CONTEXT_VERSION},收到 {version!r}", path)
845
+ source_values = _sequence(data.get("sources", []), "sources", path)
846
+ sources = tuple(
847
+ ContextSource(
848
+ _string(_mapping(item, "sources[]", path).get("type"), "sources[].type", path),
849
+ _string(_mapping(item, "sources[]", path).get("ref"), "sources[].ref", path),
850
+ )
851
+ for item in source_values
852
+ )
853
+ bindings = tuple(_binding_from_data(item, path) for item in _sequence(data.get("bindings", []), "bindings", path))
854
+ return SpecContext(
855
+ CONTEXT_VERSION,
856
+ _string(data.get("task"), "task", path),
857
+ _string(data.get("enforcement"), "enforcement", path),
858
+ _string(data.get("updated_at"), "updated_at", path),
859
+ sources,
860
+ bindings,
861
+ )
862
+
863
+
864
+ def save_context(path: str, context: SpecContext) -> None:
865
+ target = Path(path)
866
+ target.parent.mkdir(parents=True, exist_ok=True)
867
+ text = yaml.safe_dump(context_to_data(context), sort_keys=False, allow_unicode=True)
868
+ try:
869
+ _atomic_text(target, text)
870
+ except ContextError as exc:
871
+ raise ContextError("context_write_error", "file", str(exc), path) from exc
872
+
873
+
874
+ def _binding_from_input(selection: BindingInput) -> SpecBinding:
875
+ if not selection.selected_by.strip() or not selection.reason.strip():
876
+ raise ContextError("invalid_binding", "selection", "selected_by/reason 必须非空")
877
+ candidate = selection.candidate
878
+ verifier_by_rule = {item.rule: item for item in candidate.metadata.verifiers}
879
+ rules: list[RuleBinding] = []
880
+ for rule in candidate.metadata.rules:
881
+ if rule.enforcement == "informational":
882
+ continue
883
+ enforcement = "advisory" if candidate.metadata.enforcement == "advisory" else rule.enforcement
884
+ statuses = {
885
+ stage: RuleStageStatus("pending", (), None, ()) for stage in candidate.metadata.stages
886
+ }
887
+ verifier = verifier_by_rule.get(rule.ref)
888
+ verifier_ref = f"{candidate.spec_id}#{rule.ref}" if verifier is not None else "advisory:none"
889
+ rules.append(RuleBinding(rule.ref, rule.text, rule.text_sha256, enforcement, verifier_ref, statuses))
890
+ return SpecBinding(
891
+ candidate.spec_id,
892
+ candidate.path,
893
+ candidate.metadata.hashes,
894
+ selection.selected_by.strip(),
895
+ selection.reason.strip(),
896
+ candidate.metadata.enforcement,
897
+ candidate.metadata.stages,
898
+ tuple(rules),
899
+ )
900
+
901
+
902
+ def _rule_from_metadata(spec_id: str, metadata: SpecMetadata, rule: SpecRule) -> RuleBinding:
903
+ enforcement = "advisory" if metadata.enforcement == "advisory" else rule.enforcement
904
+ statuses = {stage: RuleStageStatus("pending", (), None, ()) for stage in metadata.stages}
905
+ has_verifier = any(item.rule == rule.ref for item in metadata.verifiers)
906
+ verifier_ref = f"{spec_id}#{rule.ref}" if has_verifier else "advisory:none"
907
+ return RuleBinding(rule.ref, rule.text, rule.text_sha256, enforcement, verifier_ref, statuses)
908
+
909
+
910
+ def bind_specs(context: SpecContext, selections: Sequence[BindingInput]) -> SpecContext:
911
+ bindings = {binding.spec_id: binding for binding in context.bindings}
912
+ for selection in selections:
913
+ incoming = _binding_from_input(selection)
914
+ existing = bindings.get(incoming.spec_id)
915
+ if existing is not None and existing.hashes != incoming.hashes:
916
+ raise ContextError(
917
+ "stale_binding",
918
+ "selection",
919
+ f"{incoming.spec_id} 已绑定版本发生变化,请先 refresh",
920
+ )
921
+ if existing is not None:
922
+ incoming = replace(
923
+ existing,
924
+ selected_by=incoming.selected_by,
925
+ reason=incoming.reason,
926
+ enforcement=incoming.enforcement,
927
+ stages=incoming.stages,
928
+ )
929
+ bindings[incoming.spec_id] = incoming
930
+ ordered = tuple(bindings[key] for key in sorted(bindings))
931
+ if ordered == context.bindings:
932
+ return context
933
+ return replace(context, updated_at=_now(), bindings=ordered)
934
+
935
+
936
+ def refresh_missing_specs(context: SpecContext, root: str) -> SpecContext:
937
+ spec_root = Path(root) / ".code-flow" / "specs"
938
+ bindings: list[SpecBinding] = []
939
+ for binding in context.bindings:
940
+ if (spec_root / binding.path).is_file():
941
+ bindings.append(binding)
942
+ continue
943
+ rules = []
944
+ for rule in binding.rules:
945
+ stages = {stage: replace(status, status="stale") for stage, status in rule.stage_status.items()}
946
+ rules.append(replace(rule, stage_status=stages))
947
+ bindings.append(replace(binding, status="missing", rules=tuple(rules)))
948
+ return replace(context, updated_at=_now(), bindings=tuple(bindings))
949
+
950
+
951
+ def _stale_rule(rule: RuleBinding) -> RuleBinding:
952
+ statuses = {stage: replace(status, status="stale") for stage, status in rule.stage_status.items()}
953
+ return replace(rule, stage_status=statuses)
954
+
955
+
956
+ def _removed_rule(rule: RuleBinding) -> RuleBinding:
957
+ statuses = {
958
+ stage: status
959
+ if status.status in ("not_applicable", "waived") and status.decision is not None
960
+ else replace(status, status="stale")
961
+ for stage, status in rule.stage_status.items()
962
+ }
963
+ return replace(rule, stage_status=statuses)
964
+
965
+
966
+ def _file_sha256(path: Path) -> str:
967
+ try:
968
+ return hashlib.sha256(path.read_bytes()).hexdigest()
969
+ except OSError as exc:
970
+ raise ContextError("artifact_read_error", "artifact", str(exc), str(path)) from exc
971
+
972
+
973
+ def _refresh_artifacts(
974
+ spec_id: str, rule: RuleBinding, artifact_root: Path
975
+ ) -> tuple[RuleBinding, tuple[DriftChange, ...]]:
976
+ statuses = dict(rule.stage_status)
977
+ changes: list[DriftChange] = []
978
+ for stage, status in rule.stage_status.items():
979
+ for reference in status.refs:
980
+ path = artifact_root / reference.artifact
981
+ current = _file_sha256(path) if path.is_file() else "missing"
982
+ if current == reference.artifact_sha256:
983
+ continue
984
+ if status.status == "applied" and current != "missing":
985
+ statuses[stage] = replace(
986
+ status,
987
+ refs=tuple(
988
+ replace(item, artifact_sha256=current) if item is reference else item
989
+ for item in status.refs
990
+ ),
991
+ )
992
+ else:
993
+ statuses[stage] = replace(status, status="stale")
994
+ changes.append(
995
+ DriftChange("artifact_changed", spec_id, rule.ref, reference.artifact_sha256, current, (stage,))
996
+ )
997
+ break
998
+ return replace(rule, stage_status=statuses), tuple(changes)
999
+
1000
+
1001
+ def _refresh_existing_rule(
1002
+ binding: SpecBinding, existing: RuleBinding, current: SpecRule, metadata: SpecMetadata
1003
+ ) -> tuple[RuleBinding, Optional[DriftChange]]:
1004
+ enforcement = "advisory" if metadata.enforcement == "advisory" else current.enforcement
1005
+ has_verifier = any(item.rule == current.ref for item in metadata.verifiers)
1006
+ verifier_ref = f"{binding.spec_id}#{current.ref}" if has_verifier else "advisory:none"
1007
+ updated = replace(
1008
+ existing,
1009
+ summary=current.text,
1010
+ text_sha256=current.text_sha256,
1011
+ enforcement=enforcement,
1012
+ verifier_ref=verifier_ref,
1013
+ )
1014
+ if existing.text_sha256 == current.text_sha256:
1015
+ return updated, None
1016
+ change = DriftChange(
1017
+ "rule_changed", binding.spec_id, current.ref, existing.text_sha256, current.text_sha256, tuple(existing.stage_status)
1018
+ )
1019
+ return _stale_rule(updated), change
1020
+
1021
+
1022
+ def _refresh_binding(
1023
+ binding: SpecBinding, spec_root: Path, artifact_root: Path
1024
+ ) -> tuple[SpecBinding, tuple[DriftChange, ...]]:
1025
+ path = spec_root / binding.path
1026
+ if not path.is_file():
1027
+ stale = replace(binding, status="missing", rules=tuple(_stale_rule(rule) for rule in binding.rules))
1028
+ change = DriftChange("spec_missing", binding.spec_id, "", binding.hashes.file_sha256, "missing", binding.stages)
1029
+ return stale, (change,)
1030
+ metadata = load_spec_metadata(str(path))
1031
+ existing = {rule.ref: rule for rule in binding.rules}
1032
+ rules: list[RuleBinding] = []
1033
+ changes: list[DriftChange] = []
1034
+ if metadata.hashes.metadata_sha256 != binding.hashes.metadata_sha256:
1035
+ changes.append(
1036
+ DriftChange("metadata_changed", binding.spec_id, "", binding.hashes.metadata_sha256, metadata.hashes.metadata_sha256, ())
1037
+ )
1038
+ for current in metadata.rules:
1039
+ if current.enforcement == "informational":
1040
+ continue
1041
+ prior = existing.pop(current.ref, None)
1042
+ if prior is None:
1043
+ rule = _rule_from_metadata(binding.spec_id, metadata, current)
1044
+ changes.append(DriftChange("rule_added", binding.spec_id, current.ref, "", current.text_sha256, metadata.stages))
1045
+ else:
1046
+ rule, change = _refresh_existing_rule(binding, prior, current, metadata)
1047
+ if change is not None:
1048
+ changes.append(change)
1049
+ rule, artifact_changes = _refresh_artifacts(binding.spec_id, rule, artifact_root)
1050
+ rules.append(rule)
1051
+ changes.extend(artifact_changes)
1052
+ for removed in existing.values():
1053
+ rules.append(_removed_rule(removed))
1054
+ changes.append(DriftChange("rule_removed", binding.spec_id, removed.ref, removed.text_sha256, "missing", tuple(removed.stage_status)))
1055
+ updated = replace(binding, hashes=metadata.hashes, enforcement=metadata.enforcement, stages=metadata.stages, rules=tuple(rules), status="active")
1056
+ return updated, tuple(changes)
1057
+
1058
+
1059
+ def refresh_context(context: SpecContext, root: str, artifact_root: Optional[str] = None) -> DriftResult:
1060
+ specs = Path(root) / ".code-flow" / "specs"
1061
+ artifacts = Path(artifact_root) if artifact_root is not None else Path(root)
1062
+ bindings: list[SpecBinding] = []
1063
+ changes: list[DriftChange] = []
1064
+ for binding in context.bindings:
1065
+ updated, binding_changes = _refresh_binding(binding, specs, artifacts)
1066
+ bindings.append(updated)
1067
+ changes.extend(binding_changes)
1068
+ refreshed = replace(context, updated_at=_now(), bindings=tuple(bindings))
1069
+ if refreshed.bindings == context.bindings:
1070
+ return DriftResult(context, tuple(changes))
1071
+ return DriftResult(refreshed, tuple(changes))
1072
+
1073
+
1074
+ def diff_spec_hashes(context: SpecContext, root: str) -> DriftResult:
1075
+ return refresh_context(context, root)
1076
+
1077
+
1078
+ def apply_decision(
1079
+ context: SpecContext,
1080
+ spec_id: str,
1081
+ rule_ref: str,
1082
+ stage: str,
1083
+ decision: Decision,
1084
+ batch: bool = False,
1085
+ ) -> SpecContext:
1086
+ if batch:
1087
+ raise ContextError("batch_confirmation_forbidden", "batch", "N/A/豁免必须逐项确认")
1088
+ _validate_decision(decision)
1089
+ target_status = {"not_applicable": "not_applicable", "waived": "waived", "manual_verification": "verified"}[decision.kind]
1090
+ found = False
1091
+ bindings: list[SpecBinding] = []
1092
+ for binding in context.bindings:
1093
+ changed_rules: list[RuleBinding] = []
1094
+ for rule in binding.rules:
1095
+ if binding.spec_id == spec_id and rule.ref == rule_ref:
1096
+ if stage not in rule.stage_status:
1097
+ raise ContextError("unknown_stage", "stage", f"Rule 不适用于 {stage}")
1098
+ statuses = dict(rule.stage_status)
1099
+ statuses[stage] = replace(statuses[stage], status=target_status, decision=decision)
1100
+ rule = replace(rule, stage_status=statuses)
1101
+ found = True
1102
+ changed_rules.append(rule)
1103
+ bindings.append(replace(binding, rules=tuple(changed_rules)))
1104
+ if not found:
1105
+ raise ContextError("unknown_rule", "rule_ref", f"未找到 {spec_id}#{rule_ref}")
1106
+ return replace(context, updated_at=_now(), bindings=tuple(bindings))
1107
+
1108
+
1109
+ def _upsert_artifact_ref(
1110
+ references: Sequence[ArtifactRef], replacement: ArtifactRef
1111
+ ) -> tuple[ArtifactRef, ...]:
1112
+ identity = (replacement.artifact, replacement.section_id, replacement.item_id)
1113
+ updated: list[ArtifactRef] = []
1114
+ replaced = False
1115
+ for reference in references:
1116
+ current = (reference.artifact, reference.section_id, reference.item_id)
1117
+ if current != identity:
1118
+ updated.append(reference)
1119
+ elif not replaced:
1120
+ updated.append(replacement)
1121
+ replaced = True
1122
+ if not replaced:
1123
+ updated.append(replacement)
1124
+ return tuple(updated)
1125
+
1126
+
1127
+ def apply_artifact_ref(
1128
+ context: SpecContext,
1129
+ spec_id: str,
1130
+ rule_ref: str,
1131
+ stage: str,
1132
+ reference: ArtifactRef,
1133
+ ) -> SpecContext:
1134
+ found = False
1135
+ bindings: list[SpecBinding] = []
1136
+ for binding in context.bindings:
1137
+ rules: list[RuleBinding] = []
1138
+ for rule in binding.rules:
1139
+ if binding.spec_id == spec_id and rule.ref == rule_ref:
1140
+ if stage not in rule.stage_status:
1141
+ raise ContextError("unknown_stage", "stage", f"Rule 不适用于 {stage}")
1142
+ statuses = dict(rule.stage_status)
1143
+ current = statuses[stage]
1144
+ refs = _upsert_artifact_ref(current.refs, reference)
1145
+ if current.decision is not None:
1146
+ statuses[stage] = replace(current, refs=refs)
1147
+ else:
1148
+ statuses[stage] = replace(current, status="applied", refs=refs)
1149
+ rule = replace(rule, stage_status=statuses)
1150
+ found = True
1151
+ rules.append(rule)
1152
+ bindings.append(replace(binding, rules=tuple(rules)))
1153
+ if not found:
1154
+ raise ContextError("unknown_rule", "rule_ref", f"未找到 {spec_id}#{rule_ref}")
1155
+ return replace(context, updated_at=_now(), bindings=tuple(bindings))
1156
+
1157
+
1158
+ def _json_payload(stream: IO[str]) -> Mapping[str, object]:
1159
+ try:
1160
+ loaded = json.load(stream)
1161
+ except (json.JSONDecodeError, UnicodeError) as exc:
1162
+ raise ContextError("invalid_json", "stdin", str(exc)) from exc
1163
+ return _mapping(loaded, "stdin", "")
1164
+
1165
+
1166
+ def _candidate_data(candidate: SpecCandidate) -> dict[str, object]:
1167
+ return {
1168
+ "spec_id": candidate.spec_id,
1169
+ "path": candidate.path,
1170
+ "scope": candidate.scope,
1171
+ "priority": candidate.priority,
1172
+ "matched_paths": list(candidate.matched_paths),
1173
+ "description": candidate.metadata.description,
1174
+ "enforcement": candidate.metadata.enforcement,
1175
+ "stages": list(candidate.metadata.stages),
1176
+ "hashes": {
1177
+ "file_sha256": candidate.metadata.hashes.file_sha256,
1178
+ "metadata_sha256": candidate.metadata.hashes.metadata_sha256,
1179
+ "rules_sha256": candidate.metadata.hashes.rules_sha256,
1180
+ },
1181
+ "rules": [
1182
+ {"ref": rule.ref, "text": rule.text, "enforcement": rule.enforcement}
1183
+ for rule in candidate.metadata.rules
1184
+ ],
1185
+ }
1186
+
1187
+
1188
+ def _catalog_command(args: argparse.Namespace) -> dict[str, object]:
1189
+ candidates = resolve_candidates(args.root, args.stage, tuple(args.paths))
1190
+ return {
1191
+ "ok": True,
1192
+ "stage": args.stage,
1193
+ "total": len(candidates),
1194
+ "candidates": [_candidate_data(item) for item in candidates],
1195
+ }
1196
+
1197
+
1198
+ def _application_ref(task_dir: str, value: Mapping[str, object]) -> ArtifactRef:
1199
+ artifact = _string(value.get("artifact"), "applications[].artifact", "")
1200
+ base = Path(task_dir).resolve()
1201
+ target = (base / artifact).resolve()
1202
+ if base != target and base not in target.parents:
1203
+ raise ContextError("artifact_outside_task", "applications[].artifact", artifact, task_dir)
1204
+ try:
1205
+ artifact_hash = hashlib.sha256(target.read_bytes()).hexdigest()
1206
+ except OSError as exc:
1207
+ raise ContextError("artifact_read_error", "applications[].artifact", str(exc), str(target)) from exc
1208
+ return ArtifactRef(
1209
+ artifact,
1210
+ _string(value.get("section_id"), "applications[].section_id", ""),
1211
+ _string(value.get("item_id"), "applications[].item_id", ""),
1212
+ artifact_hash,
1213
+ )
1214
+
1215
+
1216
+ def _apply_payload(context: SpecContext, task_dir: str, payload: Mapping[str, object]) -> SpecContext:
1217
+ updated = context
1218
+ for raw in _sequence(payload.get("applications", []), "applications", ""):
1219
+ item = _mapping(raw, "applications[]", "")
1220
+ updated = apply_artifact_ref(
1221
+ updated,
1222
+ _string(item.get("spec_id"), "applications[].spec_id", ""),
1223
+ _string(item.get("rule_ref"), "applications[].rule_ref", ""),
1224
+ _string(item.get("stage"), "applications[].stage", ""),
1225
+ _application_ref(task_dir, item),
1226
+ )
1227
+ return updated
1228
+
1229
+
1230
+ def _decision_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
1231
+ context_path = Path(args.task_dir) / "spec-context.yml"
1232
+ decision = _decision_from_data(payload.get("decision"), str(context_path))
1233
+ if decision is None:
1234
+ raise ContextError("invalid_decision", "decision", "必须提供 decision")
1235
+ context = apply_decision(
1236
+ load_context(str(context_path)),
1237
+ _string(payload.get("spec_id"), "spec_id", ""),
1238
+ _string(payload.get("rule_ref"), "rule_ref", ""),
1239
+ _string(payload.get("stage"), "stage", ""),
1240
+ decision,
1241
+ payload.get("batch") is True,
1242
+ )
1243
+ save_context(str(context_path), context)
1244
+ _resync_after_save(args, context_path)
1245
+ return {"ok": True, "status": context.bindings[0].rules[0].stage_status[_string(payload.get("stage"), "stage", "")].status}
1246
+
1247
+
1248
+ def _bind_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
1249
+ context_path = Path(args.task_dir) / "spec-context.yml"
1250
+ paths = tuple(_string(item, "paths[]", "") for item in _sequence(payload.get("paths", []), "paths", ""))
1251
+ candidates = {item.spec_id: item for item in resolve_candidates(args.root, args.stage, paths)}
1252
+ selections: list[BindingInput] = []
1253
+ for raw in _sequence(payload.get("selections"), "selections", ""):
1254
+ item = _mapping(raw, "selections[]", "")
1255
+ spec_id = _string(item.get("spec_id"), "selections[].spec_id", "")
1256
+ if spec_id not in candidates:
1257
+ raise ContextError("unknown_candidate", "selections[].spec_id", spec_id)
1258
+ selections.append(
1259
+ BindingInput(
1260
+ candidates[spec_id],
1261
+ _string(item.get("selected_by"), "selections[].selected_by", ""),
1262
+ _string(item.get("reason"), "selections[].reason", ""),
1263
+ )
1264
+ )
1265
+ if context_path.exists():
1266
+ context = load_context(str(context_path))
1267
+ else:
1268
+ task = _string(payload.get("task"), "task", "")
1269
+ context = new_context(task, (("cli", "bind"),))
1270
+ context = bind_specs(context, selections)
1271
+ context = _apply_payload(context, args.task_dir, payload)
1272
+ save_context(str(context_path), context)
1273
+ _resync_after_save(args, context_path)
1274
+ applied = sum(
1275
+ status.status == "applied"
1276
+ for binding in context.bindings
1277
+ for rule in binding.rules
1278
+ for status in rule.stage_status.values()
1279
+ )
1280
+ return {"ok": True, "bindings": len(context.bindings), "applied": applied}
1281
+
1282
+
1283
+ def _active_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
1284
+ if args.active_action == "start":
1285
+ owned = tuple(
1286
+ _string(item, "owned_paths[]", "")
1287
+ for item in _sequence(payload.get("owned_paths", []), "owned_paths", "")
1288
+ )
1289
+ active = start_active_task(
1290
+ args.root, args.task_dir, args.task, args.context_sha256, owned
1291
+ )
1292
+ return {"ok": True, "active": _active_data(active)}
1293
+ if args.active_action == "pause":
1294
+ active = pause_active_task(args.root)
1295
+ elif args.active_action == "resume":
1296
+ active = resume_active_task(args.root)
1297
+ elif args.active_action == "block":
1298
+ active = block_active_task(args.root)
1299
+ elif args.active_action == "complete":
1300
+ active = complete_active_task(args.root, payload.get("gate_passed") is True)
1301
+ else:
1302
+ result = doctor_active_task(
1303
+ args.root,
1304
+ args.context_sha256,
1305
+ payload.get("abandon") is True,
1306
+ payload.get("resync") is True,
1307
+ )
1308
+ return {"ok": True, "action": result.action, "active": _active_data(result.active)}
1309
+ return {"ok": True, "active": _active_data(active)}
1310
+
1311
+
1312
+ def _status_command(args: argparse.Namespace) -> dict[str, object]:
1313
+ """Human-readable Spec Context status: task, marker health, gate, rules."""
1314
+ from cf_spec_gate import result_to_data, validate_stage # local import avoids module cycle
1315
+
1316
+ context_path = Path(args.task_dir) / "spec-context.yml"
1317
+ context = load_context(str(context_path))
1318
+ gate = validate_stage(context, "code")
1319
+ root = getattr(args, "root", "") or _find_root(args.task_dir) or ""
1320
+ marker: dict[str, object] = {"exists": False}
1321
+ if root:
1322
+ marker_path = Path(root) / ".code-flow" / ".active-task.json"
1323
+ if marker_path.exists():
1324
+ active = load_active_task(root)
1325
+ marker = {
1326
+ "exists": True,
1327
+ "task_id": active.task_id,
1328
+ "status": active.status,
1329
+ "hash_match": active.context_sha256 == context_sha256(context),
1330
+ "baseline_head": active.baseline.head,
1331
+ }
1332
+ bindings = []
1333
+ for binding in context.bindings:
1334
+ rules = []
1335
+ for rule in binding.rules:
1336
+ status = rule.stage_status.get("code")
1337
+ rules.append(
1338
+ {
1339
+ "ref": f"{binding.spec_id}#{rule.ref}",
1340
+ "enforcement": rule.enforcement,
1341
+ "status": status.status if status else "missing",
1342
+ "verifier": rule.verifier_ref,
1343
+ }
1344
+ )
1345
+ bindings.append({"spec_id": binding.spec_id, "path": binding.path, "status": binding.status, "rules": rules})
1346
+ return {
1347
+ "task": context.task,
1348
+ "context_sha256": context_sha256(context),
1349
+ "gate": result_to_data(gate),
1350
+ "marker": marker,
1351
+ "bindings": bindings,
1352
+ }
1353
+
1354
+
1355
+ def _status_text(data: dict[str, object]) -> str:
1356
+ lines = [f"# {data['task']} — Spec Context 状态"]
1357
+ marker = data["marker"]
1358
+ if marker["exists"]:
1359
+ match = "✓ 一致" if marker["hash_match"] else "✗ 漂移"
1360
+ lines.append(f"- TASK {marker['task_id']}({marker['status']}),marker hash {match}")
1361
+ if not marker["hash_match"]:
1362
+ lines.append(" 下一步: 运行 cf-spec doctor(resync 可自动重同步)")
1363
+ else:
1364
+ lines.append("- 无 active TASK(path/catalog 路由模式)")
1365
+ gate = data["gate"]
1366
+ if gate["decision"] == "pass":
1367
+ lines.append("- code Gate: ✓ pass")
1368
+ else:
1369
+ lines.append(f"- code Gate: ✗ block({len(gate['errors'])} 项)")
1370
+ for issue in gate["errors"]:
1371
+ lines.append(f" ✗ {issue['message']}")
1372
+ for binding in data["bindings"]:
1373
+ lines.append(f"- {binding['spec_id']}({binding['path']})— {binding['status']}")
1374
+ for rule in binding["rules"]:
1375
+ lines.append(f" {rule['status']:<12} {rule['ref']}({rule['verifier']})")
1376
+ return "\n".join(lines)
1377
+
1378
+
1379
+ def _parser() -> argparse.ArgumentParser:
1380
+ parser = argparse.ArgumentParser(prog="cf_spec_context.py")
1381
+ commands = parser.add_subparsers(dest="command", required=True)
1382
+ catalog = commands.add_parser("catalog")
1383
+ catalog.add_argument("--root", required=True)
1384
+ catalog.add_argument("--stage", required=True)
1385
+ catalog.add_argument("--paths", nargs="*", default=())
1386
+ catalog.add_argument("--json", action="store_true")
1387
+ decision = commands.add_parser("decision")
1388
+ decision.add_argument("--task-dir", required=True)
1389
+ decision.add_argument("--json", action="store_true")
1390
+ bind = commands.add_parser("bind")
1391
+ bind.add_argument("--task-dir", required=True)
1392
+ bind.add_argument("--root", required=True)
1393
+ bind.add_argument("--stage", required=True)
1394
+ bind.add_argument("--json", action="store_true")
1395
+ validate = commands.add_parser("validate")
1396
+ validate.add_argument("--task-dir", required=True)
1397
+ validate.add_argument("--json", action="store_true")
1398
+ status = commands.add_parser("status")
1399
+ status.add_argument("--task-dir", required=True)
1400
+ status.add_argument("--root", default="")
1401
+ status.add_argument("--json", action="store_true")
1402
+ for name in ("refresh", "refresh-missing"):
1403
+ refresh = commands.add_parser(name)
1404
+ refresh.add_argument("--task-dir", required=True)
1405
+ refresh.add_argument("--root", required=True)
1406
+ refresh.add_argument("--json", action="store_true")
1407
+ active = commands.add_parser("active")
1408
+ active_actions = active.add_subparsers(dest="active_action", required=True)
1409
+ for action in ("start", "pause", "resume", "block", "complete", "doctor"):
1410
+ command = active_actions.add_parser(action)
1411
+ command.add_argument("--root", required=True)
1412
+ command.add_argument("--task-dir", required=True)
1413
+ command.add_argument("--task", required=True)
1414
+ command.add_argument("--context-sha256", required=True)
1415
+ command.add_argument("--json", action="store_true")
1416
+ return parser
1417
+
1418
+
1419
+ def _execute(args: argparse.Namespace, stdin: IO[str]) -> dict[str, object]:
1420
+ if args.command == "catalog":
1421
+ return _catalog_command(args)
1422
+ if args.command == "active":
1423
+ return _active_command(args, _json_payload(stdin))
1424
+ context_path = Path(args.task_dir) / "spec-context.yml"
1425
+ if args.command == "decision":
1426
+ return _decision_command(args, _json_payload(stdin))
1427
+ if args.command == "bind":
1428
+ return _bind_command(args, _json_payload(stdin))
1429
+ if args.command == "validate":
1430
+ context = load_context(str(context_path))
1431
+ return {"ok": True, "task": context.task, "bindings": len(context.bindings)}
1432
+ if args.command == "refresh-missing":
1433
+ context = refresh_missing_specs(load_context(str(context_path)), args.root)
1434
+ changes: tuple[DriftChange, ...] = ()
1435
+ else:
1436
+ result = refresh_context(load_context(str(context_path)), args.root, artifact_root=args.task_dir)
1437
+ context, changes = result.context, result.changes
1438
+ save_context(str(context_path), context)
1439
+ _resync_after_save(args, context_path)
1440
+ return {
1441
+ "ok": True,
1442
+ "missing": sum(item.status == "missing" for item in context.bindings),
1443
+ "changes": [change.__dict__ for change in changes],
1444
+ }
1445
+
1446
+
1447
+ def main(argv: Optional[Sequence[str]] = None, stdin: IO[str] = sys.stdin, stdout: IO[str] = sys.stdout) -> int:
1448
+ try:
1449
+ args = _parser().parse_args(argv)
1450
+ if args.command == "status":
1451
+ data = _status_command(args)
1452
+ if args.json:
1453
+ stdout.write(json.dumps(data, ensure_ascii=False))
1454
+ else:
1455
+ stdout.write(_status_text(data) + "\n")
1456
+ return 0
1457
+ result = _execute(args, stdin)
1458
+ stdout.write(json.dumps(result, ensure_ascii=False))
1459
+ return 0
1460
+ except ContextError as exc:
1461
+ stdout.write(json.dumps({"ok": False, "error": exc.to_dict()}, ensure_ascii=False))
1462
+ return 3
1463
+ except Exception as exc:
1464
+ sys.stderr.write(f"cf_spec_context unexpected error: {exc}\n")
1465
+ stdout.write(json.dumps({"ok": False, "error": {"code": "internal_error"}}, ensure_ascii=False))
1466
+ return 2
1467
+
1468
+
1469
+ if __name__ == "__main__":
1470
+ raise SystemExit(main())