@michaelxwb/code-flow 0.6.2 → 0.6.4
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/README.md +2 -2
- package/package.json +1 -1
- package/src/adapters/claude/commands/cf-init.md +1 -0
- package/src/adapters/claude/commands/cf-spec.md +7 -3
- 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 +2 -2
- package/src/adapters/claude/commands/cf-task/start.md +96 -25
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +8 -9
- package/src/adapters/claude/commands/cf-validate.md +15 -13
- package/src/adapters/codex/skills/cf-init/SKILL.md +1 -0
- package/src/adapters/codex/skills/cf-spec/SKILL.md +6 -2
- 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-plan/SKILL.md +2 -2
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +96 -25
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +106 -0
- package/src/adapters/codex/skills/cf-validate/SKILL.md +15 -13
- package/src/adapters/costrict/commands/cf-init.md +1 -0
- package/src/adapters/costrict/commands/cf-spec.md +7 -3
- 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 +2 -2
- package/src/adapters/costrict/commands/cf-task/start.md +96 -25
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +8 -9
- package/src/adapters/costrict/commands/cf-validate.md +15 -13
- package/src/adapters/opencode/commands/cf-init.md +9 -4
- package/src/adapters/opencode/commands/cf-spec.md +6 -2
- 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 +2 -2
- package/src/adapters/opencode/commands/cf-task/start.md +96 -25
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +8 -9
- package/src/adapters/opencode/commands/cf-validate.md +15 -13
- package/src/adapters/opencode/opencode.json +1 -2
- package/src/adapters/opencode/plugins/code-flow/index.js +176 -94
- package/src/adapters/opencode/plugins/code-flow/package.json +1 -1
- package/src/cli.js +228 -64
- package/src/core/code-flow/config.yml +2 -1
- package/src/core/code-flow/scripts/cf_acceptance_evidence.py +100 -0
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +137 -42
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +123 -38
- package/src/core/code-flow/scripts/cf_acceptance_schema.py +83 -0
- package/src/core/code-flow/scripts/cf_core.py +34 -1
- package/src/core/code-flow/scripts/cf_exec_base.py +112 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +5 -4
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +22 -7
- package/src/core/code-flow/scripts/cf_scan.py +8 -6
- package/src/core/code-flow/scripts/cf_spec_context.py +197 -63
- package/src/core/code-flow/scripts/cf_spec_gate.py +26 -4
- package/src/core/code-flow/scripts/cf_spec_resolver.py +7 -2
- package/src/core/code-flow/scripts/cf_spec_router.py +3 -1
- package/src/core/code-flow/scripts/cf_spec_session.py +3 -2
- package/src/core/code-flow/scripts/cf_spec_verify.py +42 -31
- package/src/core/code-flow/scripts/cf_stats.py +41 -9
- package/src/core/code-flow/scripts/cf_stop_hook.py +128 -61
- package/src/core/code-flow/scripts/cf_task_index.py +148 -0
- package/src/core/code-flow/scripts/cf_task_parallel.py +385 -0
- package/src/core/code-flow/scripts/cf_task_runtime.py +98 -29
- 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 +9 -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
- package/src/core/code-flow/validation.yml +5 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared execution substrate: deadline-aware argv subprocess runner.
|
|
3
|
+
|
|
4
|
+
All check executors (acceptance scenarios, Spec verifiers, session
|
|
5
|
+
validators) funnel through here so one entry deadline constrains the whole
|
|
6
|
+
chain, commands never pass through a shell, and unrun work is reported as
|
|
7
|
+
`incomplete` instead of silently skipped or falsely passed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import shlex
|
|
13
|
+
import json
|
|
14
|
+
import subprocess
|
|
15
|
+
import time
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from contextvars import ContextVar
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Iterator, Mapping, Optional, Sequence
|
|
20
|
+
|
|
21
|
+
_EXECUTIONS: ContextVar[Optional[dict[str, dict[str, object]]]] = ContextVar("cf_executions", default=None)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@contextmanager
|
|
25
|
+
def execution_session() -> Iterator[None]:
|
|
26
|
+
"""Share results only inside one validation operation, never across turns."""
|
|
27
|
+
if _EXECUTIONS.get() is not None:
|
|
28
|
+
yield
|
|
29
|
+
return
|
|
30
|
+
token = _EXECUTIONS.set({})
|
|
31
|
+
try:
|
|
32
|
+
yield
|
|
33
|
+
finally:
|
|
34
|
+
_EXECUTIONS.reset(token)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def invalidate_executions() -> None:
|
|
38
|
+
cache = _EXECUTIONS.get()
|
|
39
|
+
if cache is not None:
|
|
40
|
+
cache.clear()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def execution_key(argv: Sequence[str], cwd: str, timeout: float) -> str:
|
|
44
|
+
"""Identity of a command under the current process environment."""
|
|
45
|
+
return json.dumps([list(argv), str(Path(cwd).resolve()), float(timeout)], ensure_ascii=False)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def remaining_seconds(deadline: Optional[float]) -> Optional[float]:
|
|
49
|
+
"""Seconds left until the absolute monotonic deadline (None = unbounded)."""
|
|
50
|
+
if deadline is None:
|
|
51
|
+
return None
|
|
52
|
+
return deadline - time.monotonic()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_argv(template: str, files: Sequence[str]) -> list[str]:
|
|
56
|
+
"""Expand a validator command template into argv without a shell.
|
|
57
|
+
|
|
58
|
+
`{files}` must be a standalone token and expands to one argv entry per
|
|
59
|
+
file (spaces/metachars safe). Embedded usage is a config error — refusing
|
|
60
|
+
is safer than reintroducing shell splitting.
|
|
61
|
+
"""
|
|
62
|
+
tokens = shlex.split(template or "")
|
|
63
|
+
if not tokens:
|
|
64
|
+
return []
|
|
65
|
+
argv: list[str] = []
|
|
66
|
+
for token in tokens:
|
|
67
|
+
if token == "{files}":
|
|
68
|
+
argv.extend(files)
|
|
69
|
+
elif "{files}" in token:
|
|
70
|
+
raise ValueError(f"validator command must keep {{files}} as a standalone token: {template!r}")
|
|
71
|
+
else:
|
|
72
|
+
argv.append(token)
|
|
73
|
+
return argv
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def run_command(
|
|
77
|
+
argv: Sequence[str],
|
|
78
|
+
cwd: str,
|
|
79
|
+
timeout: float,
|
|
80
|
+
deadline: Optional[float] = None,
|
|
81
|
+
) -> dict[str, object]:
|
|
82
|
+
"""Run argv synchronously under timeout+deadline. Never uses a shell."""
|
|
83
|
+
key = execution_key(argv, cwd, timeout)
|
|
84
|
+
cache = _EXECUTIONS.get()
|
|
85
|
+
if cache is not None and key in cache:
|
|
86
|
+
return dict(cache[key])
|
|
87
|
+
result = _execute(argv, cwd, timeout, deadline)
|
|
88
|
+
if cache is not None and result["status"] == "ok":
|
|
89
|
+
cache[key] = result
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _execute(argv: Sequence[str], cwd: str, timeout: float, deadline: Optional[float]) -> dict[str, object]:
|
|
94
|
+
remaining = remaining_seconds(deadline)
|
|
95
|
+
if remaining is not None and remaining <= 0:
|
|
96
|
+
return {"status": "deadline_exceeded", "returncode": None, "stdout": "", "stderr": ""}
|
|
97
|
+
effective = min(timeout, remaining) if remaining is not None else timeout
|
|
98
|
+
try:
|
|
99
|
+
proc = subprocess.run(
|
|
100
|
+
list(argv), cwd=cwd, capture_output=True, text=True, timeout=max(effective, 0.01)
|
|
101
|
+
)
|
|
102
|
+
except subprocess.TimeoutExpired as exc:
|
|
103
|
+
out = exc.stdout
|
|
104
|
+
return {
|
|
105
|
+
"status": "timeout",
|
|
106
|
+
"returncode": None,
|
|
107
|
+
"stdout": (out.decode() if isinstance(out, bytes) else (out or ""))[-4000:],
|
|
108
|
+
"stderr": "",
|
|
109
|
+
}
|
|
110
|
+
except OSError as exc:
|
|
111
|
+
return {"status": "spawn_error", "returncode": None, "stdout": "", "stderr": str(exc)}
|
|
112
|
+
return {"status": "ok", "returncode": proc.returncode, "stdout": proc.stdout or "", "stderr": proc.stderr or ""}
|
|
@@ -31,6 +31,7 @@ from cf_checks import (
|
|
|
31
31
|
from cf_core import (
|
|
32
32
|
_log,
|
|
33
33
|
build_effective_mapping,
|
|
34
|
+
effective_project_root,
|
|
34
35
|
ensure_utf8_io,
|
|
35
36
|
is_code_file,
|
|
36
37
|
load_config,
|
|
@@ -107,7 +108,10 @@ def main() -> None:
|
|
|
107
108
|
if not isinstance(file_path, str) or not file_path:
|
|
108
109
|
return
|
|
109
110
|
|
|
110
|
-
|
|
111
|
+
session_root = os.getcwd()
|
|
112
|
+
abs_path = file_path if os.path.isabs(file_path) else os.path.join(session_root, file_path)
|
|
113
|
+
# 并行子 agent 在 .code-flow/worktrees/... 内编辑时路由到该 worktree 的规格与状态
|
|
114
|
+
project_root = effective_project_root(session_root, abs_path)
|
|
111
115
|
config = load_config(project_root)
|
|
112
116
|
if not config:
|
|
113
117
|
return
|
|
@@ -117,9 +121,6 @@ def main() -> None:
|
|
|
117
121
|
if not quality_loop["post_check"]:
|
|
118
122
|
return
|
|
119
123
|
|
|
120
|
-
abs_path = file_path
|
|
121
|
-
if not os.path.isabs(abs_path):
|
|
122
|
-
abs_path = os.path.join(project_root, file_path)
|
|
123
124
|
rel_path = normalize_path(os.path.relpath(abs_path, project_root))
|
|
124
125
|
sid = resolve_session_id(data)
|
|
125
126
|
cf_log.append_event(project_root, "edit", {"file": rel_path, "tool": tool_name}, sid)
|
|
@@ -11,9 +11,18 @@ from pathlib import Path
|
|
|
11
11
|
import sys
|
|
12
12
|
|
|
13
13
|
import cf_log
|
|
14
|
-
from cf_core import
|
|
14
|
+
from cf_core import (
|
|
15
|
+
_log,
|
|
16
|
+
effective_project_root,
|
|
17
|
+
ensure_utf8_io,
|
|
18
|
+
load_config,
|
|
19
|
+
normalize_path,
|
|
20
|
+
resolve_enforcement,
|
|
21
|
+
resolve_session_id,
|
|
22
|
+
timing_log,
|
|
23
|
+
)
|
|
15
24
|
from cf_session_state import load_session_state, save_session_state
|
|
16
|
-
from cf_spec_context import load_active_task, load_context
|
|
25
|
+
from cf_spec_context import injection_version, load_active_task, load_context
|
|
17
26
|
from cf_spec_resolver import resolve_candidate_headers
|
|
18
27
|
from cf_spec_router import RouterError, route_prompt
|
|
19
28
|
|
|
@@ -30,6 +39,7 @@ def _active_expansion(root: str, relative: str) -> tuple[str, ...]:
|
|
|
30
39
|
return tuple(
|
|
31
40
|
item.spec_id for item in resolve_candidate_headers(root, "code", (relative,))
|
|
32
41
|
if item.spec_id not in bound and item.enforcement == "required"
|
|
42
|
+
and getattr(item, "scope", "path") != "unmatched"
|
|
33
43
|
)
|
|
34
44
|
|
|
35
45
|
|
|
@@ -48,11 +58,13 @@ def main() -> None:
|
|
|
48
58
|
file_path = (data.get("tool_input") or {}).get("file_path", "")
|
|
49
59
|
if tool_name not in {"Edit", "Write", "MultiEdit"} or not isinstance(file_path, str) or not file_path:
|
|
50
60
|
return
|
|
51
|
-
|
|
61
|
+
session_root = os.getcwd()
|
|
62
|
+
absolute = file_path if os.path.isabs(file_path) else os.path.join(session_root, file_path)
|
|
63
|
+
# 并行子 agent 在 .code-flow/worktrees/... 内编辑时路由到该 worktree 的上下文
|
|
64
|
+
root = effective_project_root(session_root, absolute)
|
|
52
65
|
sid = resolve_session_id(data)
|
|
53
66
|
config = load_config(root)
|
|
54
67
|
enforcement = resolve_enforcement(config) if config else "required"
|
|
55
|
-
absolute = file_path if os.path.isabs(file_path) else os.path.join(root, file_path)
|
|
56
68
|
relative = normalize_path(os.path.relpath(absolute, root))
|
|
57
69
|
try:
|
|
58
70
|
expanded = _active_expansion(root, relative)
|
|
@@ -70,12 +82,15 @@ def main() -> None:
|
|
|
70
82
|
text = result.text
|
|
71
83
|
if result.mode == "task" and result.context_sha256:
|
|
72
84
|
# The projection is already in context from the prompt route unless
|
|
73
|
-
# the
|
|
85
|
+
# the session/task/contract version changed; skip per-edit
|
|
86
|
+
# re-injection. Same version key as the prompt hook so a TASK
|
|
87
|
+
# switch re-injects here too.
|
|
74
88
|
state = load_session_state(root)
|
|
75
|
-
|
|
89
|
+
version = injection_version(root, sid, result.context_sha256)
|
|
90
|
+
if state.get("injected_version") == version:
|
|
76
91
|
text = ""
|
|
77
92
|
else:
|
|
78
|
-
state["
|
|
93
|
+
state["injected_version"] = version
|
|
79
94
|
save_session_state(root, state)
|
|
80
95
|
if text:
|
|
81
96
|
sys.stdout.write(json.dumps(_inject(text), ensure_ascii=False))
|
|
@@ -11,6 +11,7 @@ from cf_core import (
|
|
|
11
11
|
build_spec_catalog,
|
|
12
12
|
estimate_tokens,
|
|
13
13
|
load_config,
|
|
14
|
+
project_instruction_file,
|
|
14
15
|
non_injectable_specs,
|
|
15
16
|
parse_spec_frontmatter,
|
|
16
17
|
)
|
|
@@ -120,19 +121,20 @@ def find_missing_paths(text: str, project_root: str, base_dir: str = "") -> list
|
|
|
120
121
|
return sorted(missing)
|
|
121
122
|
|
|
122
123
|
|
|
123
|
-
def build_report(project_root: str) -> dict:
|
|
124
|
+
def build_report(project_root: str, platform: str = "") -> dict:
|
|
124
125
|
"""规范质量审计数据(cf-scan 引擎,亦供 cf-stats --audit 复用)。
|
|
125
126
|
|
|
126
127
|
files 含 issues / template 标记,review 为待复审清单;展示层负责过滤与渲染。"""
|
|
127
128
|
files = []
|
|
128
129
|
total_tokens = 0
|
|
129
130
|
|
|
130
|
-
|
|
131
|
+
instruction_file = project_instruction_file(project_root, platform)
|
|
132
|
+
claude_path = os.path.join(project_root, instruction_file)
|
|
131
133
|
if os.path.exists(claude_path):
|
|
132
134
|
content = read_text(claude_path)
|
|
133
135
|
tokens = estimate_tokens(content)
|
|
134
136
|
total_tokens += tokens
|
|
135
|
-
files.append({"path":
|
|
137
|
+
files.append({"path": instruction_file, "tokens": tokens, "issues": []})
|
|
136
138
|
|
|
137
139
|
specs_root = os.path.join(project_root, ".code-flow", "specs")
|
|
138
140
|
specs = []
|
|
@@ -142,14 +144,14 @@ def build_report(project_root: str) -> dict:
|
|
|
142
144
|
if not name.endswith(".md"):
|
|
143
145
|
continue
|
|
144
146
|
full_path = os.path.join(root, name)
|
|
147
|
+
rel = os.path.relpath(full_path, specs_root)
|
|
148
|
+
if rel.replace(os.sep, "/").startswith("_session/"):
|
|
149
|
+
continue
|
|
145
150
|
content = read_text(full_path)
|
|
146
151
|
if not content:
|
|
147
152
|
continue
|
|
148
153
|
tokens = estimate_tokens(content)
|
|
149
154
|
total_tokens += tokens
|
|
150
|
-
rel = os.path.relpath(full_path, specs_root)
|
|
151
|
-
if rel.replace(os.sep, "/").startswith("_session/"):
|
|
152
|
-
continue # 会话级临时约束(FEAT-08)不参与审计与预算
|
|
153
155
|
rel_path = os.path.join("specs", rel).replace(os.sep, "/")
|
|
154
156
|
spec_entry = {
|
|
155
157
|
"path": rel_path,
|
|
@@ -42,6 +42,7 @@ DEFAULT_ACTIVE_EXCLUDES = (
|
|
|
42
42
|
".code-flow/.session-log.jsonl",
|
|
43
43
|
".code-flow/.active-task.json",
|
|
44
44
|
".code-flow/.active-task.lock",
|
|
45
|
+
".code-flow/worktrees/*",
|
|
45
46
|
)
|
|
46
47
|
|
|
47
48
|
|
|
@@ -159,6 +160,10 @@ class ActiveBaseline:
|
|
|
159
160
|
head: Optional[str]
|
|
160
161
|
captured_at: str
|
|
161
162
|
preexisting_changes: Mapping[str, PathSnapshot]
|
|
163
|
+
# head is frozen at start and never rewritten: task scope is the union of
|
|
164
|
+
# everything committed since head plus all working-tree changes.
|
|
165
|
+
# last_seen_head is observation-only (diagnostics), never scope input.
|
|
166
|
+
last_seen_head: Optional[str] = None
|
|
162
167
|
|
|
163
168
|
|
|
164
169
|
@dataclass(frozen=True)
|
|
@@ -285,6 +290,7 @@ def _active_data(active: ActiveTask) -> dict[str, object]:
|
|
|
285
290
|
"context_sha256": active.context_sha256,
|
|
286
291
|
"baseline": {
|
|
287
292
|
"head": active.baseline.head,
|
|
293
|
+
"last_seen_head": active.baseline.last_seen_head,
|
|
288
294
|
"captured_at": active.baseline.captured_at,
|
|
289
295
|
"preexisting_changes": changes,
|
|
290
296
|
},
|
|
@@ -319,6 +325,7 @@ def _active_from_data(value: object, path: str) -> ActiveTask:
|
|
|
319
325
|
head,
|
|
320
326
|
_string(baseline_data.get("captured_at"), "captured_at", path),
|
|
321
327
|
{key: _snapshot_from_data(item, path) for key, item in raw_changes.items()},
|
|
328
|
+
baseline_data.get("last_seen_head") if isinstance(baseline_data.get("last_seen_head"), str) else None,
|
|
322
329
|
)
|
|
323
330
|
return ActiveTask(
|
|
324
331
|
1,
|
|
@@ -351,6 +358,58 @@ def load_active_task(root: str) -> ActiveTask:
|
|
|
351
358
|
raise ContextError("invalid_active_marker", "active", str(exc), str(marker)) from exc
|
|
352
359
|
|
|
353
360
|
|
|
361
|
+
def task_contract_digest(root: str, task_dir: str, task_id: str) -> str:
|
|
362
|
+
"""Digest of the active TASK's acceptance contract (Refs + Contract + Evidence).
|
|
363
|
+
|
|
364
|
+
Two TASKs may share one Context hash while owning different scenarios; the
|
|
365
|
+
digest separates their injection versions so switching TASKs re-injects.
|
|
366
|
+
Returns "" when the TASK section cannot be located (fail-open to the
|
|
367
|
+
legacy context-hash-only key rather than crashing the hook hot path).
|
|
368
|
+
"""
|
|
369
|
+
try:
|
|
370
|
+
files = sorted((Path(root) / task_dir).glob("*.md"))
|
|
371
|
+
except OSError:
|
|
372
|
+
return ""
|
|
373
|
+
for path in files:
|
|
374
|
+
if path.name.endswith((".prd.md", ".design.md")):
|
|
375
|
+
continue
|
|
376
|
+
try:
|
|
377
|
+
text = path.read_text(encoding="utf-8")
|
|
378
|
+
except (OSError, UnicodeError):
|
|
379
|
+
continue
|
|
380
|
+
start = text.find(f"## {task_id}:")
|
|
381
|
+
if start == -1:
|
|
382
|
+
continue
|
|
383
|
+
end = text.find("\n## TASK-", start)
|
|
384
|
+
section = text[start:] if end == -1 else text[start:end]
|
|
385
|
+
chunks: list[str] = []
|
|
386
|
+
for heading in ("Acceptance-Refs", "Acceptance Contract", "Acceptance Evidence"):
|
|
387
|
+
index = section.find(heading)
|
|
388
|
+
if index == -1:
|
|
389
|
+
chunks.append(f"{heading}:missing")
|
|
390
|
+
continue
|
|
391
|
+
end = section.find("\n### ", index)
|
|
392
|
+
chunks.append(section[index:] if end == -1 else section[index:end])
|
|
393
|
+
return hashlib.sha256("\n".join(chunks).encode("utf-8")).hexdigest()
|
|
394
|
+
return ""
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def injection_version(root: str, session_id: str, context_sha: str) -> str:
|
|
398
|
+
"""Version key for hook injection dedup.
|
|
399
|
+
|
|
400
|
+
Covers session + task_dir + task_id + context hash + TASK contract digest,
|
|
401
|
+
so a TASK switch (or an in-place contract edit) always re-injects while a
|
|
402
|
+
repeated prompt on the same contract stays suppressed.
|
|
403
|
+
"""
|
|
404
|
+
try:
|
|
405
|
+
active = load_active_task(root)
|
|
406
|
+
except (ContextError, OSError, ValueError):
|
|
407
|
+
return f"none:{session_id}:{context_sha}"
|
|
408
|
+
digest = task_contract_digest(root, active.task_dir, active.task_id)
|
|
409
|
+
raw = "\n".join((session_id, active.task_dir, active.task_id, context_sha, digest))
|
|
410
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
411
|
+
|
|
412
|
+
|
|
354
413
|
def resync_active_hash(root: str, task_dir: str, current_sha256: str) -> bool:
|
|
355
414
|
"""Re-sync the active marker's context hash after toolchain-initiated
|
|
356
415
|
context edits (bind/decision/refresh) so they never surface as
|
|
@@ -433,6 +492,42 @@ def _release_active_lock(lock: Path) -> None:
|
|
|
433
492
|
return
|
|
434
493
|
|
|
435
494
|
|
|
495
|
+
def _committed_since(root: str, base_head: Optional[str]) -> Mapping[str, str]:
|
|
496
|
+
"""Paths committed between the frozen baseline head and current HEAD.
|
|
497
|
+
|
|
498
|
+
Returns {} when there is no baseline (pre-commit repo) or the range is
|
|
499
|
+
unresolvable (rewritten/pruned history) — working-tree changes still
|
|
500
|
+
flow through _business_changes, so scope never goes fully blind.
|
|
501
|
+
"""
|
|
502
|
+
if not base_head:
|
|
503
|
+
return {}
|
|
504
|
+
try:
|
|
505
|
+
output = _run_git(root, ("diff", "--name-status", "-z", base_head, "HEAD", "--"))
|
|
506
|
+
except ContextError:
|
|
507
|
+
return {}
|
|
508
|
+
names: dict[str, str] = {}
|
|
509
|
+
tokens = output.split("\0")
|
|
510
|
+
index = 0
|
|
511
|
+
while index < len(tokens) and tokens[index]:
|
|
512
|
+
status, index = tokens[index], index + 1
|
|
513
|
+
code = status[:1]
|
|
514
|
+
if code in ("R", "C"):
|
|
515
|
+
if index + 1 >= len(tokens):
|
|
516
|
+
break
|
|
517
|
+
new_path, old_path = tokens[index], tokens[index + 1]
|
|
518
|
+
index += 2
|
|
519
|
+
if new_path:
|
|
520
|
+
names[new_path] = "renamed"
|
|
521
|
+
if old_path:
|
|
522
|
+
names[old_path] = "deleted"
|
|
523
|
+
continue
|
|
524
|
+
if index >= len(tokens) or not tokens[index]:
|
|
525
|
+
break
|
|
526
|
+
names[tokens[index]] = {"M": "modified", "A": "added", "D": "deleted"}.get(code, "changed")
|
|
527
|
+
index += 1
|
|
528
|
+
return names
|
|
529
|
+
|
|
530
|
+
|
|
436
531
|
def _business_changes(root: str, excludes: Sequence[str]) -> Mapping[str, str]:
|
|
437
532
|
return {
|
|
438
533
|
path: status
|
|
@@ -462,7 +557,8 @@ def _new_active(
|
|
|
462
557
|
snapshots = {
|
|
463
558
|
path: PathSnapshot(status, "") for path, status in changes.items()
|
|
464
559
|
}
|
|
465
|
-
|
|
560
|
+
head = _git_head(root)
|
|
561
|
+
baseline = ActiveBaseline(head, _now(), snapshots, head)
|
|
466
562
|
return ActiveTask(
|
|
467
563
|
1, task_dir, task_id, "activating", context_sha256, baseline, owned, DEFAULT_ACTIVE_EXCLUDES
|
|
468
564
|
)
|
|
@@ -498,9 +594,10 @@ def _transition_active(root: str, expected: Sequence[str], target: str) -> Activ
|
|
|
498
594
|
raise ContextError("invalid_active_transition", "status", f"{active.status} -> {target}", root)
|
|
499
595
|
current_head = _git_head(root)
|
|
500
596
|
if active.baseline.head != current_head:
|
|
501
|
-
# Mid-task commits are a normal workflow
|
|
502
|
-
#
|
|
503
|
-
|
|
597
|
+
# Mid-task commits are a normal workflow. The baseline head stays
|
|
598
|
+
# frozen so committed work never drops out of scope; only the
|
|
599
|
+
# observation field advances for diagnostics.
|
|
600
|
+
active = replace(active, baseline=replace(active.baseline, last_seen_head=current_head))
|
|
504
601
|
updated = replace(active, status=target)
|
|
505
602
|
save_active_task(root, updated)
|
|
506
603
|
return updated
|
|
@@ -522,7 +619,12 @@ def resume_active_task(root: str) -> ActiveTask:
|
|
|
522
619
|
|
|
523
620
|
def current_owned_paths(root: str, active: ActiveTask) -> tuple[str, ...]:
|
|
524
621
|
changes = _business_changes(root, active.excluded_paths)
|
|
525
|
-
|
|
622
|
+
committed = {
|
|
623
|
+
path: status
|
|
624
|
+
for path, status in _committed_since(root, active.baseline.head).items()
|
|
625
|
+
if not _is_excluded(path, active.excluded_paths)
|
|
626
|
+
}
|
|
627
|
+
return tuple(sorted(set(active.owned_paths).union(changes).union(committed)))
|
|
526
628
|
|
|
527
629
|
|
|
528
630
|
def complete_active_task(root: str, gate_passed: bool) -> ActiveTask:
|
|
@@ -1213,8 +1315,15 @@ def apply_artifact_ref(
|
|
|
1213
1315
|
|
|
1214
1316
|
def _json_payload(stream: IO[str]) -> Mapping[str, object]:
|
|
1215
1317
|
try:
|
|
1216
|
-
|
|
1217
|
-
except (
|
|
1318
|
+
raw = stream.read()
|
|
1319
|
+
except (OSError, UnicodeError) as exc:
|
|
1320
|
+
raise ContextError("invalid_json", "stdin", str(exc)) from exc
|
|
1321
|
+
if not raw.strip():
|
|
1322
|
+
# 空 stdin = 无 payload(例如 active doctor --resync 全参数走 CLI 开关)。
|
|
1323
|
+
return {}
|
|
1324
|
+
try:
|
|
1325
|
+
loaded = json.loads(raw)
|
|
1326
|
+
except json.JSONDecodeError as exc:
|
|
1218
1327
|
raise ContextError("invalid_json", "stdin", str(exc)) from exc
|
|
1219
1328
|
return _mapping(loaded, "stdin", "")
|
|
1220
1329
|
|
|
@@ -1337,82 +1446,104 @@ def _bind_command(args: argparse.Namespace, payload: Mapping[str, object]) -> di
|
|
|
1337
1446
|
|
|
1338
1447
|
|
|
1339
1448
|
def _active_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
|
|
1449
|
+
from cf_workflow_service import WorkflowError, locate_task_file
|
|
1450
|
+
from cf_workflow_transaction import recover_transition
|
|
1451
|
+
|
|
1452
|
+
recover_transition(args.root)
|
|
1453
|
+
if args.active_action in ("pause", "resume", "block", "complete"):
|
|
1454
|
+
marker, _ = _active_paths(args.root)
|
|
1455
|
+
if marker.exists():
|
|
1456
|
+
current = load_active_task(args.root)
|
|
1457
|
+
if (current.task_id != args.task or
|
|
1458
|
+
(Path(args.root) / current.task_dir).resolve() != (Path(args.root) / args.task_dir).resolve()):
|
|
1459
|
+
raise ContextError("active_mismatch", "active", "requested TASK does not match marker", str(marker))
|
|
1460
|
+
|
|
1461
|
+
def _directory() -> str:
|
|
1462
|
+
return args.task_dir if Path(args.task_dir).is_absolute() else str(Path(args.root) / args.task_dir)
|
|
1463
|
+
|
|
1464
|
+
def _reraise(exc: WorkflowError) -> ContextError:
|
|
1465
|
+
return ContextError(exc.code, "workflow", str(exc), args.task_dir)
|
|
1466
|
+
|
|
1467
|
+
task_file = locate_task_file(_directory(), args.task)
|
|
1340
1468
|
if args.active_action == "start":
|
|
1341
1469
|
owned = tuple(
|
|
1342
1470
|
_string(item, "owned_paths[]", "")
|
|
1343
1471
|
for item in _sequence(payload.get("owned_paths", []), "owned_paths", "")
|
|
1344
1472
|
)
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1473
|
+
if task_file is None:
|
|
1474
|
+
active = start_active_task(
|
|
1475
|
+
args.root, args.task_dir, args.task, args.context_sha256, owned
|
|
1476
|
+
)
|
|
1477
|
+
return {"ok": True, "active": _active_data(active)}
|
|
1478
|
+
from cf_workflow_service import start_task
|
|
1479
|
+
|
|
1480
|
+
try:
|
|
1481
|
+
return start_task(args.root, args.task_dir, str(task_file), args.task, owned)
|
|
1482
|
+
except WorkflowError as exc:
|
|
1483
|
+
raise _reraise(exc) from exc
|
|
1349
1484
|
if args.active_action == "pause":
|
|
1350
1485
|
active = pause_active_task(args.root)
|
|
1351
1486
|
elif args.active_action == "resume":
|
|
1352
|
-
|
|
1487
|
+
if task_file is None:
|
|
1488
|
+
active = resume_active_task(args.root)
|
|
1489
|
+
else:
|
|
1490
|
+
from cf_workflow_service import resume_task
|
|
1491
|
+
|
|
1492
|
+
try:
|
|
1493
|
+
return resume_task(args.root, _directory(), str(task_file), args.task)
|
|
1494
|
+
except WorkflowError as exc:
|
|
1495
|
+
raise _reraise(exc) from exc
|
|
1353
1496
|
elif args.active_action == "block":
|
|
1354
|
-
|
|
1497
|
+
if task_file is None:
|
|
1498
|
+
active = block_active_task(args.root)
|
|
1499
|
+
else:
|
|
1500
|
+
from cf_workflow_service import block_task
|
|
1501
|
+
|
|
1502
|
+
reason = payload.get("reason", "")
|
|
1503
|
+
try:
|
|
1504
|
+
return block_task(args.root, _directory(), str(task_file), args.task, reason if isinstance(reason, str) else "")
|
|
1505
|
+
except WorkflowError as exc:
|
|
1506
|
+
raise _reraise(exc) from exc
|
|
1355
1507
|
elif args.active_action == "complete":
|
|
1356
|
-
|
|
1508
|
+
gate = payload.get("gate_passed") is True
|
|
1509
|
+
if task_file is None:
|
|
1510
|
+
active = complete_active_task(args.root, gate)
|
|
1511
|
+
else:
|
|
1512
|
+
from cf_workflow_service import complete_task
|
|
1513
|
+
|
|
1514
|
+
try:
|
|
1515
|
+
return complete_task(args.root, _directory(), str(task_file), args.task, gate)
|
|
1516
|
+
except WorkflowError as exc:
|
|
1517
|
+
raise _reraise(exc) from exc
|
|
1357
1518
|
else:
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
)
|
|
1519
|
+
abandon = payload.get("abandon") is True or args.abandon
|
|
1520
|
+
resync = payload.get("resync") is True or args.resync
|
|
1521
|
+
if abandon and resync:
|
|
1522
|
+
raise ContextError("conflicting_recovery", "doctor", "abandon 与 resync 不能同时使用", args.task_dir)
|
|
1523
|
+
result = doctor_active_task(args.root, args.context_sha256, abandon, resync)
|
|
1364
1524
|
return {"ok": True, "action": result.action, "active": _active_data(result.active)}
|
|
1365
1525
|
return {"ok": True, "active": _active_data(active)}
|
|
1366
1526
|
|
|
1367
1527
|
|
|
1368
1528
|
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
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1529
|
+
"""Run refresh, activation and TASK projection in one guarded process.
|
|
1530
|
+
|
|
1531
|
+
Delegates to the unified workflow service: hard preconditions (status,
|
|
1532
|
+
NOTES, depends, marker) are enforced before any state changes, and the
|
|
1533
|
+
Markdown view flips to in-progress in the same call.
|
|
1534
|
+
"""
|
|
1535
|
+
from cf_workflow_service import WorkflowError, start_task
|
|
1536
|
+
|
|
1375
1537
|
owned = tuple(
|
|
1376
1538
|
_string(item, "owned_paths[]", "")
|
|
1377
1539
|
for item in _sequence(payload.get("owned_paths", []), "owned_paths", "")
|
|
1378
1540
|
)
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
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),
|
|
1541
|
+
try:
|
|
1542
|
+
return start_task(
|
|
1543
|
+
args.root, args.task_dir, args.task_file, args.task, owned, args.session_output
|
|
1390
1544
|
)
|
|
1391
|
-
|
|
1392
|
-
|
|
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
|
-
}
|
|
1545
|
+
except WorkflowError as exc:
|
|
1546
|
+
raise ContextError(exc.code, "workflow", str(exc), args.task_dir) from exc
|
|
1416
1547
|
|
|
1417
1548
|
|
|
1418
1549
|
def _status_command(args: argparse.Namespace) -> dict[str, object]:
|
|
@@ -1465,7 +1596,7 @@ def _status_text(data: dict[str, object]) -> str:
|
|
|
1465
1596
|
match = "✓ 一致" if marker["hash_match"] else "✗ 漂移"
|
|
1466
1597
|
lines.append(f"- TASK {marker['task_id']}({marker['status']}),marker hash {match}")
|
|
1467
1598
|
if not marker["hash_match"]:
|
|
1468
|
-
lines.append(" 下一步: 运行 cf-spec doctor(
|
|
1599
|
+
lines.append(" 下一步: 运行 cf-spec refresh 自动重同步 marker hash;仍不一致时用 active doctor --resync(hash 取本命令 --json 的 context_sha256)")
|
|
1469
1600
|
else:
|
|
1470
1601
|
lines.append("- 无 active TASK(path/catalog 路由模式)")
|
|
1471
1602
|
gate = data["gate"]
|
|
@@ -1519,6 +1650,9 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
1519
1650
|
command.add_argument("--task", required=True)
|
|
1520
1651
|
command.add_argument("--context-sha256", required=True)
|
|
1521
1652
|
command.add_argument("--json", action="store_true")
|
|
1653
|
+
if action == "doctor":
|
|
1654
|
+
command.add_argument("--abandon", action="store_true", help="确认放弃无法证明的 marker(需用户确认)")
|
|
1655
|
+
command.add_argument("--resync", action="store_true", help="用 --context-sha256 重新绑定 marker 并恢复 active")
|
|
1522
1656
|
start = commands.add_parser("start")
|
|
1523
1657
|
start.add_argument("--task-dir", required=True)
|
|
1524
1658
|
start.add_argument("--root", required=True)
|