@michaelxwb/code-flow 0.6.1 → 0.6.3
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-task/archive.md +5 -5
- package/src/adapters/claude/commands/cf-task/block.md +15 -9
- package/src/adapters/claude/commands/cf-task/graph.md +8 -1
- package/src/adapters/claude/commands/cf-task/note.md +1 -4
- package/src/adapters/claude/commands/cf-task/plan.md +40 -0
- package/src/adapters/claude/commands/cf-task/start.md +22 -22
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +110 -0
- package/src/adapters/claude/commands/cf-validate.md +12 -13
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +5 -5
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -9
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +8 -1
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +1 -4
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +22 -22
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +106 -0
- package/src/adapters/codex/skills/cf-validate/SKILL.md +12 -13
- package/src/adapters/costrict/commands/cf-task/archive.md +5 -5
- package/src/adapters/costrict/commands/cf-task/block.md +15 -9
- package/src/adapters/costrict/commands/cf-task/graph.md +8 -1
- package/src/adapters/costrict/commands/cf-task/note.md +1 -4
- package/src/adapters/costrict/commands/cf-task/plan.md +40 -0
- package/src/adapters/costrict/commands/cf-task/start.md +22 -22
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +110 -0
- package/src/adapters/costrict/commands/cf-validate.md +12 -13
- package/src/adapters/opencode/commands/cf-task/archive.md +5 -5
- package/src/adapters/opencode/commands/cf-task/block.md +15 -9
- package/src/adapters/opencode/commands/cf-task/graph.md +8 -1
- package/src/adapters/opencode/commands/cf-task/note.md +1 -4
- package/src/adapters/opencode/commands/cf-task/plan.md +40 -5
- package/src/adapters/opencode/commands/cf-task/start.md +22 -22
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +110 -0
- package/src/adapters/opencode/commands/cf-validate.md +12 -13
- package/src/adapters/opencode/plugins/code-flow/index.js +50 -28
- package/src/cli.js +177 -63
- package/src/core/code-flow/scripts/cf_acceptance_evidence.py +100 -0
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +248 -0
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +192 -0
- package/src/core/code-flow/scripts/cf_acceptance_schema.py +83 -0
- package/src/core/code-flow/scripts/cf_core.py +26 -0
- package/src/core/code-flow/scripts/cf_exec_base.py +112 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +2 -0
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +13 -8
- package/src/core/code-flow/scripts/cf_scan.py +8 -6
- package/src/core/code-flow/scripts/cf_spec_context.py +267 -28
- package/src/core/code-flow/scripts/cf_spec_gate.py +10 -0
- package/src/core/code-flow/scripts/cf_spec_metadata.py +60 -11
- package/src/core/code-flow/scripts/cf_spec_resolver.py +85 -9
- package/src/core/code-flow/scripts/cf_spec_router.py +104 -9
- package/src/core/code-flow/scripts/cf_spec_session.py +3 -2
- package/src/core/code-flow/scripts/cf_spec_verify.py +159 -38
- package/src/core/code-flow/scripts/cf_stats.py +41 -9
- package/src/core/code-flow/scripts/cf_stop_hook.py +100 -59
- package/src/core/code-flow/scripts/cf_task_index.py +147 -0
- package/src/core/code-flow/scripts/cf_task_runtime.py +84 -3
- package/src/core/code-flow/scripts/cf_task_state.py +3 -0
- package/src/core/code-flow/scripts/cf_task_workflow.py +107 -0
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +11 -5
- package/src/core/code-flow/scripts/cf_validation.py +81 -0
- package/src/core/code-flow/scripts/cf_workflow_service.py +366 -0
- package/src/core/code-flow/scripts/cf_workflow_transaction.py +96 -0
|
@@ -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",
|
|
@@ -154,6 +159,10 @@ class ActiveBaseline:
|
|
|
154
159
|
head: Optional[str]
|
|
155
160
|
captured_at: str
|
|
156
161
|
preexisting_changes: Mapping[str, PathSnapshot]
|
|
162
|
+
# head is frozen at start and never rewritten: task scope is the union of
|
|
163
|
+
# everything committed since head plus all working-tree changes.
|
|
164
|
+
# last_seen_head is observation-only (diagnostics), never scope input.
|
|
165
|
+
last_seen_head: Optional[str] = None
|
|
157
166
|
|
|
158
167
|
|
|
159
168
|
@dataclass(frozen=True)
|
|
@@ -208,7 +217,7 @@ def _active_paths(root: str) -> tuple[Path, Path]:
|
|
|
208
217
|
def _run_git(root: str, arguments: Sequence[str]) -> str:
|
|
209
218
|
try:
|
|
210
219
|
result = subprocess.run(
|
|
211
|
-
("git", *arguments), cwd=root, text=True, capture_output=True, check=False
|
|
220
|
+
("git", *arguments), cwd=root, text=True, encoding="utf-8", capture_output=True, check=False
|
|
212
221
|
)
|
|
213
222
|
except OSError as exc:
|
|
214
223
|
raise ContextError("git_unavailable", "git", str(exc), root) from exc
|
|
@@ -267,16 +276,6 @@ def _is_excluded(path: str, patterns: Sequence[str]) -> bool:
|
|
|
267
276
|
return any(fnmatchcase(path, pattern) for pattern in patterns)
|
|
268
277
|
|
|
269
278
|
|
|
270
|
-
def _path_hash(root: str, path: str) -> str:
|
|
271
|
-
target = Path(root) / path
|
|
272
|
-
digest = hashlib.sha256()
|
|
273
|
-
if target.is_file():
|
|
274
|
-
with target.open("rb") as handle:
|
|
275
|
-
for chunk in iter(lambda: handle.read(65536), b""):
|
|
276
|
-
digest.update(chunk)
|
|
277
|
-
return digest.hexdigest()
|
|
278
|
-
|
|
279
|
-
|
|
280
279
|
def _active_data(active: ActiveTask) -> dict[str, object]:
|
|
281
280
|
changes = {
|
|
282
281
|
path: {"status": item.status, "content_sha256": item.content_sha256}
|
|
@@ -290,6 +289,7 @@ def _active_data(active: ActiveTask) -> dict[str, object]:
|
|
|
290
289
|
"context_sha256": active.context_sha256,
|
|
291
290
|
"baseline": {
|
|
292
291
|
"head": active.baseline.head,
|
|
292
|
+
"last_seen_head": active.baseline.last_seen_head,
|
|
293
293
|
"captured_at": active.baseline.captured_at,
|
|
294
294
|
"preexisting_changes": changes,
|
|
295
295
|
},
|
|
@@ -300,9 +300,10 @@ def _active_data(active: ActiveTask) -> dict[str, object]:
|
|
|
300
300
|
|
|
301
301
|
def _snapshot_from_data(value: object, path: str) -> PathSnapshot:
|
|
302
302
|
data = _mapping(value, "preexisting_changes[]", path)
|
|
303
|
+
content_hash = data.get("content_sha256")
|
|
303
304
|
return PathSnapshot(
|
|
304
305
|
_string(data.get("status"), "status", path),
|
|
305
|
-
|
|
306
|
+
content_hash if isinstance(content_hash, str) else "",
|
|
306
307
|
)
|
|
307
308
|
|
|
308
309
|
|
|
@@ -323,6 +324,7 @@ def _active_from_data(value: object, path: str) -> ActiveTask:
|
|
|
323
324
|
head,
|
|
324
325
|
_string(baseline_data.get("captured_at"), "captured_at", path),
|
|
325
326
|
{key: _snapshot_from_data(item, path) for key, item in raw_changes.items()},
|
|
327
|
+
baseline_data.get("last_seen_head") if isinstance(baseline_data.get("last_seen_head"), str) else None,
|
|
326
328
|
)
|
|
327
329
|
return ActiveTask(
|
|
328
330
|
1,
|
|
@@ -355,6 +357,58 @@ def load_active_task(root: str) -> ActiveTask:
|
|
|
355
357
|
raise ContextError("invalid_active_marker", "active", str(exc), str(marker)) from exc
|
|
356
358
|
|
|
357
359
|
|
|
360
|
+
def task_contract_digest(root: str, task_dir: str, task_id: str) -> str:
|
|
361
|
+
"""Digest of the active TASK's acceptance contract (Refs + Contract + Evidence).
|
|
362
|
+
|
|
363
|
+
Two TASKs may share one Context hash while owning different scenarios; the
|
|
364
|
+
digest separates their injection versions so switching TASKs re-injects.
|
|
365
|
+
Returns "" when the TASK section cannot be located (fail-open to the
|
|
366
|
+
legacy context-hash-only key rather than crashing the hook hot path).
|
|
367
|
+
"""
|
|
368
|
+
try:
|
|
369
|
+
files = sorted((Path(root) / task_dir).glob("*.md"))
|
|
370
|
+
except OSError:
|
|
371
|
+
return ""
|
|
372
|
+
for path in files:
|
|
373
|
+
if path.name.endswith((".prd.md", ".design.md")):
|
|
374
|
+
continue
|
|
375
|
+
try:
|
|
376
|
+
text = path.read_text(encoding="utf-8")
|
|
377
|
+
except (OSError, UnicodeError):
|
|
378
|
+
continue
|
|
379
|
+
start = text.find(f"## {task_id}:")
|
|
380
|
+
if start == -1:
|
|
381
|
+
continue
|
|
382
|
+
end = text.find("\n## TASK-", start)
|
|
383
|
+
section = text[start:] if end == -1 else text[start:end]
|
|
384
|
+
chunks: list[str] = []
|
|
385
|
+
for heading in ("Acceptance-Refs", "Acceptance Contract", "Acceptance Evidence"):
|
|
386
|
+
index = section.find(heading)
|
|
387
|
+
if index == -1:
|
|
388
|
+
chunks.append(f"{heading}:missing")
|
|
389
|
+
continue
|
|
390
|
+
end = section.find("\n### ", index)
|
|
391
|
+
chunks.append(section[index:] if end == -1 else section[index:end])
|
|
392
|
+
return hashlib.sha256("\n".join(chunks).encode("utf-8")).hexdigest()
|
|
393
|
+
return ""
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def injection_version(root: str, session_id: str, context_sha: str) -> str:
|
|
397
|
+
"""Version key for hook injection dedup.
|
|
398
|
+
|
|
399
|
+
Covers session + task_dir + task_id + context hash + TASK contract digest,
|
|
400
|
+
so a TASK switch (or an in-place contract edit) always re-injects while a
|
|
401
|
+
repeated prompt on the same contract stays suppressed.
|
|
402
|
+
"""
|
|
403
|
+
try:
|
|
404
|
+
active = load_active_task(root)
|
|
405
|
+
except (ContextError, OSError, ValueError):
|
|
406
|
+
return f"none:{session_id}:{context_sha}"
|
|
407
|
+
digest = task_contract_digest(root, active.task_dir, active.task_id)
|
|
408
|
+
raw = "\n".join((session_id, active.task_dir, active.task_id, context_sha, digest))
|
|
409
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
410
|
+
|
|
411
|
+
|
|
358
412
|
def resync_active_hash(root: str, task_dir: str, current_sha256: str) -> bool:
|
|
359
413
|
"""Re-sync the active marker's context hash after toolchain-initiated
|
|
360
414
|
context edits (bind/decision/refresh) so they never surface as
|
|
@@ -437,6 +491,42 @@ def _release_active_lock(lock: Path) -> None:
|
|
|
437
491
|
return
|
|
438
492
|
|
|
439
493
|
|
|
494
|
+
def _committed_since(root: str, base_head: Optional[str]) -> Mapping[str, str]:
|
|
495
|
+
"""Paths committed between the frozen baseline head and current HEAD.
|
|
496
|
+
|
|
497
|
+
Returns {} when there is no baseline (pre-commit repo) or the range is
|
|
498
|
+
unresolvable (rewritten/pruned history) — working-tree changes still
|
|
499
|
+
flow through _business_changes, so scope never goes fully blind.
|
|
500
|
+
"""
|
|
501
|
+
if not base_head:
|
|
502
|
+
return {}
|
|
503
|
+
try:
|
|
504
|
+
output = _run_git(root, ("diff", "--name-status", "-z", base_head, "HEAD", "--"))
|
|
505
|
+
except ContextError:
|
|
506
|
+
return {}
|
|
507
|
+
names: dict[str, str] = {}
|
|
508
|
+
tokens = output.split("\0")
|
|
509
|
+
index = 0
|
|
510
|
+
while index < len(tokens) and tokens[index]:
|
|
511
|
+
status, index = tokens[index], index + 1
|
|
512
|
+
code = status[:1]
|
|
513
|
+
if code in ("R", "C"):
|
|
514
|
+
if index + 1 >= len(tokens):
|
|
515
|
+
break
|
|
516
|
+
new_path, old_path = tokens[index], tokens[index + 1]
|
|
517
|
+
index += 2
|
|
518
|
+
if new_path:
|
|
519
|
+
names[new_path] = "renamed"
|
|
520
|
+
if old_path:
|
|
521
|
+
names[old_path] = "deleted"
|
|
522
|
+
continue
|
|
523
|
+
if index >= len(tokens) or not tokens[index]:
|
|
524
|
+
break
|
|
525
|
+
names[tokens[index]] = {"M": "modified", "A": "added", "D": "deleted"}.get(code, "changed")
|
|
526
|
+
index += 1
|
|
527
|
+
return names
|
|
528
|
+
|
|
529
|
+
|
|
440
530
|
def _business_changes(root: str, excludes: Sequence[str]) -> Mapping[str, str]:
|
|
441
531
|
return {
|
|
442
532
|
path: status
|
|
@@ -460,10 +550,14 @@ def _new_active(
|
|
|
460
550
|
raise ContextError("unknown_owned_path", "owned_paths", ", ".join(unknown), root)
|
|
461
551
|
if unowned:
|
|
462
552
|
raise ContextError("unowned_changes", "owned_paths", ", ".join(unowned), root)
|
|
553
|
+
# content_sha256 was a per-file hash but is never consumed by any gate (only
|
|
554
|
+
# baseline.head is compared); keep the schema field empty so start never hashes
|
|
555
|
+
# every uncommitted file.
|
|
463
556
|
snapshots = {
|
|
464
|
-
path: PathSnapshot(status,
|
|
557
|
+
path: PathSnapshot(status, "") for path, status in changes.items()
|
|
465
558
|
}
|
|
466
|
-
|
|
559
|
+
head = _git_head(root)
|
|
560
|
+
baseline = ActiveBaseline(head, _now(), snapshots, head)
|
|
467
561
|
return ActiveTask(
|
|
468
562
|
1, task_dir, task_id, "activating", context_sha256, baseline, owned, DEFAULT_ACTIVE_EXCLUDES
|
|
469
563
|
)
|
|
@@ -499,9 +593,10 @@ def _transition_active(root: str, expected: Sequence[str], target: str) -> Activ
|
|
|
499
593
|
raise ContextError("invalid_active_transition", "status", f"{active.status} -> {target}", root)
|
|
500
594
|
current_head = _git_head(root)
|
|
501
595
|
if active.baseline.head != current_head:
|
|
502
|
-
# Mid-task commits are a normal workflow
|
|
503
|
-
#
|
|
504
|
-
|
|
596
|
+
# Mid-task commits are a normal workflow. The baseline head stays
|
|
597
|
+
# frozen so committed work never drops out of scope; only the
|
|
598
|
+
# observation field advances for diagnostics.
|
|
599
|
+
active = replace(active, baseline=replace(active.baseline, last_seen_head=current_head))
|
|
505
600
|
updated = replace(active, status=target)
|
|
506
601
|
save_active_task(root, updated)
|
|
507
602
|
return updated
|
|
@@ -523,7 +618,12 @@ def resume_active_task(root: str) -> ActiveTask:
|
|
|
523
618
|
|
|
524
619
|
def current_owned_paths(root: str, active: ActiveTask) -> tuple[str, ...]:
|
|
525
620
|
changes = _business_changes(root, active.excluded_paths)
|
|
526
|
-
|
|
621
|
+
committed = {
|
|
622
|
+
path: status
|
|
623
|
+
for path, status in _committed_since(root, active.baseline.head).items()
|
|
624
|
+
if not _is_excluded(path, active.excluded_paths)
|
|
625
|
+
}
|
|
626
|
+
return tuple(sorted(set(active.owned_paths).union(changes).union(committed)))
|
|
527
627
|
|
|
528
628
|
|
|
529
629
|
def complete_active_task(root: str, gate_passed: bool) -> ActiveTask:
|
|
@@ -822,6 +922,7 @@ def context_sha256(context: SpecContext) -> str:
|
|
|
822
922
|
Computed from the stable identity projection (never volatile runtime state),
|
|
823
923
|
so evidence timestamps and stage statuses cannot drift the marker.
|
|
824
924
|
"""
|
|
925
|
+
import yaml
|
|
825
926
|
data = yaml.safe_dump(context_to_identity(context), sort_keys=True, allow_unicode=True).encode()
|
|
826
927
|
return hashlib.sha256(data).hexdigest()
|
|
827
928
|
|
|
@@ -834,6 +935,7 @@ def new_context(task: str, sources: Sequence[tuple[str, str]]) -> SpecContext:
|
|
|
834
935
|
|
|
835
936
|
|
|
836
937
|
def load_context(path: str) -> SpecContext:
|
|
938
|
+
import yaml
|
|
837
939
|
try:
|
|
838
940
|
loaded = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
|
|
839
941
|
except (OSError, UnicodeError, yaml.YAMLError) as exc:
|
|
@@ -862,6 +964,7 @@ def load_context(path: str) -> SpecContext:
|
|
|
862
964
|
|
|
863
965
|
|
|
864
966
|
def save_context(path: str, context: SpecContext) -> None:
|
|
967
|
+
import yaml
|
|
865
968
|
target = Path(path)
|
|
866
969
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
867
970
|
text = yaml.safe_dump(context_to_data(context), sort_keys=False, allow_unicode=True)
|
|
@@ -965,7 +1068,57 @@ def _removed_rule(rule: RuleBinding) -> RuleBinding:
|
|
|
965
1068
|
|
|
966
1069
|
def _file_sha256(path: Path) -> str:
|
|
967
1070
|
try:
|
|
968
|
-
|
|
1071
|
+
stat = path.stat()
|
|
1072
|
+
key = str(path)
|
|
1073
|
+
cached = _FILE_HASH_CACHE.get(key)
|
|
1074
|
+
signature = (stat.st_mtime_ns, stat.st_size, stat.st_ino)
|
|
1075
|
+
if cached is not None and cached[:3] == signature:
|
|
1076
|
+
return cached[3]
|
|
1077
|
+
cache_path = next(
|
|
1078
|
+
(parent / ".artifact-hash-cache.json" for parent in path.parents if parent.name == ".code-flow"),
|
|
1079
|
+
None,
|
|
1080
|
+
)
|
|
1081
|
+
cache_key = str(cache_path) if cache_path is not None else ""
|
|
1082
|
+
if cache_key and cache_key not in _PERSISTENT_HASH_PATHS:
|
|
1083
|
+
try:
|
|
1084
|
+
loaded = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
1085
|
+
if isinstance(loaded, dict):
|
|
1086
|
+
_PERSISTENT_HASH_CACHE.update({str(k): v for k, v in loaded.items() if isinstance(v, dict)})
|
|
1087
|
+
except (OSError, ValueError):
|
|
1088
|
+
pass
|
|
1089
|
+
_PERSISTENT_HASH_PATHS.add(cache_key)
|
|
1090
|
+
persistent = _PERSISTENT_HASH_CACHE.get(key)
|
|
1091
|
+
if (
|
|
1092
|
+
isinstance(persistent, dict)
|
|
1093
|
+
and persistent.get("mtime_ns") == stat.st_mtime_ns
|
|
1094
|
+
and persistent.get("size") == stat.st_size
|
|
1095
|
+
and persistent.get("ino") == stat.st_ino
|
|
1096
|
+
):
|
|
1097
|
+
digest = persistent.get("sha256")
|
|
1098
|
+
if isinstance(digest, str) and digest:
|
|
1099
|
+
_FILE_HASH_CACHE[key] = (signature[0], signature[1], signature[2], digest)
|
|
1100
|
+
return digest
|
|
1101
|
+
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
1102
|
+
_FILE_HASH_CACHE[key] = (signature[0], signature[1], signature[2], digest)
|
|
1103
|
+
if cache_key:
|
|
1104
|
+
_PERSISTENT_HASH_CACHE[key] = {
|
|
1105
|
+
"mtime_ns": stat.st_mtime_ns,
|
|
1106
|
+
"size": stat.st_size,
|
|
1107
|
+
"ino": stat.st_ino,
|
|
1108
|
+
"sha256": digest,
|
|
1109
|
+
}
|
|
1110
|
+
while len(_PERSISTENT_HASH_CACHE) > _MAX_HASH_CACHE_ENTRIES:
|
|
1111
|
+
_PERSISTENT_HASH_CACHE.pop(next(iter(_PERSISTENT_HASH_CACHE)))
|
|
1112
|
+
try:
|
|
1113
|
+
temporary = cache_path.with_suffix(".json.tmp")
|
|
1114
|
+
temporary.write_text(
|
|
1115
|
+
json.dumps(_PERSISTENT_HASH_CACHE, ensure_ascii=False, sort_keys=True),
|
|
1116
|
+
encoding="utf-8",
|
|
1117
|
+
)
|
|
1118
|
+
temporary.replace(cache_path)
|
|
1119
|
+
except OSError:
|
|
1120
|
+
pass
|
|
1121
|
+
return digest
|
|
969
1122
|
except OSError as exc:
|
|
970
1123
|
raise ContextError("artifact_read_error", "artifact", str(exc), str(path)) from exc
|
|
971
1124
|
|
|
@@ -1057,15 +1210,19 @@ def _refresh_binding(
|
|
|
1057
1210
|
|
|
1058
1211
|
|
|
1059
1212
|
def refresh_context(context: SpecContext, root: str, artifact_root: Optional[str] = None) -> DriftResult:
|
|
1213
|
+
started = time.monotonic()
|
|
1060
1214
|
specs = Path(root) / ".code-flow" / "specs"
|
|
1061
1215
|
artifacts = Path(artifact_root) if artifact_root is not None else Path(root)
|
|
1062
1216
|
bindings: list[SpecBinding] = []
|
|
1063
1217
|
changes: list[DriftChange] = []
|
|
1064
1218
|
for binding in context.bindings:
|
|
1219
|
+
phase_started = time.monotonic()
|
|
1065
1220
|
updated, binding_changes = _refresh_binding(binding, specs, artifacts)
|
|
1221
|
+
phase_timing(f"context.refresh.{binding.spec_id}", phase_started)
|
|
1066
1222
|
bindings.append(updated)
|
|
1067
1223
|
changes.extend(binding_changes)
|
|
1068
1224
|
refreshed = replace(context, updated_at=_now(), bindings=tuple(bindings))
|
|
1225
|
+
phase_timing("context.refresh.total", started)
|
|
1069
1226
|
if refreshed.bindings == context.bindings:
|
|
1070
1227
|
return DriftResult(context, tuple(changes))
|
|
1071
1228
|
return DriftResult(refreshed, tuple(changes))
|
|
@@ -1281,23 +1438,75 @@ def _bind_command(args: argparse.Namespace, payload: Mapping[str, object]) -> di
|
|
|
1281
1438
|
|
|
1282
1439
|
|
|
1283
1440
|
def _active_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
|
|
1441
|
+
from cf_workflow_service import WorkflowError, locate_task_file
|
|
1442
|
+
from cf_workflow_transaction import recover_transition
|
|
1443
|
+
|
|
1444
|
+
recover_transition(args.root)
|
|
1445
|
+
if args.active_action in ("pause", "resume", "block", "complete"):
|
|
1446
|
+
marker, _ = _active_paths(args.root)
|
|
1447
|
+
if marker.exists():
|
|
1448
|
+
current = load_active_task(args.root)
|
|
1449
|
+
if (current.task_id != args.task or
|
|
1450
|
+
(Path(args.root) / current.task_dir).resolve() != (Path(args.root) / args.task_dir).resolve()):
|
|
1451
|
+
raise ContextError("active_mismatch", "active", "requested TASK does not match marker", str(marker))
|
|
1452
|
+
|
|
1453
|
+
def _directory() -> str:
|
|
1454
|
+
return args.task_dir if Path(args.task_dir).is_absolute() else str(Path(args.root) / args.task_dir)
|
|
1455
|
+
|
|
1456
|
+
def _reraise(exc: WorkflowError) -> ContextError:
|
|
1457
|
+
return ContextError(exc.code, "workflow", str(exc), args.task_dir)
|
|
1458
|
+
|
|
1459
|
+
task_file = locate_task_file(_directory(), args.task)
|
|
1284
1460
|
if args.active_action == "start":
|
|
1285
1461
|
owned = tuple(
|
|
1286
1462
|
_string(item, "owned_paths[]", "")
|
|
1287
1463
|
for item in _sequence(payload.get("owned_paths", []), "owned_paths", "")
|
|
1288
1464
|
)
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1465
|
+
if task_file is None:
|
|
1466
|
+
active = start_active_task(
|
|
1467
|
+
args.root, args.task_dir, args.task, args.context_sha256, owned
|
|
1468
|
+
)
|
|
1469
|
+
return {"ok": True, "active": _active_data(active)}
|
|
1470
|
+
from cf_workflow_service import start_task
|
|
1471
|
+
|
|
1472
|
+
try:
|
|
1473
|
+
return start_task(args.root, args.task_dir, str(task_file), args.task, owned)
|
|
1474
|
+
except WorkflowError as exc:
|
|
1475
|
+
raise _reraise(exc) from exc
|
|
1293
1476
|
if args.active_action == "pause":
|
|
1294
1477
|
active = pause_active_task(args.root)
|
|
1295
1478
|
elif args.active_action == "resume":
|
|
1296
|
-
|
|
1479
|
+
if task_file is None:
|
|
1480
|
+
active = resume_active_task(args.root)
|
|
1481
|
+
else:
|
|
1482
|
+
from cf_workflow_service import resume_task
|
|
1483
|
+
|
|
1484
|
+
try:
|
|
1485
|
+
return resume_task(args.root, _directory(), str(task_file), args.task)
|
|
1486
|
+
except WorkflowError as exc:
|
|
1487
|
+
raise _reraise(exc) from exc
|
|
1297
1488
|
elif args.active_action == "block":
|
|
1298
|
-
|
|
1489
|
+
if task_file is None:
|
|
1490
|
+
active = block_active_task(args.root)
|
|
1491
|
+
else:
|
|
1492
|
+
from cf_workflow_service import block_task
|
|
1493
|
+
|
|
1494
|
+
reason = payload.get("reason", "")
|
|
1495
|
+
try:
|
|
1496
|
+
return block_task(args.root, _directory(), str(task_file), args.task, reason if isinstance(reason, str) else "")
|
|
1497
|
+
except WorkflowError as exc:
|
|
1498
|
+
raise _reraise(exc) from exc
|
|
1299
1499
|
elif args.active_action == "complete":
|
|
1300
|
-
|
|
1500
|
+
gate = payload.get("gate_passed") is True
|
|
1501
|
+
if task_file is None:
|
|
1502
|
+
active = complete_active_task(args.root, gate)
|
|
1503
|
+
else:
|
|
1504
|
+
from cf_workflow_service import complete_task
|
|
1505
|
+
|
|
1506
|
+
try:
|
|
1507
|
+
return complete_task(args.root, _directory(), str(task_file), args.task, gate)
|
|
1508
|
+
except WorkflowError as exc:
|
|
1509
|
+
raise _reraise(exc) from exc
|
|
1301
1510
|
else:
|
|
1302
1511
|
result = doctor_active_task(
|
|
1303
1512
|
args.root,
|
|
@@ -1309,6 +1518,27 @@ def _active_command(args: argparse.Namespace, payload: Mapping[str, object]) ->
|
|
|
1309
1518
|
return {"ok": True, "active": _active_data(active)}
|
|
1310
1519
|
|
|
1311
1520
|
|
|
1521
|
+
def _start_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
|
|
1522
|
+
"""Run refresh, activation and TASK projection in one guarded process.
|
|
1523
|
+
|
|
1524
|
+
Delegates to the unified workflow service: hard preconditions (status,
|
|
1525
|
+
NOTES, depends, marker) are enforced before any state changes, and the
|
|
1526
|
+
Markdown view flips to in-progress in the same call.
|
|
1527
|
+
"""
|
|
1528
|
+
from cf_workflow_service import WorkflowError, start_task
|
|
1529
|
+
|
|
1530
|
+
owned = tuple(
|
|
1531
|
+
_string(item, "owned_paths[]", "")
|
|
1532
|
+
for item in _sequence(payload.get("owned_paths", []), "owned_paths", "")
|
|
1533
|
+
)
|
|
1534
|
+
try:
|
|
1535
|
+
return start_task(
|
|
1536
|
+
args.root, args.task_dir, args.task_file, args.task, owned, args.session_output
|
|
1537
|
+
)
|
|
1538
|
+
except WorkflowError as exc:
|
|
1539
|
+
raise ContextError(exc.code, "workflow", str(exc), args.task_dir) from exc
|
|
1540
|
+
|
|
1541
|
+
|
|
1312
1542
|
def _status_command(args: argparse.Namespace) -> dict[str, object]:
|
|
1313
1543
|
"""Human-readable Spec Context status: task, marker health, gate, rules."""
|
|
1314
1544
|
from cf_spec_gate import result_to_data, validate_stage # local import avoids module cycle
|
|
@@ -1413,6 +1643,13 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
1413
1643
|
command.add_argument("--task", required=True)
|
|
1414
1644
|
command.add_argument("--context-sha256", required=True)
|
|
1415
1645
|
command.add_argument("--json", action="store_true")
|
|
1646
|
+
start = commands.add_parser("start")
|
|
1647
|
+
start.add_argument("--task-dir", required=True)
|
|
1648
|
+
start.add_argument("--root", required=True)
|
|
1649
|
+
start.add_argument("--task", required=True)
|
|
1650
|
+
start.add_argument("--task-file", required=True)
|
|
1651
|
+
start.add_argument("--session-output", default="")
|
|
1652
|
+
start.add_argument("--json", action="store_true")
|
|
1416
1653
|
return parser
|
|
1417
1654
|
|
|
1418
1655
|
|
|
@@ -1421,6 +1658,8 @@ def _execute(args: argparse.Namespace, stdin: IO[str]) -> dict[str, object]:
|
|
|
1421
1658
|
return _catalog_command(args)
|
|
1422
1659
|
if args.command == "active":
|
|
1423
1660
|
return _active_command(args, _json_payload(stdin))
|
|
1661
|
+
if args.command == "start":
|
|
1662
|
+
return _start_command(args, _json_payload(stdin))
|
|
1424
1663
|
context_path = Path(args.task_dir) / "spec-context.yml"
|
|
1425
1664
|
if args.command == "decision":
|
|
1426
1665
|
return _decision_command(args, _json_payload(stdin))
|
|
@@ -10,9 +10,11 @@ import json
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
import re
|
|
12
12
|
import sys
|
|
13
|
+
import time
|
|
13
14
|
from typing import IO, Mapping, Optional, Sequence
|
|
14
15
|
|
|
15
16
|
from cf_spec_context import Decision, RuleBinding, RuleStageStatus, SpecBinding, SpecContext, load_context
|
|
17
|
+
from cf_core import phase_timing
|
|
16
18
|
|
|
17
19
|
|
|
18
20
|
@dataclass(frozen=True)
|
|
@@ -212,13 +214,21 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
212
214
|
|
|
213
215
|
|
|
214
216
|
def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
|
|
217
|
+
started = time.monotonic()
|
|
215
218
|
try:
|
|
216
219
|
args = _parser().parse_args(argv)
|
|
220
|
+
phase_started = time.monotonic()
|
|
217
221
|
context = load_context(str(Path(args.task_dir) / "spec-context.yml"))
|
|
222
|
+
phase_timing("gate.load_context", phase_started)
|
|
223
|
+
phase_started = time.monotonic()
|
|
218
224
|
result = validate_stage(context, args.stage, task_id=args.task)
|
|
225
|
+
phase_timing("gate.validate_stage", phase_started)
|
|
219
226
|
if args.stage == "plan" and args.artifact:
|
|
227
|
+
phase_started = time.monotonic()
|
|
220
228
|
result = _merge_results(result, validate_plan_coverage(context, args.artifact))
|
|
229
|
+
phase_timing("gate.validate_plan", phase_started)
|
|
221
230
|
stdout.write(json.dumps(result_to_data(result), ensure_ascii=False))
|
|
231
|
+
phase_timing("gate.total", started)
|
|
222
232
|
return 0 if result.decision == "pass" else 3
|
|
223
233
|
except Exception as exc:
|
|
224
234
|
sys.stderr.write(f"cf_spec_gate error: {exc}\n")
|
|
@@ -10,7 +10,6 @@ from pathlib import Path
|
|
|
10
10
|
import re
|
|
11
11
|
from typing import Mapping, Optional
|
|
12
12
|
|
|
13
|
-
import yaml
|
|
14
13
|
|
|
15
14
|
|
|
16
15
|
ALLOWED_STAGES = frozenset(("prd", "design", "plan", "code", "review"))
|
|
@@ -20,6 +19,8 @@ _ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
|
20
19
|
_RULE_ID_RE = re.compile(r"^RULE-[a-z0-9]+(?:-[a-z0-9]+)*-\d{3}$")
|
|
21
20
|
_EXPLICIT_RULE_RE = re.compile(r"^\[(RULE-[a-z0-9-]+-\d{3})\]\s+(.+)$")
|
|
22
21
|
_FRONTMATTER_RE = re.compile(r"\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|$)", re.DOTALL)
|
|
22
|
+
_FRONTMATTER_OPEN_RE = re.compile(br"---[ \t]*\r?\n")
|
|
23
|
+
_FRONTMATTER_CLOSE_RE = re.compile(br"---[ \t]*(?:\r?\n)?")
|
|
23
24
|
_SECTION_RE = re.compile(r"^##\s+(Rules|Anti-Patterns|Patterns|Examples)\s*$")
|
|
24
25
|
_H2_RE = re.compile(r"^##\s+")
|
|
25
26
|
_BULLET_RE = re.compile(r"^\s*-\s+(.+\S|\S)\s*$")
|
|
@@ -63,6 +64,13 @@ class SpecRule:
|
|
|
63
64
|
line: int
|
|
64
65
|
|
|
65
66
|
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class SpecHeader:
|
|
69
|
+
id: str
|
|
70
|
+
stages: tuple[str, ...]
|
|
71
|
+
enforcement: str
|
|
72
|
+
|
|
73
|
+
|
|
66
74
|
@dataclass(frozen=True)
|
|
67
75
|
class SpecMetadata:
|
|
68
76
|
id: str
|
|
@@ -116,6 +124,11 @@ def _split_frontmatter(content: str, path: str) -> tuple[Mapping[str, object], s
|
|
|
116
124
|
if not match:
|
|
117
125
|
raise SpecMetadataError(path, "frontmatter", 1, "缺失或未闭合 YAML frontmatter")
|
|
118
126
|
raw = match.group(1)
|
|
127
|
+
return _parse_frontmatter(raw, path), raw, content[match.end():]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parse_frontmatter(raw: str, path: str) -> Mapping[str, object]:
|
|
131
|
+
import yaml
|
|
119
132
|
try:
|
|
120
133
|
loaded = yaml.safe_load(raw) or {}
|
|
121
134
|
except yaml.YAMLError as exc:
|
|
@@ -126,7 +139,7 @@ def _split_frontmatter(content: str, path: str) -> tuple[Mapping[str, object], s
|
|
|
126
139
|
raise SpecMetadataError(path, "frontmatter", 2, "顶层必须是 mapping")
|
|
127
140
|
if not all(isinstance(key, str) for key in loaded):
|
|
128
141
|
raise SpecMetadataError(path, "frontmatter", 2, "所有顶层 key 必须是字符串")
|
|
129
|
-
return loaded
|
|
142
|
+
return loaded
|
|
130
143
|
|
|
131
144
|
|
|
132
145
|
def _required_string(meta: Mapping[str, object], key: str, path: str, raw: str) -> str:
|
|
@@ -149,6 +162,46 @@ def _validate_stages(meta: Mapping[str, object], path: str, raw: str) -> tuple[s
|
|
|
149
162
|
return stages
|
|
150
163
|
|
|
151
164
|
|
|
165
|
+
def _spec_header(meta: Mapping[str, object], raw: str, path: str) -> SpecHeader:
|
|
166
|
+
spec_id = _required_string(meta, "id", path, raw)
|
|
167
|
+
if not _ID_RE.fullmatch(spec_id):
|
|
168
|
+
raise SpecMetadataError(path, "id", _field_line(raw, "id"), "必须是 kebab-case")
|
|
169
|
+
stages = _validate_stages(meta, path, raw)
|
|
170
|
+
enforcement = _required_string(meta, "enforcement", path, raw)
|
|
171
|
+
if enforcement not in ALLOWED_ENFORCEMENT:
|
|
172
|
+
line = _field_line(raw, "enforcement")
|
|
173
|
+
raise SpecMetadataError(path, "enforcement", line, f"必须是 {sorted(ALLOWED_ENFORCEMENT)}")
|
|
174
|
+
return SpecHeader(spec_id, stages, enforcement)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _read_frontmatter(path: str) -> str:
|
|
178
|
+
lines: list[bytes] = []
|
|
179
|
+
try:
|
|
180
|
+
with Path(path).open("rb") as source:
|
|
181
|
+
if not _FRONTMATTER_OPEN_RE.fullmatch(source.readline()):
|
|
182
|
+
raise SpecMetadataError(path, "frontmatter", 1, "缺失或未闭合 YAML frontmatter")
|
|
183
|
+
for line in source:
|
|
184
|
+
if _FRONTMATTER_CLOSE_RE.fullmatch(line):
|
|
185
|
+
break
|
|
186
|
+
lines.append(line)
|
|
187
|
+
else:
|
|
188
|
+
raise SpecMetadataError(path, "frontmatter", 1, "缺失或未闭合 YAML frontmatter")
|
|
189
|
+
except OSError as exc:
|
|
190
|
+
raise SpecMetadataError(path, "file", 1, f"读取失败: {exc}") from exc
|
|
191
|
+
raw = b"".join(lines)
|
|
192
|
+
try:
|
|
193
|
+
return raw.decode("utf-8")
|
|
194
|
+
except UnicodeDecodeError as exc:
|
|
195
|
+
line = raw[:exc.start].count(b"\n") + 2
|
|
196
|
+
raise SpecMetadataError(path, "encoding", line, f"必须是 UTF-8: {exc}") from exc
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def load_spec_header(path: str) -> SpecHeader:
|
|
200
|
+
"""Load only routing fields, stopping at the frontmatter delimiter."""
|
|
201
|
+
raw = _read_frontmatter(path)
|
|
202
|
+
return _spec_header(_parse_frontmatter(raw, path), raw, path)
|
|
203
|
+
|
|
204
|
+
|
|
152
205
|
def _validate_checks(meta: Mapping[str, object], path: str, raw: str) -> tuple[Mapping[str, object], ...]:
|
|
153
206
|
value = meta.get("checks", [])
|
|
154
207
|
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
|
|
@@ -265,15 +318,8 @@ def _validate_links(
|
|
|
265
318
|
|
|
266
319
|
def parse_spec_metadata(content: str, path: str = "<memory>", raw_bytes: Optional[bytes] = None) -> SpecMetadata:
|
|
267
320
|
meta, raw_frontmatter, body = _split_frontmatter(content, path)
|
|
268
|
-
|
|
269
|
-
if not _ID_RE.fullmatch(spec_id):
|
|
270
|
-
raise SpecMetadataError(path, "id", _field_line(raw_frontmatter, "id"), "必须是 kebab-case")
|
|
321
|
+
header = _spec_header(meta, raw_frontmatter, path)
|
|
271
322
|
description = _required_string(meta, "description", path, raw_frontmatter)
|
|
272
|
-
stages = _validate_stages(meta, path, raw_frontmatter)
|
|
273
|
-
enforcement = _required_string(meta, "enforcement", path, raw_frontmatter)
|
|
274
|
-
if enforcement not in ALLOWED_ENFORCEMENT:
|
|
275
|
-
line = _field_line(raw_frontmatter, "enforcement")
|
|
276
|
-
raise SpecMetadataError(path, "enforcement", line, f"必须是 {sorted(ALLOWED_ENFORCEMENT)}")
|
|
277
323
|
owner = meta.get("owner")
|
|
278
324
|
if owner is not None and (not isinstance(owner, str) or not owner.strip()):
|
|
279
325
|
raise SpecMetadataError(path, "owner", _field_line(raw_frontmatter, "owner"), "必须是非空字符串")
|
|
@@ -289,7 +335,10 @@ def parse_spec_metadata(content: str, path: str = "<memory>", raw_bytes: Optiona
|
|
|
289
335
|
metadata_sha256=_sha256(_canonical_json(meta, path, "frontmatter", 1)),
|
|
290
336
|
rules_sha256=_sha256(_canonical_json(required_rules, path, "rules", 1)),
|
|
291
337
|
)
|
|
292
|
-
return SpecMetadata(
|
|
338
|
+
return SpecMetadata(
|
|
339
|
+
header.id, description, header.stages, header.enforcement,
|
|
340
|
+
owner, checks, verifiers, rules, hashes, path,
|
|
341
|
+
)
|
|
293
342
|
|
|
294
343
|
|
|
295
344
|
def load_spec_metadata(path: str) -> SpecMetadata:
|