@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.
- package/package.json +1 -1
- package/src/adapters/claude/commands/cf-spec.md +13 -0
- package/src/adapters/claude/commands/cf-sync.md +25 -0
- package/src/adapters/codex/skills/cf-spec/SKILL.md +1 -0
- package/src/adapters/codex/skills/cf-sync/SKILL.md +30 -0
- package/src/adapters/costrict/commands/cf-spec.md +13 -0
- package/src/adapters/costrict/commands/cf-sync.md +25 -0
- package/src/adapters/opencode/commands/cf-spec.md +1 -0
- package/src/adapters/opencode/commands/cf-sync.md +29 -0
- package/src/adapters/opencode/plugins/code-flow/index.js +3 -3
- package/src/core/code-flow/config.yml +2 -1
- package/src/core/code-flow/scripts/cf_core.py +24 -1
- package/src/core/code-flow/scripts/cf_post_hook.py +3 -30
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +37 -14
- package/src/core/code-flow/scripts/cf_session_state.py +37 -0
- package/src/core/code-flow/scripts/cf_spec_context.py +250 -13
- package/src/core/code-flow/scripts/cf_spec_gate.py +12 -6
- package/src/core/code-flow/scripts/cf_spec_router.py +6 -2
- package/src/core/code-flow/scripts/cf_spec_session.py +5 -9
- package/src/core/code-flow/scripts/cf_spec_verify.py +51 -4
- package/src/core/code-flow/scripts/cf_stop_hook.py +33 -12
- package/src/core/code-flow/scripts/cf_sync.py +140 -0
- package/src/core/code-flow/scripts/cf_task_runtime.py +49 -7
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +104 -8
|
@@ -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
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
431
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
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 =
|
|
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=
|
|
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
|
-
|
|
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
|
|