@michaelxwb/code-flow 0.6.2 → 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/start.md +18 -17
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +8 -9
- 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 +18 -17
- 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/start.md +18 -17
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +8 -9
- 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/start.md +18 -17
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +8 -9
- 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 +85 -36
- 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 +6 -0
- package/src/core/code-flow/scripts/cf_exec_base.py +112 -0
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +8 -4
- package/src/core/code-flow/scripts/cf_scan.py +8 -6
- package/src/core/code-flow/scripts/cf_spec_context.py +178 -54
- package/src/core/code-flow/scripts/cf_spec_resolver.py +7 -2
- package/src/core/code-flow/scripts/cf_spec_router.py +2 -0
- 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 +98 -59
- package/src/core/code-flow/scripts/cf_task_index.py +147 -0
- package/src/core/code-flow/scripts/cf_task_runtime.py +62 -27
- 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
|
@@ -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 ""}
|
|
@@ -13,7 +13,7 @@ import sys
|
|
|
13
13
|
import cf_log
|
|
14
14
|
from cf_core import _log, ensure_utf8_io, load_config, normalize_path, resolve_enforcement, resolve_session_id, timing_log
|
|
15
15
|
from cf_session_state import load_session_state, save_session_state
|
|
16
|
-
from cf_spec_context import load_active_task, load_context
|
|
16
|
+
from cf_spec_context import injection_version, load_active_task, load_context
|
|
17
17
|
from cf_spec_resolver import resolve_candidate_headers
|
|
18
18
|
from cf_spec_router import RouterError, route_prompt
|
|
19
19
|
|
|
@@ -30,6 +30,7 @@ def _active_expansion(root: str, relative: str) -> tuple[str, ...]:
|
|
|
30
30
|
return tuple(
|
|
31
31
|
item.spec_id for item in resolve_candidate_headers(root, "code", (relative,))
|
|
32
32
|
if item.spec_id not in bound and item.enforcement == "required"
|
|
33
|
+
and getattr(item, "scope", "path") != "unmatched"
|
|
33
34
|
)
|
|
34
35
|
|
|
35
36
|
|
|
@@ -70,12 +71,15 @@ def main() -> None:
|
|
|
70
71
|
text = result.text
|
|
71
72
|
if result.mode == "task" and result.context_sha256:
|
|
72
73
|
# The projection is already in context from the prompt route unless
|
|
73
|
-
# the
|
|
74
|
+
# the session/task/contract version changed; skip per-edit
|
|
75
|
+
# re-injection. Same version key as the prompt hook so a TASK
|
|
76
|
+
# switch re-injects here too.
|
|
74
77
|
state = load_session_state(root)
|
|
75
|
-
|
|
78
|
+
version = injection_version(root, sid, result.context_sha256)
|
|
79
|
+
if state.get("injected_version") == version:
|
|
76
80
|
text = ""
|
|
77
81
|
else:
|
|
78
|
-
state["
|
|
82
|
+
state["injected_version"] = version
|
|
79
83
|
save_session_state(root, state)
|
|
80
84
|
if text:
|
|
81
85
|
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,
|
|
@@ -159,6 +159,10 @@ class ActiveBaseline:
|
|
|
159
159
|
head: Optional[str]
|
|
160
160
|
captured_at: str
|
|
161
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
|
|
162
166
|
|
|
163
167
|
|
|
164
168
|
@dataclass(frozen=True)
|
|
@@ -285,6 +289,7 @@ def _active_data(active: ActiveTask) -> dict[str, object]:
|
|
|
285
289
|
"context_sha256": active.context_sha256,
|
|
286
290
|
"baseline": {
|
|
287
291
|
"head": active.baseline.head,
|
|
292
|
+
"last_seen_head": active.baseline.last_seen_head,
|
|
288
293
|
"captured_at": active.baseline.captured_at,
|
|
289
294
|
"preexisting_changes": changes,
|
|
290
295
|
},
|
|
@@ -319,6 +324,7 @@ def _active_from_data(value: object, path: str) -> ActiveTask:
|
|
|
319
324
|
head,
|
|
320
325
|
_string(baseline_data.get("captured_at"), "captured_at", path),
|
|
321
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,
|
|
322
328
|
)
|
|
323
329
|
return ActiveTask(
|
|
324
330
|
1,
|
|
@@ -351,6 +357,58 @@ def load_active_task(root: str) -> ActiveTask:
|
|
|
351
357
|
raise ContextError("invalid_active_marker", "active", str(exc), str(marker)) from exc
|
|
352
358
|
|
|
353
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
|
+
|
|
354
412
|
def resync_active_hash(root: str, task_dir: str, current_sha256: str) -> bool:
|
|
355
413
|
"""Re-sync the active marker's context hash after toolchain-initiated
|
|
356
414
|
context edits (bind/decision/refresh) so they never surface as
|
|
@@ -433,6 +491,42 @@ def _release_active_lock(lock: Path) -> None:
|
|
|
433
491
|
return
|
|
434
492
|
|
|
435
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
|
+
|
|
436
530
|
def _business_changes(root: str, excludes: Sequence[str]) -> Mapping[str, str]:
|
|
437
531
|
return {
|
|
438
532
|
path: status
|
|
@@ -462,7 +556,8 @@ def _new_active(
|
|
|
462
556
|
snapshots = {
|
|
463
557
|
path: PathSnapshot(status, "") for path, status in changes.items()
|
|
464
558
|
}
|
|
465
|
-
|
|
559
|
+
head = _git_head(root)
|
|
560
|
+
baseline = ActiveBaseline(head, _now(), snapshots, head)
|
|
466
561
|
return ActiveTask(
|
|
467
562
|
1, task_dir, task_id, "activating", context_sha256, baseline, owned, DEFAULT_ACTIVE_EXCLUDES
|
|
468
563
|
)
|
|
@@ -498,9 +593,10 @@ def _transition_active(root: str, expected: Sequence[str], target: str) -> Activ
|
|
|
498
593
|
raise ContextError("invalid_active_transition", "status", f"{active.status} -> {target}", root)
|
|
499
594
|
current_head = _git_head(root)
|
|
500
595
|
if active.baseline.head != current_head:
|
|
501
|
-
# Mid-task commits are a normal workflow
|
|
502
|
-
#
|
|
503
|
-
|
|
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))
|
|
504
600
|
updated = replace(active, status=target)
|
|
505
601
|
save_active_task(root, updated)
|
|
506
602
|
return updated
|
|
@@ -522,7 +618,12 @@ def resume_active_task(root: str) -> ActiveTask:
|
|
|
522
618
|
|
|
523
619
|
def current_owned_paths(root: str, active: ActiveTask) -> tuple[str, ...]:
|
|
524
620
|
changes = _business_changes(root, active.excluded_paths)
|
|
525
|
-
|
|
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)))
|
|
526
627
|
|
|
527
628
|
|
|
528
629
|
def complete_active_task(root: str, gate_passed: bool) -> ActiveTask:
|
|
@@ -1337,23 +1438,75 @@ def _bind_command(args: argparse.Namespace, payload: Mapping[str, object]) -> di
|
|
|
1337
1438
|
|
|
1338
1439
|
|
|
1339
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)
|
|
1340
1460
|
if args.active_action == "start":
|
|
1341
1461
|
owned = tuple(
|
|
1342
1462
|
_string(item, "owned_paths[]", "")
|
|
1343
1463
|
for item in _sequence(payload.get("owned_paths", []), "owned_paths", "")
|
|
1344
1464
|
)
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
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
|
|
1349
1476
|
if args.active_action == "pause":
|
|
1350
1477
|
active = pause_active_task(args.root)
|
|
1351
1478
|
elif args.active_action == "resume":
|
|
1352
|
-
|
|
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
|
|
1353
1488
|
elif args.active_action == "block":
|
|
1354
|
-
|
|
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
|
|
1355
1499
|
elif args.active_action == "complete":
|
|
1356
|
-
|
|
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
|
|
1357
1510
|
else:
|
|
1358
1511
|
result = doctor_active_task(
|
|
1359
1512
|
args.root,
|
|
@@ -1366,53 +1519,24 @@ def _active_command(args: argparse.Namespace, payload: Mapping[str, object]) ->
|
|
|
1366
1519
|
|
|
1367
1520
|
|
|
1368
1521
|
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
|
-
|
|
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
|
+
|
|
1375
1530
|
owned = tuple(
|
|
1376
1531
|
_string(item, "owned_paths[]", "")
|
|
1377
1532
|
for item in _sequence(payload.get("owned_paths", []), "owned_paths", "")
|
|
1378
1533
|
)
|
|
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),
|
|
1534
|
+
try:
|
|
1535
|
+
return start_task(
|
|
1536
|
+
args.root, args.task_dir, args.task_file, args.task, owned, args.session_output
|
|
1390
1537
|
)
|
|
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
|
-
}
|
|
1538
|
+
except WorkflowError as exc:
|
|
1539
|
+
raise ContextError(exc.code, "workflow", str(exc), args.task_dir) from exc
|
|
1416
1540
|
|
|
1417
1541
|
|
|
1418
1542
|
def _status_command(args: argparse.Namespace) -> dict[str, object]:
|
|
@@ -18,7 +18,7 @@ from cf_spec_metadata import (
|
|
|
18
18
|
)
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
SCOPE_PRIORITY = {"global": 1, "path": 2, "task": 3}
|
|
21
|
+
SCOPE_PRIORITY = {"unmatched": 0, "global": 1, "path": 2, "task": 3}
|
|
22
22
|
_config_cache: dict[str, tuple[int, int, Mapping[str, object]]] = {}
|
|
23
23
|
_metadata_cache: dict[str, tuple[int, int, SpecMetadata]] = {}
|
|
24
24
|
|
|
@@ -174,7 +174,12 @@ def _candidate_scope(relative: str, patterns: Sequence[str], paths: Sequence[str
|
|
|
174
174
|
if relative.startswith("_session/"):
|
|
175
175
|
return "task", ()
|
|
176
176
|
matched = tuple(path for path in paths if any(fnmatch.fnmatch(path, pattern) for pattern in patterns))
|
|
177
|
-
|
|
177
|
+
if matched:
|
|
178
|
+
return "path", matched
|
|
179
|
+
# A domain without patterns is truly global. A domain WITH patterns that
|
|
180
|
+
# match nothing is irrelevant to these paths ("unmatched") — discovery
|
|
181
|
+
# keeps it, but injection and scope expansion must not consume it.
|
|
182
|
+
return ("global", ()) if not patterns else ("unmatched", ())
|
|
178
183
|
|
|
179
184
|
|
|
180
185
|
def _candidate_for(
|
|
@@ -162,6 +162,8 @@ def _read_candidates(root: str, paths: Sequence[str], compress: bool = True) ->
|
|
|
162
162
|
raise RouterError("invalid_spec_metadata", str(exc)) from exc
|
|
163
163
|
specs = []
|
|
164
164
|
for candidate in candidates:
|
|
165
|
+
if candidate.scope == "unmatched":
|
|
166
|
+
continue
|
|
165
167
|
target = Path(root) / ".code-flow/specs" / candidate.path
|
|
166
168
|
try:
|
|
167
169
|
raw = target.read_text(encoding="utf-8")
|
|
@@ -36,7 +36,8 @@ def _task_section(text: str, task_id: str) -> str:
|
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
def _refs(section: str) -> tuple[str, ...]:
|
|
39
|
-
|
|
39
|
+
# [ \t]* 而非 \s*:空 Spec-Refs 不得跨行吞掉下一字段(Acceptance-Refs 等)
|
|
40
|
+
match = re.search(r"(?m)^- \*\*Spec-Refs\*\*:[ \t]*(.*)$", section)
|
|
40
41
|
if match is None:
|
|
41
42
|
raise ValueError("spec_refs_missing")
|
|
42
43
|
return tuple(item.strip() for item in match.group(1).split(",") if item.strip())
|
|
@@ -83,7 +84,7 @@ def project_task_session(
|
|
|
83
84
|
if len(header + "".join(lines) + candidate + footer) > max_chars:
|
|
84
85
|
break
|
|
85
86
|
lines.append(candidate)
|
|
86
|
-
truncated = len(lines) < len(refs)
|
|
87
|
+
truncated = len(lines) < len(refs) or len(header + "".join(lines) + footer) > max_chars
|
|
87
88
|
if truncated:
|
|
88
89
|
footer = "\n> 投影超过预算;完整 Context 保留在需求目录,请拆分 TASK。\n" + footer
|
|
89
90
|
text = (header + "".join(lines) + footer)[:max_chars]
|