@michaelxwb/code-flow 0.6.0 → 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.
@@ -249,10 +249,17 @@ def _git_changes(root: str) -> Mapping[str, str]:
249
249
  if len(record) < 4:
250
250
  raise ContextError("invalid_git_status", "git.status", record, root)
251
251
  code, path = record[:2], record[3:]
252
+ index += 1
252
253
  if "R" in code or "C" in code:
253
- raise ContextError("unsupported_git_status", "git.status", code, path)
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
254
262
  changes[path] = _status_name(code)
255
- index += 1
256
263
  return changes
257
264
 
258
265
 
@@ -348,13 +355,76 @@ def load_active_task(root: str) -> ActiveTask:
348
355
  raise ContextError("invalid_active_marker", "active", str(exc), str(marker)) from exc
349
356
 
350
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
+
351
411
  def _acquire_active_lock(root: str) -> Path:
352
412
  marker, lock = _active_paths(root)
353
413
  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
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))
358
428
  with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
359
429
  handle.write(json.dumps({"pid": os.getpid(), "marker": str(marker)}))
360
430
  return lock
@@ -427,8 +497,11 @@ def _transition_active(root: str, expected: Sequence[str], target: str) -> Activ
427
497
  active = load_active_task(root)
428
498
  if active.status not in expected:
429
499
  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)
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))
432
505
  updated = replace(active, status=target)
433
506
  save_active_task(root, updated)
434
507
  return updated
@@ -466,7 +539,9 @@ def complete_active_task(root: str, gate_passed: bool) -> ActiveTask:
466
539
  return completed
467
540
 
468
541
 
469
- def doctor_active_task(root: str, expected_context_sha256: str, abandon: bool = False) -> DoctorResult:
542
+ def doctor_active_task(
543
+ root: str, expected_context_sha256: str, abandon: bool = False, resync: bool = False
544
+ ) -> DoctorResult:
470
545
  marker, lock = _active_paths(root)
471
546
  try:
472
547
  active = load_active_task(root)
@@ -476,6 +551,15 @@ def doctor_active_task(root: str, expected_context_sha256: str, abandon: bool =
476
551
  marker.unlink()
477
552
  _release_active_lock(lock)
478
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)
479
563
  proven = active.context_sha256 == expected_context_sha256
480
564
  proven = proven and active.baseline.head == _git_head(root)
481
565
  if active.status != "activating" or not proven:
@@ -690,6 +774,58 @@ def context_to_data(context: SpecContext) -> dict[str, object]:
690
774
  }
691
775
 
692
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
+
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", "必须是非空字符串")
@@ -792,6 +928,8 @@ def bind_specs(context: SpecContext, selections: Sequence[BindingInput]) -> Spec
792
928
  )
793
929
  bindings[incoming.spec_id] = incoming
794
930
  ordered = tuple(bindings[key] for key in sorted(bindings))
931
+ if ordered == context.bindings:
932
+ return context
795
933
  return replace(context, updated_at=_now(), bindings=ordered)
796
934
 
797
935
 
@@ -843,7 +981,16 @@ def _refresh_artifacts(
843
981
  current = _file_sha256(path) if path.is_file() else "missing"
844
982
  if current == reference.artifact_sha256:
845
983
  continue
846
- statuses[stage] = replace(status, status="stale")
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")
847
994
  changes.append(
848
995
  DriftChange("artifact_changed", spec_id, rule.ref, reference.artifact_sha256, current, (stage,))
849
996
  )
@@ -919,6 +1066,8 @@ def refresh_context(context: SpecContext, root: str, artifact_root: Optional[str
919
1066
  bindings.append(updated)
920
1067
  changes.extend(binding_changes)
921
1068
  refreshed = replace(context, updated_at=_now(), bindings=tuple(bindings))
1069
+ if refreshed.bindings == context.bindings:
1070
+ return DriftResult(context, tuple(changes))
922
1071
  return DriftResult(refreshed, tuple(changes))
923
1072
 
924
1073
 
@@ -993,7 +1142,10 @@ def apply_artifact_ref(
993
1142
  statuses = dict(rule.stage_status)
994
1143
  current = statuses[stage]
995
1144
  refs = _upsert_artifact_ref(current.refs, reference)
996
- statuses[stage] = replace(current, status="applied", refs=refs)
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)
997
1149
  rule = replace(rule, stage_status=statuses)
998
1150
  found = True
999
1151
  rules.append(rule)
@@ -1089,6 +1241,7 @@ def _decision_command(args: argparse.Namespace, payload: Mapping[str, object]) -
1089
1241
  payload.get("batch") is True,
1090
1242
  )
1091
1243
  save_context(str(context_path), context)
1244
+ _resync_after_save(args, context_path)
1092
1245
  return {"ok": True, "status": context.bindings[0].rules[0].stage_status[_string(payload.get("stage"), "stage", "")].status}
1093
1246
 
1094
1247
 
@@ -1117,6 +1270,7 @@ def _bind_command(args: argparse.Namespace, payload: Mapping[str, object]) -> di
1117
1270
  context = bind_specs(context, selections)
1118
1271
  context = _apply_payload(context, args.task_dir, payload)
1119
1272
  save_context(str(context_path), context)
1273
+ _resync_after_save(args, context_path)
1120
1274
  applied = sum(
1121
1275
  status.status == "applied"
1122
1276
  for binding in context.bindings
@@ -1146,12 +1300,82 @@ def _active_command(args: argparse.Namespace, payload: Mapping[str, object]) ->
1146
1300
  active = complete_active_task(args.root, payload.get("gate_passed") is True)
1147
1301
  else:
1148
1302
  result = doctor_active_task(
1149
- args.root, args.context_sha256, payload.get("abandon") is True
1303
+ args.root,
1304
+ args.context_sha256,
1305
+ payload.get("abandon") is True,
1306
+ payload.get("resync") is True,
1150
1307
  )
1151
1308
  return {"ok": True, "action": result.action, "active": _active_data(result.active)}
1152
1309
  return {"ok": True, "active": _active_data(active)}
1153
1310
 
1154
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
+
1155
1379
  def _parser() -> argparse.ArgumentParser:
1156
1380
  parser = argparse.ArgumentParser(prog="cf_spec_context.py")
1157
1381
  commands = parser.add_subparsers(dest="command", required=True)
@@ -1171,6 +1395,10 @@ def _parser() -> argparse.ArgumentParser:
1171
1395
  validate = commands.add_parser("validate")
1172
1396
  validate.add_argument("--task-dir", required=True)
1173
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")
1174
1402
  for name in ("refresh", "refresh-missing"):
1175
1403
  refresh = commands.add_parser(name)
1176
1404
  refresh.add_argument("--task-dir", required=True)
@@ -1208,6 +1436,7 @@ def _execute(args: argparse.Namespace, stdin: IO[str]) -> dict[str, object]:
1208
1436
  result = refresh_context(load_context(str(context_path)), args.root, artifact_root=args.task_dir)
1209
1437
  context, changes = result.context, result.changes
1210
1438
  save_context(str(context_path), context)
1439
+ _resync_after_save(args, context_path)
1211
1440
  return {
1212
1441
  "ok": True,
1213
1442
  "missing": sum(item.status == "missing" for item in context.bindings),
@@ -1217,7 +1446,15 @@ def _execute(args: argparse.Namespace, stdin: IO[str]) -> dict[str, object]:
1217
1446
 
1218
1447
  def main(argv: Optional[Sequence[str]] = None, stdin: IO[str] = sys.stdin, stdout: IO[str] = sys.stdout) -> int:
1219
1448
  try:
1220
- result = _execute(_parser().parse_args(argv), stdin)
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)
1221
1458
  stdout.write(json.dumps(result, ensure_ascii=False))
1222
1459
  return 0
1223
1460
  except ContextError as exc:
@@ -55,7 +55,7 @@ def _waiver_issue(decision: Optional[Decision], now: datetime) -> Optional[str]:
55
55
  return "waiver_expired" if expires <= now else None
56
56
 
57
57
 
58
- def _evidence_issue(rule: RuleBinding, status: RuleStageStatus) -> Optional[str]:
58
+ def _evidence_issue(rule: RuleBinding, status: RuleStageStatus, diff_sha256: Optional[str]) -> Optional[str]:
59
59
  if not status.evidence:
60
60
  return "evidence_missing"
61
61
  for evidence in status.evidence:
@@ -63,17 +63,20 @@ def _evidence_issue(rule: RuleBinding, status: RuleStageStatus) -> Optional[str]
63
63
  continue
64
64
  if evidence.get("rule_text_sha256") != rule.text_sha256:
65
65
  continue
66
+ evidence_diff = evidence.get("diff_sha256")
67
+ if evidence_diff is not None and diff_sha256 is not None and evidence_diff != diff_sha256:
68
+ continue
66
69
  result_hash = evidence.get("result_sha256")
67
70
  if isinstance(result_hash, str) and result_hash:
68
71
  return None
69
72
  return "stale_evidence"
70
73
 
71
74
 
72
- def _status_issue(rule: RuleBinding, status: RuleStageStatus, now: datetime) -> Optional[str]:
75
+ def _status_issue(rule: RuleBinding, status: RuleStageStatus, now: datetime, diff_sha256: Optional[str]) -> Optional[str]:
73
76
  if status.status == "applied":
74
77
  return None
75
78
  if status.status == "verified":
76
- return _evidence_issue(rule, status)
79
+ return _evidence_issue(rule, status, diff_sha256)
77
80
  if status.status == "not_applicable":
78
81
  return None if _decision_valid(status.decision) else "decision_invalid"
79
82
  if status.status == "waived":
@@ -81,7 +84,9 @@ def _status_issue(rule: RuleBinding, status: RuleStageStatus, now: datetime) ->
81
84
  return status.status
82
85
 
83
86
 
84
- def _binding_issues(binding: SpecBinding, stage: str, now: datetime) -> tuple[list[GateIssue], list[GateIssue]]:
87
+ def _binding_issues(
88
+ binding: SpecBinding, stage: str, now: datetime, diff_sha256: Optional[str]
89
+ ) -> tuple[list[GateIssue], list[GateIssue]]:
85
90
  errors: list[GateIssue] = []
86
91
  warnings: list[GateIssue] = []
87
92
  for rule in binding.rules:
@@ -90,7 +95,7 @@ def _binding_issues(binding: SpecBinding, stage: str, now: datetime) -> tuple[li
90
95
  continue
91
96
  if rule.enforcement == "advisory" and status.status == "pending":
92
97
  continue
93
- code = "missing_spec" if binding.status == "missing" else _status_issue(rule, status, now)
98
+ code = "missing_spec" if binding.status == "missing" else _status_issue(rule, status, now, diff_sha256)
94
99
  if code is None:
95
100
  continue
96
101
  issue = GateIssue(code, binding.spec_id, rule.ref, stage, f"{binding.spec_id}#{rule.ref}: {code}")
@@ -105,13 +110,14 @@ def validate_stage(
105
110
  artifact: str = "",
106
111
  task_id: str = "",
107
112
  now: Optional[datetime] = None,
113
+ diff_sha256: Optional[str] = None,
108
114
  ) -> GateResult:
109
115
  del artifact, task_id
110
116
  current = now or datetime.now(timezone.utc)
111
117
  errors: list[GateIssue] = []
112
118
  warnings: list[GateIssue] = []
113
119
  for binding in context.bindings:
114
- binding_errors, binding_warnings = _binding_issues(binding, stage, current)
120
+ binding_errors, binding_warnings = _binding_issues(binding, stage, current, diff_sha256)
115
121
  errors.extend(binding_errors)
116
122
  warnings.extend(binding_warnings)
117
123
  refs = tuple(sorted({f"{item.spec_id}#{item.rule_ref}" for item in (*errors, *warnings)}))
@@ -21,6 +21,7 @@ class RouteResult:
21
21
  mode: str
22
22
  text: str
23
23
  specs: tuple[str, ...]
24
+ context_sha256: Optional[str] = None
24
25
 
25
26
 
26
27
  class RouterError(ValueError):
@@ -52,12 +53,15 @@ def _active_route(root: str) -> RouteResult:
52
53
  raise RouterError("invalid_active_task", str(exc)) from exc
53
54
  current_hash = context_sha256(context)
54
55
  if current_hash != active.context_sha256:
55
- raise RouterError("active_context_drift", "active marker Context hash does not match")
56
+ raise RouterError(
57
+ "active_context_drift",
58
+ "active marker Context hash does not match; run `cf-spec doctor` (resync) to re-sync the marker before continuing",
59
+ )
56
60
  projection = project_task_session(context, str(_task_file(task_dir, active.task_id)), active.task_id)
57
61
  if projection.truncated:
58
62
  raise RouterError("task_projection_truncated", "split the TASK before coding")
59
63
  refs = tuple(binding.spec_id for binding in context.bindings)
60
- return RouteResult("task", projection.text, refs)
64
+ return RouteResult("task", projection.text, refs, current_hash)
61
65
 
62
66
 
63
67
  def _read_candidates(root: str, paths: Sequence[str]) -> RouteResult:
@@ -5,16 +5,13 @@ from __future__ import annotations
5
5
 
6
6
  import argparse
7
7
  from dataclasses import dataclass
8
- import hashlib
9
8
  import json
10
9
  from pathlib import Path
11
10
  import re
12
11
  import sys
13
12
  from typing import IO, Mapping, Optional, Sequence
14
13
 
15
- import yaml
16
-
17
- from cf_spec_context import RuleBinding, SpecContext, context_to_data, load_context
14
+ from cf_spec_context import RuleBinding, SpecContext, context_sha256, load_context
18
15
 
19
16
 
20
17
  @dataclass(frozen=True)
@@ -25,9 +22,8 @@ class SessionProjection:
25
22
  truncated: bool
26
23
 
27
24
 
28
- def context_sha256(context: SpecContext) -> str:
29
- data = yaml.safe_dump(context_to_data(context), sort_keys=True, allow_unicode=True).encode()
30
- return hashlib.sha256(data).hexdigest()
25
+ # context_sha256 is defined next to the identity projection in cf_spec_context
26
+ # and re-exported here for callers that import the session module.
31
27
 
32
28
 
33
29
  def _task_section(text: str, task_id: str) -> str:
@@ -70,7 +66,7 @@ def _rule_line(ref: str, rule: RuleBinding, compact: bool = False) -> str:
70
66
 
71
67
 
72
68
  def project_task_session(
73
- context: SpecContext, task_file: str, task_id: str, max_chars: int = 4000
69
+ context: SpecContext, task_file: str, task_id: str, max_chars: int = 12000
74
70
  ) -> SessionProjection:
75
71
  section = _task_section(Path(task_file).read_text(encoding="utf-8"), task_id)
76
72
  refs = _refs(section)
@@ -100,7 +96,7 @@ def _parser() -> argparse.ArgumentParser:
100
96
  parser.add_argument("--task-file", required=True)
101
97
  parser.add_argument("--task", required=True)
102
98
  parser.add_argument("--output", required=True)
103
- parser.add_argument("--budget", type=int, default=4000)
99
+ parser.add_argument("--budget", type=int, default=12000)
104
100
  parser.add_argument("--json", action="store_true")
105
101
  return parser
106
102
 
@@ -11,6 +11,7 @@ import hashlib
11
11
  import json
12
12
  from pathlib import Path
13
13
  import subprocess
14
+ import time
14
15
  from typing import Mapping, Optional, Sequence
15
16
 
16
17
  from cf_checks import run_regex_verifier
@@ -160,13 +161,15 @@ def _argv(config: Mapping[str, object]) -> Optional[list[str]]:
160
161
  return list(value)
161
162
 
162
163
 
163
- def _command(config: Mapping[str, object], scope: VerificationScope) -> _Outcome:
164
+ def _command(config: Mapping[str, object], scope: VerificationScope, timeout_cap: Optional[float] = None) -> _Outcome:
164
165
  argv = _argv(config)
165
166
  if argv is None:
166
167
  return _Outcome(False, "invalid_verifier_config", None, {"required": ["argv"]})
167
168
  cwd = _config_string(config, "cwd") or "."
168
169
  timeout_value = config.get("timeout", 30)
169
170
  timeout = float(timeout_value) if isinstance(timeout_value, (int, float)) else 30.0
171
+ if timeout_cap is not None:
172
+ timeout = min(timeout, timeout_cap)
170
173
  allowed_value = config.get("allowed_exit_codes", [0])
171
174
  allowed = tuple(item for item in allowed_value if isinstance(item, int)) if isinstance(allowed_value, list) else (0,)
172
175
  try:
@@ -209,6 +212,7 @@ def _run(
209
212
  metadata: SpecMetadata,
210
213
  scope: VerificationScope,
211
214
  confirmation: Optional[Mapping[str, object]],
215
+ timeout_cap: Optional[float] = None,
212
216
  ) -> _Outcome:
213
217
  if verifier.type == "document":
214
218
  return _document(verifier.config, scope)
@@ -217,7 +221,7 @@ def _run(
217
221
  if verifier.type == "ast":
218
222
  return _ast(verifier.config, scope)
219
223
  if verifier.type in ("command", "test"):
220
- return _command(verifier.config, scope)
224
+ return _command(verifier.config, scope, timeout_cap)
221
225
  if verifier.type == "manual":
222
226
  return _manual(verifier.config, confirmation)
223
227
  return _Outcome(False, "verifier_type_unimplemented", None, {"type": verifier.type})
@@ -229,8 +233,9 @@ def _evidence(
229
233
  verifier: SpecVerifier,
230
234
  scope: VerificationScope,
231
235
  confirmation: Optional[Mapping[str, object]],
236
+ timeout_cap: Optional[float] = None,
232
237
  ) -> VerificationEvidence:
233
- outcome = _run(verifier, metadata, scope, confirmation)
238
+ outcome = _run(verifier, metadata, scope, confirmation, timeout_cap)
234
239
  status = "verified" if outcome.passed else "unverified"
235
240
  diff_hash = None if verifier.type == "document" else scope.diff_sha256
236
241
  return VerificationEvidence(
@@ -246,17 +251,55 @@ def _evidence(
246
251
  )
247
252
 
248
253
 
254
+ def _skipped_evidence(metadata: SpecMetadata, rule: SpecRule, scope: VerificationScope) -> VerificationEvidence:
255
+ details = {"skipped": True}
256
+ return VerificationEvidence(
257
+ f"{metadata.id}#{rule.ref}",
258
+ datetime.now(timezone.utc).isoformat(),
259
+ "unverified",
260
+ rule.text_sha256,
261
+ None,
262
+ scope.diff_sha256,
263
+ _result_hash("unverified", "skipped_in_cheap_gate", details),
264
+ "skipped_in_cheap_gate",
265
+ details,
266
+ )
267
+
268
+
269
+ def _budget_evidence(metadata: SpecMetadata, rule: SpecRule, scope: VerificationScope, budget: float) -> VerificationEvidence:
270
+ details = {"budget": budget}
271
+ return VerificationEvidence(
272
+ f"{metadata.id}#{rule.ref}",
273
+ datetime.now(timezone.utc).isoformat(),
274
+ "unverified",
275
+ rule.text_sha256,
276
+ None,
277
+ scope.diff_sha256,
278
+ _result_hash("unverified", "verifier_budget_exhausted", details),
279
+ "verifier_budget_exhausted",
280
+ details,
281
+ )
282
+
283
+
249
284
  def run_all_verifiers(
250
285
  metadata: SpecMetadata,
251
286
  scope: VerificationScope,
252
287
  confirmations: Optional[Mapping[str, Mapping[str, object]]] = None,
288
+ skip_command: bool = False,
289
+ timeout_budget: Optional[float] = None,
253
290
  ) -> VerificationResult:
254
291
  verifier_by_rule = {item.rule: item for item in metadata.verifiers}
255
292
  confirmation_by_rule = confirmations or {}
256
293
  evidence: list[VerificationEvidence] = []
294
+ started = time.monotonic()
257
295
  for rule in metadata.rules:
258
296
  if rule.enforcement != "required":
259
297
  continue
298
+ if timeout_budget is not None:
299
+ remaining = timeout_budget - (time.monotonic() - started)
300
+ if remaining <= 0:
301
+ evidence.append(_budget_evidence(metadata, rule, scope, timeout_budget))
302
+ continue
260
303
  verifier = verifier_by_rule.get(rule.ref)
261
304
  if verifier is None:
262
305
  details = {"rule": rule.ref}
@@ -274,7 +317,11 @@ def run_all_verifiers(
274
317
  )
275
318
  )
276
319
  continue
277
- evidence.append(_evidence(metadata, rule, verifier, scope, confirmation_by_rule.get(rule.ref)))
320
+ if skip_command and verifier.type in ("command", "test"):
321
+ evidence.append(_skipped_evidence(metadata, rule, scope))
322
+ continue
323
+ cap = None if timeout_budget is None else max(0.1, timeout_budget - (time.monotonic() - started))
324
+ evidence.append(_evidence(metadata, rule, verifier, scope, confirmation_by_rule.get(rule.ref), cap))
278
325
  result = tuple(evidence)
279
326
  return VerificationResult(result, bool(result) and all(item.status == "verified" for item in result))
280
327