@michaelxwb/code-flow 0.6.0 → 0.6.2

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 (38) hide show
  1. package/package.json +1 -1
  2. package/src/adapters/claude/commands/cf-spec.md +13 -0
  3. package/src/adapters/claude/commands/cf-sync.md +25 -0
  4. package/src/adapters/claude/commands/cf-task/plan.md +40 -0
  5. package/src/adapters/claude/commands/cf-task/start.md +6 -7
  6. package/src/adapters/claude/commands/cf-task/verify-e2e.md +111 -0
  7. package/src/adapters/codex/skills/cf-spec/SKILL.md +1 -0
  8. package/src/adapters/codex/skills/cf-sync/SKILL.md +30 -0
  9. package/src/adapters/codex/skills/cf-task-start/SKILL.md +6 -7
  10. package/src/adapters/costrict/commands/cf-spec.md +13 -0
  11. package/src/adapters/costrict/commands/cf-sync.md +25 -0
  12. package/src/adapters/costrict/commands/cf-task/plan.md +40 -0
  13. package/src/adapters/costrict/commands/cf-task/start.md +6 -7
  14. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +111 -0
  15. package/src/adapters/opencode/commands/cf-spec.md +1 -0
  16. package/src/adapters/opencode/commands/cf-sync.md +29 -0
  17. package/src/adapters/opencode/commands/cf-task/plan.md +40 -5
  18. package/src/adapters/opencode/commands/cf-task/start.md +6 -7
  19. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +111 -0
  20. package/src/adapters/opencode/plugins/code-flow/index.js +3 -3
  21. package/src/core/code-flow/config.yml +2 -1
  22. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +199 -0
  23. package/src/core/code-flow/scripts/cf_acceptance_runner.py +107 -0
  24. package/src/core/code-flow/scripts/cf_core.py +44 -1
  25. package/src/core/code-flow/scripts/cf_post_hook.py +5 -30
  26. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +41 -17
  27. package/src/core/code-flow/scripts/cf_session_state.py +37 -0
  28. package/src/core/code-flow/scripts/cf_spec_context.py +381 -29
  29. package/src/core/code-flow/scripts/cf_spec_gate.py +22 -6
  30. package/src/core/code-flow/scripts/cf_spec_metadata.py +60 -11
  31. package/src/core/code-flow/scripts/cf_spec_resolver.py +78 -7
  32. package/src/core/code-flow/scripts/cf_spec_router.py +107 -10
  33. package/src/core/code-flow/scripts/cf_spec_session.py +5 -9
  34. package/src/core/code-flow/scripts/cf_spec_verify.py +166 -9
  35. package/src/core/code-flow/scripts/cf_stop_hook.py +35 -12
  36. package/src/core/code-flow/scripts/cf_sync.py +140 -0
  37. package/src/core/code-flow/scripts/cf_task_runtime.py +95 -7
  38. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +106 -8
@@ -14,12 +14,12 @@ from pathlib import Path
14
14
  import subprocess
15
15
  import sys
16
16
  import tempfile
17
+ import time
17
18
  from typing import IO, Mapping, Optional, Sequence
18
19
 
19
- import yaml
20
-
21
20
  from cf_spec_metadata import SpecHashes, SpecMetadata, SpecRule, load_spec_metadata
22
21
  from cf_spec_resolver import SpecCandidate, resolve_candidates
22
+ from cf_core import phase_timing
23
23
 
24
24
 
25
25
  CONTEXT_VERSION = 1
@@ -28,12 +28,17 @@ STAGE_STATUSES = frozenset(
28
28
  )
29
29
  DECISION_KINDS = frozenset(("not_applicable", "waived", "manual_verification"))
30
30
  _AGENT_IDENTITIES = frozenset(("agent", "assistant", "codex", "claude", "opencode", "costrict"))
31
+ _FILE_HASH_CACHE: dict[str, tuple[int, int, int, str]] = {}
32
+ _PERSISTENT_HASH_CACHE: dict[str, dict[str, object]] = {}
33
+ _PERSISTENT_HASH_PATHS: set[str] = set()
34
+ _MAX_HASH_CACHE_ENTRIES = 4096
31
35
  ACTIVE_STATUSES = frozenset(("activating", "active", "paused", "blocked", "completed"))
32
36
  DEFAULT_ACTIVE_EXCLUDES = (
33
37
  ".code-flow/tasks/*",
34
38
  ".code-flow/specs/_session/*",
35
39
  ".code-flow/migrations/*",
36
40
  ".code-flow/.*state*",
41
+ ".code-flow/.*cache*",
37
42
  ".code-flow/.session-log.jsonl",
38
43
  ".code-flow/.active-task.json",
39
44
  ".code-flow/.active-task.lock",
@@ -208,7 +213,7 @@ def _active_paths(root: str) -> tuple[Path, Path]:
208
213
  def _run_git(root: str, arguments: Sequence[str]) -> str:
209
214
  try:
210
215
  result = subprocess.run(
211
- ("git", *arguments), cwd=root, text=True, capture_output=True, check=False
216
+ ("git", *arguments), cwd=root, text=True, encoding="utf-8", capture_output=True, check=False
212
217
  )
213
218
  except OSError as exc:
214
219
  raise ContextError("git_unavailable", "git", str(exc), root) from exc
@@ -249,10 +254,17 @@ def _git_changes(root: str) -> Mapping[str, str]:
249
254
  if len(record) < 4:
250
255
  raise ContextError("invalid_git_status", "git.status", record, root)
251
256
  code, path = record[:2], record[3:]
257
+ index += 1
252
258
  if "R" in code or "C" in code:
253
- raise ContextError("unsupported_git_status", "git.status", code, path)
259
+ # porcelain -z rename/copy emits two records: "XY <new>" then "<old>"
260
+ if index >= len(records) or not records[index]:
261
+ raise ContextError("invalid_git_status", "git.status", f"{code} {path}", root)
262
+ old_path = records[index]
263
+ index += 1
264
+ changes[path] = _status_name(code)
265
+ changes[old_path] = "deleted"
266
+ continue
254
267
  changes[path] = _status_name(code)
255
- index += 1
256
268
  return changes
257
269
 
258
270
 
@@ -260,16 +272,6 @@ def _is_excluded(path: str, patterns: Sequence[str]) -> bool:
260
272
  return any(fnmatchcase(path, pattern) for pattern in patterns)
261
273
 
262
274
 
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
275
  def _active_data(active: ActiveTask) -> dict[str, object]:
274
276
  changes = {
275
277
  path: {"status": item.status, "content_sha256": item.content_sha256}
@@ -293,9 +295,10 @@ def _active_data(active: ActiveTask) -> dict[str, object]:
293
295
 
294
296
  def _snapshot_from_data(value: object, path: str) -> PathSnapshot:
295
297
  data = _mapping(value, "preexisting_changes[]", path)
298
+ content_hash = data.get("content_sha256")
296
299
  return PathSnapshot(
297
300
  _string(data.get("status"), "status", path),
298
- _string(data.get("content_sha256"), "content_sha256", path),
301
+ content_hash if isinstance(content_hash, str) else "",
299
302
  )
300
303
 
301
304
 
@@ -348,13 +351,76 @@ def load_active_task(root: str) -> ActiveTask:
348
351
  raise ContextError("invalid_active_marker", "active", str(exc), str(marker)) from exc
349
352
 
350
353
 
354
+ def resync_active_hash(root: str, task_dir: str, current_sha256: str) -> bool:
355
+ """Re-sync the active marker's context hash after toolchain-initiated
356
+ context edits (bind/decision/refresh) so they never surface as
357
+ unrecoverable active_context_drift. No-op without an active marker or when
358
+ the hash already matches; never raises on a missing marker."""
359
+ marker = Path(root) / ".code-flow" / ".active-task.json"
360
+ if not marker.exists():
361
+ return False
362
+ active = load_active_task(root)
363
+ if active.context_sha256 == current_sha256:
364
+ return False
365
+ save_active_task(root, replace(active, context_sha256=current_sha256))
366
+ return True
367
+
368
+
369
+ def _find_root(task_dir: str) -> Optional[str]:
370
+ """Walk up from a task dir to the project root that owns its marker."""
371
+ current = Path(task_dir).resolve()
372
+ for candidate in (current, *current.parents):
373
+ if (candidate / ".code-flow" / ".active-task.json").exists():
374
+ return str(candidate)
375
+ return None
376
+
377
+
378
+ def _resync_after_save(args: argparse.Namespace, context_path: Path) -> bool:
379
+ """Post-save marker re-sync used by CLI commands that persist the context."""
380
+ root = getattr(args, "root", "") or _find_root(str(context_path.parent)) or ""
381
+ if not root:
382
+ return False
383
+ current = context_sha256(load_context(str(context_path)))
384
+ return resync_active_hash(root, str(context_path.parent), current)
385
+
386
+
387
+ def _lock_stale(lock: Path) -> bool:
388
+ """A lock is stale when its file is unreadable, carries no valid PID, or the
389
+ owning process is gone. A lock we cannot prove dead is never deleted (PID
390
+ reuse is accepted as a rare race)."""
391
+ try:
392
+ data = json.loads(lock.read_text(encoding="utf-8"))
393
+ except (OSError, json.JSONDecodeError):
394
+ return True
395
+ pid = data.get("pid")
396
+ if not isinstance(pid, int) or pid <= 0:
397
+ return True
398
+ try:
399
+ os.kill(pid, 0)
400
+ except ProcessLookupError:
401
+ return True
402
+ except (PermissionError, OSError):
403
+ return False
404
+ return False
405
+
406
+
351
407
  def _acquire_active_lock(root: str) -> Path:
352
408
  marker, lock = _active_paths(root)
353
409
  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
410
+ descriptor = -1
411
+ for _attempt in range(2):
412
+ try:
413
+ descriptor = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
414
+ break
415
+ except FileExistsError:
416
+ if not _lock_stale(lock):
417
+ raise ContextError("active_lock_exists", "active.lock", "运行 doctor 检查残留 lock", str(lock)) from None
418
+ try:
419
+ lock.unlink()
420
+ except OSError as exc:
421
+ raise ContextError("active_lock_exists", "active.lock", f"残留 lock 清理失败: {exc}", str(lock)) from exc
422
+ else:
423
+ raise ContextError("active_lock_exists", "active.lock", "残留 lock 无法清理", str(lock))
358
424
  with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
359
425
  handle.write(json.dumps({"pid": os.getpid(), "marker": str(marker)}))
360
426
  return lock
@@ -390,8 +456,11 @@ def _new_active(
390
456
  raise ContextError("unknown_owned_path", "owned_paths", ", ".join(unknown), root)
391
457
  if unowned:
392
458
  raise ContextError("unowned_changes", "owned_paths", ", ".join(unowned), root)
459
+ # content_sha256 was a per-file hash but is never consumed by any gate (only
460
+ # baseline.head is compared); keep the schema field empty so start never hashes
461
+ # every uncommitted file.
393
462
  snapshots = {
394
- path: PathSnapshot(status, _path_hash(root, path)) for path, status in changes.items()
463
+ path: PathSnapshot(status, "") for path, status in changes.items()
395
464
  }
396
465
  baseline = ActiveBaseline(_git_head(root), _now(), snapshots)
397
466
  return ActiveTask(
@@ -427,8 +496,11 @@ def _transition_active(root: str, expected: Sequence[str], target: str) -> Activ
427
496
  active = load_active_task(root)
428
497
  if active.status not in expected:
429
498
  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)
499
+ current_head = _git_head(root)
500
+ if active.baseline.head != current_head:
501
+ # Mid-task commits are a normal workflow; re-baseline instead of
502
+ # bricking pause/resume/complete on any unrelated commit.
503
+ active = replace(active, baseline=replace(active.baseline, head=current_head))
432
504
  updated = replace(active, status=target)
433
505
  save_active_task(root, updated)
434
506
  return updated
@@ -466,7 +538,9 @@ def complete_active_task(root: str, gate_passed: bool) -> ActiveTask:
466
538
  return completed
467
539
 
468
540
 
469
- def doctor_active_task(root: str, expected_context_sha256: str, abandon: bool = False) -> DoctorResult:
541
+ def doctor_active_task(
542
+ root: str, expected_context_sha256: str, abandon: bool = False, resync: bool = False
543
+ ) -> DoctorResult:
470
544
  marker, lock = _active_paths(root)
471
545
  try:
472
546
  active = load_active_task(root)
@@ -476,6 +550,15 @@ def doctor_active_task(root: str, expected_context_sha256: str, abandon: bool =
476
550
  marker.unlink()
477
551
  _release_active_lock(lock)
478
552
  return DoctorResult("abandoned", replace(active, status="completed"))
553
+ if resync:
554
+ # Self-heal: the context file is authoritative and the marker hash is a
555
+ # cache. Accept the current context when the marker is the only
556
+ # inconsistency; bind/decision/refresh already re-sync automatically,
557
+ # so this covers legacy markers and manual context edits.
558
+ repaired = replace(active, status="active", context_sha256=expected_context_sha256)
559
+ save_active_task(root, repaired)
560
+ _release_active_lock(lock)
561
+ return DoctorResult("resynced", repaired)
479
562
  proven = active.context_sha256 == expected_context_sha256
480
563
  proven = proven and active.baseline.head == _git_head(root)
481
564
  if active.status != "activating" or not proven:
@@ -690,6 +773,59 @@ def context_to_data(context: SpecContext) -> dict[str, object]:
690
773
  }
691
774
 
692
775
 
776
+ def context_to_identity(context: SpecContext) -> dict[str, object]:
777
+ """Stable identity projection for marker drift detection.
778
+
779
+ Excludes volatile runtime state (`updated_at`, `stage_status` including
780
+ evidence timestamps) so the active marker only invalidates when the bound
781
+ rules themselves change (rule/spec text, bindings, sources).
782
+ """
783
+ return {
784
+ "version": context.version,
785
+ "task": context.task,
786
+ "enforcement": context.enforcement,
787
+ "sources": [{"type": item.type, "ref": item.ref} for item in context.sources],
788
+ "bindings": [
789
+ {
790
+ "spec_id": binding.spec_id,
791
+ "path": binding.path,
792
+ "status": binding.status,
793
+ "hashes": {
794
+ "file_sha256": binding.hashes.file_sha256,
795
+ "metadata_sha256": binding.hashes.metadata_sha256,
796
+ "rules_sha256": binding.hashes.rules_sha256,
797
+ },
798
+ "selected_by": binding.selected_by,
799
+ "reason": binding.reason,
800
+ "enforcement": binding.enforcement,
801
+ "stages": list(binding.stages),
802
+ "rules": [
803
+ {
804
+ "ref": rule.ref,
805
+ "summary": rule.summary,
806
+ "text_sha256": rule.text_sha256,
807
+ "enforcement": rule.enforcement,
808
+ "verifier_ref": rule.verifier_ref,
809
+ }
810
+ for rule in binding.rules
811
+ ],
812
+ }
813
+ for binding in context.bindings
814
+ ],
815
+ }
816
+
817
+
818
+ def context_sha256(context: SpecContext) -> str:
819
+ """Deterministic identity hash binding the active marker to the bound rules.
820
+
821
+ Computed from the stable identity projection (never volatile runtime state),
822
+ so evidence timestamps and stage statuses cannot drift the marker.
823
+ """
824
+ import yaml
825
+ data = yaml.safe_dump(context_to_identity(context), sort_keys=True, allow_unicode=True).encode()
826
+ return hashlib.sha256(data).hexdigest()
827
+
828
+
693
829
  def new_context(task: str, sources: Sequence[tuple[str, str]]) -> SpecContext:
694
830
  if not task.strip():
695
831
  raise ContextError("invalid_context", "task", "必须是非空字符串")
@@ -698,6 +834,7 @@ def new_context(task: str, sources: Sequence[tuple[str, str]]) -> SpecContext:
698
834
 
699
835
 
700
836
  def load_context(path: str) -> SpecContext:
837
+ import yaml
701
838
  try:
702
839
  loaded = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
703
840
  except (OSError, UnicodeError, yaml.YAMLError) as exc:
@@ -726,6 +863,7 @@ def load_context(path: str) -> SpecContext:
726
863
 
727
864
 
728
865
  def save_context(path: str, context: SpecContext) -> None:
866
+ import yaml
729
867
  target = Path(path)
730
868
  target.parent.mkdir(parents=True, exist_ok=True)
731
869
  text = yaml.safe_dump(context_to_data(context), sort_keys=False, allow_unicode=True)
@@ -792,6 +930,8 @@ def bind_specs(context: SpecContext, selections: Sequence[BindingInput]) -> Spec
792
930
  )
793
931
  bindings[incoming.spec_id] = incoming
794
932
  ordered = tuple(bindings[key] for key in sorted(bindings))
933
+ if ordered == context.bindings:
934
+ return context
795
935
  return replace(context, updated_at=_now(), bindings=ordered)
796
936
 
797
937
 
@@ -827,7 +967,57 @@ def _removed_rule(rule: RuleBinding) -> RuleBinding:
827
967
 
828
968
  def _file_sha256(path: Path) -> str:
829
969
  try:
830
- return hashlib.sha256(path.read_bytes()).hexdigest()
970
+ stat = path.stat()
971
+ key = str(path)
972
+ cached = _FILE_HASH_CACHE.get(key)
973
+ signature = (stat.st_mtime_ns, stat.st_size, stat.st_ino)
974
+ if cached is not None and cached[:3] == signature:
975
+ return cached[3]
976
+ cache_path = next(
977
+ (parent / ".artifact-hash-cache.json" for parent in path.parents if parent.name == ".code-flow"),
978
+ None,
979
+ )
980
+ cache_key = str(cache_path) if cache_path is not None else ""
981
+ if cache_key and cache_key not in _PERSISTENT_HASH_PATHS:
982
+ try:
983
+ loaded = json.loads(cache_path.read_text(encoding="utf-8"))
984
+ if isinstance(loaded, dict):
985
+ _PERSISTENT_HASH_CACHE.update({str(k): v for k, v in loaded.items() if isinstance(v, dict)})
986
+ except (OSError, ValueError):
987
+ pass
988
+ _PERSISTENT_HASH_PATHS.add(cache_key)
989
+ persistent = _PERSISTENT_HASH_CACHE.get(key)
990
+ if (
991
+ isinstance(persistent, dict)
992
+ and persistent.get("mtime_ns") == stat.st_mtime_ns
993
+ and persistent.get("size") == stat.st_size
994
+ and persistent.get("ino") == stat.st_ino
995
+ ):
996
+ digest = persistent.get("sha256")
997
+ if isinstance(digest, str) and digest:
998
+ _FILE_HASH_CACHE[key] = (signature[0], signature[1], signature[2], digest)
999
+ return digest
1000
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
1001
+ _FILE_HASH_CACHE[key] = (signature[0], signature[1], signature[2], digest)
1002
+ if cache_key:
1003
+ _PERSISTENT_HASH_CACHE[key] = {
1004
+ "mtime_ns": stat.st_mtime_ns,
1005
+ "size": stat.st_size,
1006
+ "ino": stat.st_ino,
1007
+ "sha256": digest,
1008
+ }
1009
+ while len(_PERSISTENT_HASH_CACHE) > _MAX_HASH_CACHE_ENTRIES:
1010
+ _PERSISTENT_HASH_CACHE.pop(next(iter(_PERSISTENT_HASH_CACHE)))
1011
+ try:
1012
+ temporary = cache_path.with_suffix(".json.tmp")
1013
+ temporary.write_text(
1014
+ json.dumps(_PERSISTENT_HASH_CACHE, ensure_ascii=False, sort_keys=True),
1015
+ encoding="utf-8",
1016
+ )
1017
+ temporary.replace(cache_path)
1018
+ except OSError:
1019
+ pass
1020
+ return digest
831
1021
  except OSError as exc:
832
1022
  raise ContextError("artifact_read_error", "artifact", str(exc), str(path)) from exc
833
1023
 
@@ -843,7 +1033,16 @@ def _refresh_artifacts(
843
1033
  current = _file_sha256(path) if path.is_file() else "missing"
844
1034
  if current == reference.artifact_sha256:
845
1035
  continue
846
- statuses[stage] = replace(status, status="stale")
1036
+ if status.status == "applied" and current != "missing":
1037
+ statuses[stage] = replace(
1038
+ status,
1039
+ refs=tuple(
1040
+ replace(item, artifact_sha256=current) if item is reference else item
1041
+ for item in status.refs
1042
+ ),
1043
+ )
1044
+ else:
1045
+ statuses[stage] = replace(status, status="stale")
847
1046
  changes.append(
848
1047
  DriftChange("artifact_changed", spec_id, rule.ref, reference.artifact_sha256, current, (stage,))
849
1048
  )
@@ -910,15 +1109,21 @@ def _refresh_binding(
910
1109
 
911
1110
 
912
1111
  def refresh_context(context: SpecContext, root: str, artifact_root: Optional[str] = None) -> DriftResult:
1112
+ started = time.monotonic()
913
1113
  specs = Path(root) / ".code-flow" / "specs"
914
1114
  artifacts = Path(artifact_root) if artifact_root is not None else Path(root)
915
1115
  bindings: list[SpecBinding] = []
916
1116
  changes: list[DriftChange] = []
917
1117
  for binding in context.bindings:
1118
+ phase_started = time.monotonic()
918
1119
  updated, binding_changes = _refresh_binding(binding, specs, artifacts)
1120
+ phase_timing(f"context.refresh.{binding.spec_id}", phase_started)
919
1121
  bindings.append(updated)
920
1122
  changes.extend(binding_changes)
921
1123
  refreshed = replace(context, updated_at=_now(), bindings=tuple(bindings))
1124
+ phase_timing("context.refresh.total", started)
1125
+ if refreshed.bindings == context.bindings:
1126
+ return DriftResult(context, tuple(changes))
922
1127
  return DriftResult(refreshed, tuple(changes))
923
1128
 
924
1129
 
@@ -993,7 +1198,10 @@ def apply_artifact_ref(
993
1198
  statuses = dict(rule.stage_status)
994
1199
  current = statuses[stage]
995
1200
  refs = _upsert_artifact_ref(current.refs, reference)
996
- statuses[stage] = replace(current, status="applied", refs=refs)
1201
+ if current.decision is not None:
1202
+ statuses[stage] = replace(current, refs=refs)
1203
+ else:
1204
+ statuses[stage] = replace(current, status="applied", refs=refs)
997
1205
  rule = replace(rule, stage_status=statuses)
998
1206
  found = True
999
1207
  rules.append(rule)
@@ -1089,6 +1297,7 @@ def _decision_command(args: argparse.Namespace, payload: Mapping[str, object]) -
1089
1297
  payload.get("batch") is True,
1090
1298
  )
1091
1299
  save_context(str(context_path), context)
1300
+ _resync_after_save(args, context_path)
1092
1301
  return {"ok": True, "status": context.bindings[0].rules[0].stage_status[_string(payload.get("stage"), "stage", "")].status}
1093
1302
 
1094
1303
 
@@ -1117,6 +1326,7 @@ def _bind_command(args: argparse.Namespace, payload: Mapping[str, object]) -> di
1117
1326
  context = bind_specs(context, selections)
1118
1327
  context = _apply_payload(context, args.task_dir, payload)
1119
1328
  save_context(str(context_path), context)
1329
+ _resync_after_save(args, context_path)
1120
1330
  applied = sum(
1121
1331
  status.status == "applied"
1122
1332
  for binding in context.bindings
@@ -1146,12 +1356,132 @@ def _active_command(args: argparse.Namespace, payload: Mapping[str, object]) ->
1146
1356
  active = complete_active_task(args.root, payload.get("gate_passed") is True)
1147
1357
  else:
1148
1358
  result = doctor_active_task(
1149
- args.root, args.context_sha256, payload.get("abandon") is True
1359
+ args.root,
1360
+ args.context_sha256,
1361
+ payload.get("abandon") is True,
1362
+ payload.get("resync") is True,
1150
1363
  )
1151
1364
  return {"ok": True, "action": result.action, "active": _active_data(result.active)}
1152
1365
  return {"ok": True, "active": _active_data(active)}
1153
1366
 
1154
1367
 
1368
+ def _start_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
1369
+ """Run refresh, activation and TASK projection in one guarded process."""
1370
+ context_path = Path(args.task_dir) / "spec-context.yml"
1371
+ refreshed = refresh_context(load_context(str(context_path)), args.root, artifact_root=args.task_dir)
1372
+ save_context(str(context_path), refreshed.context)
1373
+ _resync_after_save(args, context_path)
1374
+ current_hash = context_sha256(refreshed.context)
1375
+ owned = tuple(
1376
+ _string(item, "owned_paths[]", "")
1377
+ for item in _sequence(payload.get("owned_paths", []), "owned_paths", "")
1378
+ )
1379
+ task_file = Path(args.task_file).resolve()
1380
+ task_dir = Path(args.task_dir).resolve()
1381
+ if task_file.parent != task_dir or not task_file.is_file():
1382
+ raise ContextError("invalid_task_file", "task_file", str(task_file), args.task_dir)
1383
+ manifest_file = task_dir / ".acceptance-manifest.json"
1384
+ if "## Acceptance Coverage" in task_file.read_text(encoding="utf-8") and not manifest_file.is_file():
1385
+ raise ContextError(
1386
+ "acceptance_manifest_missing",
1387
+ "acceptance_manifest",
1388
+ "run plan verification and lock the manifest",
1389
+ str(manifest_file),
1390
+ )
1391
+ if manifest_file.is_file():
1392
+ from cf_acceptance_manifest import validate_manifest
1393
+
1394
+ valid, reason = validate_manifest(str(task_file), str(manifest_file))
1395
+ if not valid:
1396
+ raise ContextError(reason, "acceptance_manifest", "run plan verification before coding", str(manifest_file))
1397
+ from cf_spec_session import project_task_session
1398
+
1399
+ projection = project_task_session(refreshed.context, str(task_file), args.task)
1400
+ if projection.truncated:
1401
+ raise ContextError("task_projection_truncated", "task_file", "split the TASK before coding")
1402
+ output = Path(args.session_output) if args.session_output else (
1403
+ Path(args.root) / ".code-flow/specs/_session" / f"task-{task_file.stem}.md"
1404
+ )
1405
+ output.parent.mkdir(parents=True, exist_ok=True)
1406
+ _atomic_text(output, projection.text)
1407
+ active = start_active_task(args.root, args.task_dir, args.task, current_hash, owned)
1408
+ return {
1409
+ "ok": True,
1410
+ "context_sha256": current_hash,
1411
+ "refresh_changes": [item.__dict__ for item in refreshed.changes],
1412
+ "active": _active_data(active),
1413
+ "session_output": str(output),
1414
+ "acceptance_manifest": str(manifest_file) if manifest_file.is_file() else None,
1415
+ }
1416
+
1417
+
1418
+ def _status_command(args: argparse.Namespace) -> dict[str, object]:
1419
+ """Human-readable Spec Context status: task, marker health, gate, rules."""
1420
+ from cf_spec_gate import result_to_data, validate_stage # local import avoids module cycle
1421
+
1422
+ context_path = Path(args.task_dir) / "spec-context.yml"
1423
+ context = load_context(str(context_path))
1424
+ gate = validate_stage(context, "code")
1425
+ root = getattr(args, "root", "") or _find_root(args.task_dir) or ""
1426
+ marker: dict[str, object] = {"exists": False}
1427
+ if root:
1428
+ marker_path = Path(root) / ".code-flow" / ".active-task.json"
1429
+ if marker_path.exists():
1430
+ active = load_active_task(root)
1431
+ marker = {
1432
+ "exists": True,
1433
+ "task_id": active.task_id,
1434
+ "status": active.status,
1435
+ "hash_match": active.context_sha256 == context_sha256(context),
1436
+ "baseline_head": active.baseline.head,
1437
+ }
1438
+ bindings = []
1439
+ for binding in context.bindings:
1440
+ rules = []
1441
+ for rule in binding.rules:
1442
+ status = rule.stage_status.get("code")
1443
+ rules.append(
1444
+ {
1445
+ "ref": f"{binding.spec_id}#{rule.ref}",
1446
+ "enforcement": rule.enforcement,
1447
+ "status": status.status if status else "missing",
1448
+ "verifier": rule.verifier_ref,
1449
+ }
1450
+ )
1451
+ bindings.append({"spec_id": binding.spec_id, "path": binding.path, "status": binding.status, "rules": rules})
1452
+ return {
1453
+ "task": context.task,
1454
+ "context_sha256": context_sha256(context),
1455
+ "gate": result_to_data(gate),
1456
+ "marker": marker,
1457
+ "bindings": bindings,
1458
+ }
1459
+
1460
+
1461
+ def _status_text(data: dict[str, object]) -> str:
1462
+ lines = [f"# {data['task']} — Spec Context 状态"]
1463
+ marker = data["marker"]
1464
+ if marker["exists"]:
1465
+ match = "✓ 一致" if marker["hash_match"] else "✗ 漂移"
1466
+ lines.append(f"- TASK {marker['task_id']}({marker['status']}),marker hash {match}")
1467
+ if not marker["hash_match"]:
1468
+ lines.append(" 下一步: 运行 cf-spec doctor(resync 可自动重同步)")
1469
+ else:
1470
+ lines.append("- 无 active TASK(path/catalog 路由模式)")
1471
+ gate = data["gate"]
1472
+ if gate["decision"] == "pass":
1473
+ lines.append("- code Gate: ✓ pass")
1474
+ else:
1475
+ lines.append(f"- code Gate: ✗ block({len(gate['errors'])} 项)")
1476
+ for issue in gate["errors"]:
1477
+ lines.append(f" ✗ {issue['message']}")
1478
+ for binding in data["bindings"]:
1479
+ lines.append(f"- {binding['spec_id']}({binding['path']})— {binding['status']}")
1480
+ for rule in binding["rules"]:
1481
+ lines.append(f" {rule['status']:<12} {rule['ref']}({rule['verifier']})")
1482
+ return "\n".join(lines)
1483
+
1484
+
1155
1485
  def _parser() -> argparse.ArgumentParser:
1156
1486
  parser = argparse.ArgumentParser(prog="cf_spec_context.py")
1157
1487
  commands = parser.add_subparsers(dest="command", required=True)
@@ -1171,6 +1501,10 @@ def _parser() -> argparse.ArgumentParser:
1171
1501
  validate = commands.add_parser("validate")
1172
1502
  validate.add_argument("--task-dir", required=True)
1173
1503
  validate.add_argument("--json", action="store_true")
1504
+ status = commands.add_parser("status")
1505
+ status.add_argument("--task-dir", required=True)
1506
+ status.add_argument("--root", default="")
1507
+ status.add_argument("--json", action="store_true")
1174
1508
  for name in ("refresh", "refresh-missing"):
1175
1509
  refresh = commands.add_parser(name)
1176
1510
  refresh.add_argument("--task-dir", required=True)
@@ -1185,6 +1519,13 @@ def _parser() -> argparse.ArgumentParser:
1185
1519
  command.add_argument("--task", required=True)
1186
1520
  command.add_argument("--context-sha256", required=True)
1187
1521
  command.add_argument("--json", action="store_true")
1522
+ start = commands.add_parser("start")
1523
+ start.add_argument("--task-dir", required=True)
1524
+ start.add_argument("--root", required=True)
1525
+ start.add_argument("--task", required=True)
1526
+ start.add_argument("--task-file", required=True)
1527
+ start.add_argument("--session-output", default="")
1528
+ start.add_argument("--json", action="store_true")
1188
1529
  return parser
1189
1530
 
1190
1531
 
@@ -1193,6 +1534,8 @@ def _execute(args: argparse.Namespace, stdin: IO[str]) -> dict[str, object]:
1193
1534
  return _catalog_command(args)
1194
1535
  if args.command == "active":
1195
1536
  return _active_command(args, _json_payload(stdin))
1537
+ if args.command == "start":
1538
+ return _start_command(args, _json_payload(stdin))
1196
1539
  context_path = Path(args.task_dir) / "spec-context.yml"
1197
1540
  if args.command == "decision":
1198
1541
  return _decision_command(args, _json_payload(stdin))
@@ -1208,6 +1551,7 @@ def _execute(args: argparse.Namespace, stdin: IO[str]) -> dict[str, object]:
1208
1551
  result = refresh_context(load_context(str(context_path)), args.root, artifact_root=args.task_dir)
1209
1552
  context, changes = result.context, result.changes
1210
1553
  save_context(str(context_path), context)
1554
+ _resync_after_save(args, context_path)
1211
1555
  return {
1212
1556
  "ok": True,
1213
1557
  "missing": sum(item.status == "missing" for item in context.bindings),
@@ -1217,7 +1561,15 @@ def _execute(args: argparse.Namespace, stdin: IO[str]) -> dict[str, object]:
1217
1561
 
1218
1562
  def main(argv: Optional[Sequence[str]] = None, stdin: IO[str] = sys.stdin, stdout: IO[str] = sys.stdout) -> int:
1219
1563
  try:
1220
- result = _execute(_parser().parse_args(argv), stdin)
1564
+ args = _parser().parse_args(argv)
1565
+ if args.command == "status":
1566
+ data = _status_command(args)
1567
+ if args.json:
1568
+ stdout.write(json.dumps(data, ensure_ascii=False))
1569
+ else:
1570
+ stdout.write(_status_text(data) + "\n")
1571
+ return 0
1572
+ result = _execute(args, stdin)
1221
1573
  stdout.write(json.dumps(result, ensure_ascii=False))
1222
1574
  return 0
1223
1575
  except ContextError as exc: