@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.
- 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/claude/commands/cf-task/plan.md +40 -0
- package/src/adapters/claude/commands/cf-task/start.md +6 -7
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +111 -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/codex/skills/cf-task-start/SKILL.md +6 -7
- package/src/adapters/costrict/commands/cf-spec.md +13 -0
- package/src/adapters/costrict/commands/cf-sync.md +25 -0
- package/src/adapters/costrict/commands/cf-task/plan.md +40 -0
- package/src/adapters/costrict/commands/cf-task/start.md +6 -7
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +111 -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/commands/cf-task/plan.md +40 -5
- package/src/adapters/opencode/commands/cf-task/start.md +6 -7
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +111 -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_acceptance_manifest.py +199 -0
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +107 -0
- package/src/core/code-flow/scripts/cf_core.py +44 -1
- package/src/core/code-flow/scripts/cf_post_hook.py +5 -30
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +41 -17
- package/src/core/code-flow/scripts/cf_session_state.py +37 -0
- package/src/core/code-flow/scripts/cf_spec_context.py +381 -29
- package/src/core/code-flow/scripts/cf_spec_gate.py +22 -6
- package/src/core/code-flow/scripts/cf_spec_metadata.py +60 -11
- package/src/core/code-flow/scripts/cf_spec_resolver.py +78 -7
- package/src/core/code-flow/scripts/cf_spec_router.py +107 -10
- package/src/core/code-flow/scripts/cf_spec_session.py +5 -9
- package/src/core/code-flow/scripts/cf_spec_verify.py +166 -9
- package/src/core/code-flow/scripts/cf_stop_hook.py +35 -12
- package/src/core/code-flow/scripts/cf_sync.py +140 -0
- package/src/core/code-flow/scripts/cf_task_runtime.py +95 -7
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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,
|
|
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
|
-
|
|
431
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|