@michaelxwb/code-flow 0.6.3 → 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/graph.md +1 -1
- package/src/adapters/claude/commands/cf-task/plan.md +2 -2
- package/src/adapters/claude/commands/cf-task/start.md +80 -10
- package/src/adapters/claude/commands/cf-validate.md +3 -0
- 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-graph/SKILL.md +1 -1
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +2 -2
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +80 -10
- package/src/adapters/codex/skills/cf-validate/SKILL.md +3 -0
- 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/graph.md +1 -1
- package/src/adapters/costrict/commands/cf-task/plan.md +2 -2
- package/src/adapters/costrict/commands/cf-task/start.md +80 -10
- package/src/adapters/costrict/commands/cf-validate.md +3 -0
- 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/graph.md +1 -1
- package/src/adapters/opencode/commands/cf-task/plan.md +2 -2
- package/src/adapters/opencode/commands/cf-task/start.md +80 -10
- package/src/adapters/opencode/commands/cf-validate.md +3 -0
- package/src/adapters/opencode/opencode.json +1 -2
- package/src/adapters/opencode/plugins/code-flow/index.js +138 -78
- package/src/adapters/opencode/plugins/code-flow/package.json +1 -1
- package/src/cli.js +51 -1
- package/src/core/code-flow/config.yml +2 -1
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +54 -8
- package/src/core/code-flow/scripts/cf_core.py +28 -1
- package/src/core/code-flow/scripts/cf_post_hook.py +5 -4
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +14 -3
- package/src/core/code-flow/scripts/cf_spec_context.py +19 -9
- package/src/core/code-flow/scripts/cf_spec_gate.py +26 -4
- package/src/core/code-flow/scripts/cf_spec_router.py +1 -1
- package/src/core/code-flow/scripts/cf_stop_hook.py +31 -3
- package/src/core/code-flow/scripts/cf_task_index.py +5 -4
- package/src/core/code-flow/scripts/cf_task_parallel.py +385 -0
- package/src/core/code-flow/scripts/cf_task_runtime.py +38 -4
- package/src/core/code-flow/validation.yml +5 -0
package/src/cli.js
CHANGED
|
@@ -237,6 +237,56 @@ function mergeSettingsJson(srcFile, destFile) {
|
|
|
237
237
|
return added;
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
+
// OpenCode v2 merge: migrate the v1 `plugin` key to `plugins`, drop the
|
|
241
|
+
// auto-discovered local path (`.opencode/plugins/*` loads without config),
|
|
242
|
+
// and union template `plugins` entries without overwriting user content.
|
|
243
|
+
function mergeOpencodeJson(srcFile, destFile) {
|
|
244
|
+
const src = JSON.parse(fs.readFileSync(srcFile, 'utf8'));
|
|
245
|
+
const dest = JSON.parse(fs.readFileSync(destFile, 'utf8'));
|
|
246
|
+
const added = [];
|
|
247
|
+
const isLocalEntry = (e) => {
|
|
248
|
+
const p = typeof e === 'string' ? e : (e && e.package);
|
|
249
|
+
return typeof p === 'string' && /(^|\/)\.opencode\/plugins\//.test(p);
|
|
250
|
+
};
|
|
251
|
+
if (Array.isArray(dest.plugin)) {
|
|
252
|
+
const carried = dest.plugin.filter((e) => !isLocalEntry(e));
|
|
253
|
+
delete dest.plugin;
|
|
254
|
+
added.push(`removed legacy 'plugin' key (v1 → v2)`);
|
|
255
|
+
if (carried.length > 0 && !Array.isArray(dest.plugins)) dest.plugins = carried;
|
|
256
|
+
} else if ('plugin' in dest) {
|
|
257
|
+
delete dest.plugin;
|
|
258
|
+
added.push(`removed legacy 'plugin' key (v1 → v2)`);
|
|
259
|
+
}
|
|
260
|
+
if (Array.isArray(dest.plugins)) {
|
|
261
|
+
const before = dest.plugins.length;
|
|
262
|
+
dest.plugins = dest.plugins.filter((e) => !isLocalEntry(e));
|
|
263
|
+
if (dest.plugins.length !== before) added.push('removed auto-discovered local plugin entry');
|
|
264
|
+
}
|
|
265
|
+
if (Array.isArray(src.plugins)) {
|
|
266
|
+
if (!Array.isArray(dest.plugins)) dest.plugins = [];
|
|
267
|
+
const seen = new Set(dest.plugins.map((e) => JSON.stringify(e)));
|
|
268
|
+
for (const entry of src.plugins) {
|
|
269
|
+
const key = JSON.stringify(entry);
|
|
270
|
+
if (!seen.has(key)) {
|
|
271
|
+
dest.plugins.push(entry);
|
|
272
|
+
seen.add(key);
|
|
273
|
+
added.push(`plugins +${typeof entry === 'string' ? entry : key}`.slice(0, 80));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
for (const key of Object.keys(src)) {
|
|
278
|
+
if (key === 'plugins' || key === 'plugin') continue;
|
|
279
|
+
if (!(key in dest)) {
|
|
280
|
+
dest[key] = src[key];
|
|
281
|
+
added.push(key);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (added.length > 0) {
|
|
285
|
+
fs.writeFileSync(destFile, JSON.stringify(dest, null, 2) + '\n');
|
|
286
|
+
}
|
|
287
|
+
return added;
|
|
288
|
+
}
|
|
289
|
+
|
|
240
290
|
function mergeConfigYml(srcFile, destFile) {
|
|
241
291
|
const srcText = fs.readFileSync(srcFile, 'utf8');
|
|
242
292
|
const destText = fs.readFileSync(destFile, 'utf8');
|
|
@@ -781,7 +831,7 @@ function runInit(force, platform, opts) {
|
|
|
781
831
|
dest: path.join(cwd, 'opencode.json'),
|
|
782
832
|
label: 'opencode.json',
|
|
783
833
|
mode: platformMode,
|
|
784
|
-
mergeFn:
|
|
834
|
+
mergeFn: mergeOpencodeJson,
|
|
785
835
|
toolOnUpgrade: false,
|
|
786
836
|
results,
|
|
787
837
|
});
|
|
@@ -24,7 +24,8 @@ budget:
|
|
|
24
24
|
quality_loop:
|
|
25
25
|
enabled: true
|
|
26
26
|
post_check: true # PostToolUse 违规反馈(只提示不阻断)
|
|
27
|
-
stop_check: true # Stop 收尾按 validation.yml
|
|
27
|
+
stop_check: true # Stop 收尾按 validation.yml 校验(heavy 项跳过)
|
|
28
|
+
finish_check: true # 任务 finish 时全量执行 validation.yml(含 heavy,每任务一次)
|
|
28
29
|
correction_capture: true # 纠正句式采集(喂 cf-learn 候选)
|
|
29
30
|
# 注入时对 spec 内容做保守无损压缩(去行尾空白、折叠多空行、剥 HTML 注释、去重 bullet)
|
|
30
31
|
# 缺省视为 true;仅显式 false 关闭
|
|
@@ -10,7 +10,7 @@ import shlex
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
import re
|
|
12
12
|
import sys
|
|
13
|
-
from typing import IO, Mapping, Optional, Sequence
|
|
13
|
+
from typing import IO, Callable, Mapping, Optional, Sequence
|
|
14
14
|
|
|
15
15
|
from cf_acceptance_schema import load_manifest, validate_scenarios
|
|
16
16
|
|
|
@@ -26,12 +26,18 @@ def _kind(level: str) -> str:
|
|
|
26
26
|
- functional: auto-executed in Done Gate (unit, integration)
|
|
27
27
|
- e2e: deferred until explicit --verify-e2e (needs external dependencies)
|
|
28
28
|
- manual: user confirmation required, never auto-executed
|
|
29
|
+
|
|
30
|
+
层级单元格允许带注解(如 `E2E(Playwright)`、`e2e/chrome`、`manual(chrome)`),
|
|
31
|
+
取分隔符前的词元判定;避免带注解的 E2E 被误判为 functional 而每任务执行。
|
|
29
32
|
"""
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
head = level.strip().lower()
|
|
34
|
+
for separator in ("(", "(", "[", "/", ",", ",", " ", "—", "-"):
|
|
35
|
+
head = head.split(separator, 1)[0]
|
|
36
|
+
head = head.strip()
|
|
37
|
+
if head in ("manual", "手工", "手动"):
|
|
32
38
|
return "manual"
|
|
33
|
-
if
|
|
34
|
-
return "e2e"
|
|
39
|
+
if head in ("e2e", "端到端", "end"):
|
|
40
|
+
return "e2e"
|
|
35
41
|
return "functional"
|
|
36
42
|
|
|
37
43
|
|
|
@@ -70,7 +76,17 @@ def _manifest_hash(rows: list[dict[str, object]]) -> str:
|
|
|
70
76
|
return hashlib.sha256(payload).hexdigest()
|
|
71
77
|
|
|
72
78
|
|
|
73
|
-
def
|
|
79
|
+
def _legacy_kind(level: str) -> str:
|
|
80
|
+
"""v1 精确匹配:仅用于识别旧版锁定的 manifest,不做新语义判定。"""
|
|
81
|
+
normalized = level.strip().lower()
|
|
82
|
+
if normalized == "manual":
|
|
83
|
+
return "manual"
|
|
84
|
+
if normalized == "e2e":
|
|
85
|
+
return "e2e"
|
|
86
|
+
return "functional"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _extract_rows(task_file: str, kind_fn: Callable[[str], str]) -> dict[str, object]:
|
|
74
90
|
path = Path(task_file)
|
|
75
91
|
text = path.read_text(encoding="utf-8")
|
|
76
92
|
rows: list[dict[str, object]] = []
|
|
@@ -98,7 +114,7 @@ def extract_manifest(task_file: str) -> dict[str, object]:
|
|
|
98
114
|
"id": match.group(1),
|
|
99
115
|
"source": fields[0],
|
|
100
116
|
"level": fields[1],
|
|
101
|
-
"kind":
|
|
117
|
+
"kind": kind_fn(fields[1]),
|
|
102
118
|
"boundary": fields[2],
|
|
103
119
|
"owner": fields[3],
|
|
104
120
|
"status": fields[4],
|
|
@@ -113,6 +129,10 @@ def extract_manifest(task_file: str) -> dict[str, object]:
|
|
|
113
129
|
return {"schema": _MANIFEST_SCHEMA, "task_file": str(path), "task_sha256": _manifest_hash(rows), "scenarios": rows}
|
|
114
130
|
|
|
115
131
|
|
|
132
|
+
def extract_manifest(task_file: str, kind_fn: Callable[[str], str] = _kind) -> dict[str, object]:
|
|
133
|
+
return _extract_rows(task_file, kind_fn)
|
|
134
|
+
|
|
135
|
+
|
|
116
136
|
def write_manifest(task_file: str, output: str) -> dict[str, object]:
|
|
117
137
|
manifest = extract_manifest(task_file)
|
|
118
138
|
target = Path(output)
|
|
@@ -130,14 +150,40 @@ _IMMUTABLE_SCENARIO_FIELDS = (
|
|
|
130
150
|
)
|
|
131
151
|
|
|
132
152
|
|
|
153
|
+
def _heal_legacy_kinds(manifest_file: Path, manifest: dict[str, object], expected: dict[str, object]) -> None:
|
|
154
|
+
"""旧版 `_kind` 精确匹配把带注解的 E2E 锁成 functional;任务内容未变时
|
|
155
|
+
原地升级 kind 并重锁哈希,保留 execution evidence 等运行态字段。"""
|
|
156
|
+
by_id: dict[str, dict[str, object]] = {}
|
|
157
|
+
scenarios = expected.get("scenarios")
|
|
158
|
+
if isinstance(scenarios, list):
|
|
159
|
+
for item in scenarios:
|
|
160
|
+
if isinstance(item, dict) and isinstance(item.get("id"), str):
|
|
161
|
+
by_id[item["id"]] = item
|
|
162
|
+
current = manifest.get("scenarios")
|
|
163
|
+
if isinstance(current, list):
|
|
164
|
+
for item in current:
|
|
165
|
+
if not isinstance(item, dict):
|
|
166
|
+
continue
|
|
167
|
+
target = by_id.get(item.get("id")) if isinstance(item.get("id"), str) else None
|
|
168
|
+
if target is not None:
|
|
169
|
+
item["kind"] = target.get("kind")
|
|
170
|
+
manifest["task_sha256"] = expected["task_sha256"]
|
|
171
|
+
manifest_file.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
172
|
+
|
|
173
|
+
|
|
133
174
|
def validate_manifest(task_file: str, manifest_file: str) -> tuple[bool, str]:
|
|
134
175
|
try:
|
|
135
176
|
manifest = load_manifest(Path(manifest_file))
|
|
136
177
|
expected = extract_manifest(task_file)
|
|
137
178
|
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
138
179
|
return False, f"acceptance_manifest_invalid: {exc}"
|
|
139
|
-
if manifest.get("schema") != _MANIFEST_SCHEMA
|
|
180
|
+
if manifest.get("schema") != _MANIFEST_SCHEMA:
|
|
140
181
|
return False, "acceptance_manifest_drift"
|
|
182
|
+
if manifest.get("task_sha256") != expected["task_sha256"]:
|
|
183
|
+
legacy = extract_manifest(task_file, _legacy_kind)
|
|
184
|
+
if manifest.get("task_sha256") != legacy["task_sha256"]:
|
|
185
|
+
return False, "acceptance_manifest_drift"
|
|
186
|
+
_heal_legacy_kinds(Path(manifest_file), manifest, expected)
|
|
141
187
|
stored_task = manifest.get("task_file", "")
|
|
142
188
|
if not stored_task or (Path(manifest_file).parent / stored_task).resolve() != Path(task_file).resolve():
|
|
143
189
|
return False, "acceptance_manifest_task_mismatch"
|
|
@@ -55,6 +55,31 @@ def normalize_path(path: str) -> str:
|
|
|
55
55
|
return path.replace("\\", "/")
|
|
56
56
|
|
|
57
57
|
|
|
58
|
+
def effective_project_root(project_root: str, absolute_path: str) -> str:
|
|
59
|
+
"""Resolve the project root that owns an edited file.
|
|
60
|
+
|
|
61
|
+
Parallel subagents edit `.code-flow/worktrees/<run>/<TASK>/...`; the nearest
|
|
62
|
+
`.code-flow` ancestor is that worktree's root, not the session root. Files
|
|
63
|
+
outside the session root and unresolvable paths fall back to `project_root`.
|
|
64
|
+
Lexical `abspath` (not `realpath`) keeps caller/returned paths in the same
|
|
65
|
+
coordinates (macOS /var ↔ /private/var symlink).
|
|
66
|
+
"""
|
|
67
|
+
root = os.path.abspath(project_root)
|
|
68
|
+
target = os.path.abspath(absolute_path) if absolute_path else root
|
|
69
|
+
if target != root and not target.startswith(root + os.sep):
|
|
70
|
+
return root
|
|
71
|
+
current = target
|
|
72
|
+
while True:
|
|
73
|
+
if os.path.isdir(os.path.join(current, ".code-flow")):
|
|
74
|
+
return current
|
|
75
|
+
if current == root:
|
|
76
|
+
return root
|
|
77
|
+
parent = os.path.dirname(current)
|
|
78
|
+
if parent == current:
|
|
79
|
+
return root
|
|
80
|
+
current = parent
|
|
81
|
+
|
|
82
|
+
|
|
58
83
|
def _spec_path_from_entry(entry) -> str:
|
|
59
84
|
cfg = normalize_spec_entry(entry)
|
|
60
85
|
return normalize_path(cfg.get("path", ""))
|
|
@@ -474,7 +499,8 @@ def resolve_quality_loop(config: dict) -> dict:
|
|
|
474
499
|
"""
|
|
475
500
|
if not isinstance(config, dict):
|
|
476
501
|
return {"enabled": False, "post_check": False,
|
|
477
|
-
"stop_check": False, "
|
|
502
|
+
"stop_check": False, "finish_check": False,
|
|
503
|
+
"correction_capture": False,
|
|
478
504
|
"compress_reminder": False}
|
|
479
505
|
cfg = config.get("quality_loop")
|
|
480
506
|
cfg = cfg if isinstance(cfg, dict) else {}
|
|
@@ -487,6 +513,7 @@ def resolve_quality_loop(config: dict) -> dict:
|
|
|
487
513
|
"enabled": enabled,
|
|
488
514
|
"post_check": _sub("post_check"),
|
|
489
515
|
"stop_check": _sub("stop_check"),
|
|
516
|
+
"finish_check": _sub("finish_check"),
|
|
490
517
|
"correction_capture": _sub("correction_capture"),
|
|
491
518
|
"compress_reminder": _sub("compress_reminder"),
|
|
492
519
|
}
|
|
@@ -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,7 +11,16 @@ 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
25
|
from cf_spec_context import injection_version, load_active_task, load_context
|
|
17
26
|
from cf_spec_resolver import resolve_candidate_headers
|
|
@@ -49,11 +58,13 @@ def main() -> None:
|
|
|
49
58
|
file_path = (data.get("tool_input") or {}).get("file_path", "")
|
|
50
59
|
if tool_name not in {"Edit", "Write", "MultiEdit"} or not isinstance(file_path, str) or not file_path:
|
|
51
60
|
return
|
|
52
|
-
|
|
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)
|
|
53
65
|
sid = resolve_session_id(data)
|
|
54
66
|
config = load_config(root)
|
|
55
67
|
enforcement = resolve_enforcement(config) if config else "required"
|
|
56
|
-
absolute = file_path if os.path.isabs(file_path) else os.path.join(root, file_path)
|
|
57
68
|
relative = normalize_path(os.path.relpath(absolute, root))
|
|
58
69
|
try:
|
|
59
70
|
expanded = _active_expansion(root, relative)
|
|
@@ -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
|
|
|
@@ -1314,8 +1315,15 @@ def apply_artifact_ref(
|
|
|
1314
1315
|
|
|
1315
1316
|
def _json_payload(stream: IO[str]) -> Mapping[str, object]:
|
|
1316
1317
|
try:
|
|
1317
|
-
|
|
1318
|
-
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:
|
|
1319
1327
|
raise ContextError("invalid_json", "stdin", str(exc)) from exc
|
|
1320
1328
|
return _mapping(loaded, "stdin", "")
|
|
1321
1329
|
|
|
@@ -1508,12 +1516,11 @@ def _active_command(args: argparse.Namespace, payload: Mapping[str, object]) ->
|
|
|
1508
1516
|
except WorkflowError as exc:
|
|
1509
1517
|
raise _reraise(exc) from exc
|
|
1510
1518
|
else:
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
)
|
|
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)
|
|
1517
1524
|
return {"ok": True, "action": result.action, "active": _active_data(result.active)}
|
|
1518
1525
|
return {"ok": True, "active": _active_data(active)}
|
|
1519
1526
|
|
|
@@ -1589,7 +1596,7 @@ def _status_text(data: dict[str, object]) -> str:
|
|
|
1589
1596
|
match = "✓ 一致" if marker["hash_match"] else "✗ 漂移"
|
|
1590
1597
|
lines.append(f"- TASK {marker['task_id']}({marker['status']}),marker hash {match}")
|
|
1591
1598
|
if not marker["hash_match"]:
|
|
1592
|
-
lines.append(" 下一步: 运行 cf-spec doctor(
|
|
1599
|
+
lines.append(" 下一步: 运行 cf-spec refresh 自动重同步 marker hash;仍不一致时用 active doctor --resync(hash 取本命令 --json 的 context_sha256)")
|
|
1593
1600
|
else:
|
|
1594
1601
|
lines.append("- 无 active TASK(path/catalog 路由模式)")
|
|
1595
1602
|
gate = data["gate"]
|
|
@@ -1643,6 +1650,9 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
1643
1650
|
command.add_argument("--task", required=True)
|
|
1644
1651
|
command.add_argument("--context-sha256", required=True)
|
|
1645
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")
|
|
1646
1656
|
start = commands.add_parser("start")
|
|
1647
1657
|
start.add_argument("--task-dir", required=True)
|
|
1648
1658
|
start.add_argument("--root", required=True)
|
|
@@ -74,11 +74,28 @@ def _evidence_issue(rule: RuleBinding, status: RuleStageStatus, diff_sha256: Opt
|
|
|
74
74
|
return "stale_evidence"
|
|
75
75
|
|
|
76
76
|
|
|
77
|
-
def
|
|
77
|
+
def _cheap_skip(status: RuleStageStatus) -> bool:
|
|
78
|
+
"""Latest evidence is a cheap-gate skip (command/test verifier deferred)."""
|
|
79
|
+
if not status.evidence:
|
|
80
|
+
return False
|
|
81
|
+
latest = status.evidence[-1]
|
|
82
|
+
return isinstance(latest, Mapping) and latest.get("error_code") == "skipped_in_cheap_gate"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _status_issue(
|
|
86
|
+
rule: RuleBinding,
|
|
87
|
+
status: RuleStageStatus,
|
|
88
|
+
now: datetime,
|
|
89
|
+
diff_sha256: Optional[str],
|
|
90
|
+
allow_cheap_skips: bool = False,
|
|
91
|
+
) -> Optional[str]:
|
|
78
92
|
if status.status == "applied":
|
|
79
93
|
return None
|
|
80
94
|
if status.status == "verified":
|
|
81
95
|
return _evidence_issue(rule, status, diff_sha256)
|
|
96
|
+
if allow_cheap_skips and status.status == "unverified" and _cheap_skip(status):
|
|
97
|
+
# 轻量门禁(stop 阶段):命令类 verifier 未执行属预期,留给 finish 全量门禁。
|
|
98
|
+
return None
|
|
82
99
|
if status.status == "not_applicable":
|
|
83
100
|
return None if _decision_valid(status.decision) else "decision_invalid"
|
|
84
101
|
if status.status == "waived":
|
|
@@ -87,7 +104,11 @@ def _status_issue(rule: RuleBinding, status: RuleStageStatus, now: datetime, dif
|
|
|
87
104
|
|
|
88
105
|
|
|
89
106
|
def _binding_issues(
|
|
90
|
-
binding: SpecBinding,
|
|
107
|
+
binding: SpecBinding,
|
|
108
|
+
stage: str,
|
|
109
|
+
now: datetime,
|
|
110
|
+
diff_sha256: Optional[str],
|
|
111
|
+
allow_cheap_skips: bool = False,
|
|
91
112
|
) -> tuple[list[GateIssue], list[GateIssue]]:
|
|
92
113
|
errors: list[GateIssue] = []
|
|
93
114
|
warnings: list[GateIssue] = []
|
|
@@ -97,7 +118,7 @@ def _binding_issues(
|
|
|
97
118
|
continue
|
|
98
119
|
if rule.enforcement == "advisory" and status.status == "pending":
|
|
99
120
|
continue
|
|
100
|
-
code = "missing_spec" if binding.status == "missing" else _status_issue(rule, status, now, diff_sha256)
|
|
121
|
+
code = "missing_spec" if binding.status == "missing" else _status_issue(rule, status, now, diff_sha256, allow_cheap_skips)
|
|
101
122
|
if code is None:
|
|
102
123
|
continue
|
|
103
124
|
issue = GateIssue(code, binding.spec_id, rule.ref, stage, f"{binding.spec_id}#{rule.ref}: {code}")
|
|
@@ -113,13 +134,14 @@ def validate_stage(
|
|
|
113
134
|
task_id: str = "",
|
|
114
135
|
now: Optional[datetime] = None,
|
|
115
136
|
diff_sha256: Optional[str] = None,
|
|
137
|
+
allow_cheap_skips: bool = False,
|
|
116
138
|
) -> GateResult:
|
|
117
139
|
del artifact, task_id
|
|
118
140
|
current = now or datetime.now(timezone.utc)
|
|
119
141
|
errors: list[GateIssue] = []
|
|
120
142
|
warnings: list[GateIssue] = []
|
|
121
143
|
for binding in context.bindings:
|
|
122
|
-
binding_errors, binding_warnings = _binding_issues(binding, stage, current, diff_sha256)
|
|
144
|
+
binding_errors, binding_warnings = _binding_issues(binding, stage, current, diff_sha256, allow_cheap_skips)
|
|
123
145
|
errors.extend(binding_errors)
|
|
124
146
|
warnings.extend(binding_warnings)
|
|
125
147
|
refs = tuple(sorted({f"{item.spec_id}#{item.rule_ref}" for item in (*errors, *warnings)}))
|
|
@@ -137,7 +137,7 @@ def _active_route(root: str) -> RouteResult:
|
|
|
137
137
|
if current_hash != active.context_sha256:
|
|
138
138
|
raise RouterError(
|
|
139
139
|
"active_context_drift",
|
|
140
|
-
"active marker Context hash does not match; run `
|
|
140
|
+
"active marker Context hash does not match; run `cf_spec_context.py refresh --task-dir <dir> --root <root> --json` to re-sync it (refresh updates the marker hash), or `active doctor --resync` with the hash from `status --json`",
|
|
141
141
|
)
|
|
142
142
|
cached = _cached_projection(root, task_dir, active.task_id, current_hash)
|
|
143
143
|
if cached is None:
|
|
@@ -96,6 +96,34 @@ def session_edited_files(project_root: str, sid: str) -> list:
|
|
|
96
96
|
return files
|
|
97
97
|
|
|
98
98
|
|
|
99
|
+
def _root_scoped_files(project_root: str, files: list) -> list:
|
|
100
|
+
"""Drop worktree-internal / outside-root paths.
|
|
101
|
+
|
|
102
|
+
Parallel subagents edit another working copy (.code-flow/worktrees/<run>/<TASK>/);
|
|
103
|
+
those paths must not trigger this root's validators.
|
|
104
|
+
"""
|
|
105
|
+
root = os.path.realpath(project_root)
|
|
106
|
+
scoped = []
|
|
107
|
+
for rel in files:
|
|
108
|
+
path = normalize_path(rel)
|
|
109
|
+
if path.startswith(".code-flow/worktrees/"):
|
|
110
|
+
continue
|
|
111
|
+
absolute = os.path.realpath(os.path.join(root, path))
|
|
112
|
+
if absolute == root or absolute.startswith(root + os.sep):
|
|
113
|
+
scoped.append(rel)
|
|
114
|
+
return scoped
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _stop_validators(project_root: str) -> list:
|
|
118
|
+
"""Per-stop validators: heavy entries (full tests / e2e suites) are skipped
|
|
119
|
+
on every Stop and only run via the explicit `cf-validate` command (or CI)."""
|
|
120
|
+
return [
|
|
121
|
+
validator
|
|
122
|
+
for validator in load_validators(project_root)
|
|
123
|
+
if not (isinstance(validator, dict) and validator.get("heavy") is True)
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
|
|
99
127
|
def _is_active_task_file(rel_path: str) -> bool:
|
|
100
128
|
path = normalize_path(rel_path)
|
|
101
129
|
if not path.startswith(".code-flow/tasks/") or "/archived/" in path:
|
|
@@ -294,7 +322,7 @@ def _main() -> None:
|
|
|
294
322
|
active = load_active_task(project_root)
|
|
295
323
|
task_dir = os.path.join(project_root, active.task_dir)
|
|
296
324
|
gate_remaining = deadline - time.monotonic()
|
|
297
|
-
done = run_done_gate(project_root, task_dir, budget=min(GATE_BUDGET_SECONDS, max(gate_remaining, 0.01)))
|
|
325
|
+
done = run_done_gate(project_root, task_dir, cheap=True, budget=min(GATE_BUDGET_SECONDS, max(gate_remaining, 0.01)))
|
|
298
326
|
except (OSError, ValueError) as exc:
|
|
299
327
|
if enforcement == "required":
|
|
300
328
|
payload = {"decision": "block", "reason": f"SPEC_WORKFLOW_BLOCKED: active task is invalid: {exc}"}
|
|
@@ -316,11 +344,11 @@ def _main() -> None:
|
|
|
316
344
|
if not resolve_quality_loop(config)["stop_check"]:
|
|
317
345
|
return
|
|
318
346
|
if not has_active:
|
|
319
|
-
files = session_edited_files(project_root, sid)
|
|
347
|
+
files = _root_scoped_files(project_root, session_edited_files(project_root, sid))
|
|
320
348
|
if not files:
|
|
321
349
|
return
|
|
322
350
|
acceptance_failures = task_acceptance_failures(project_root, files)
|
|
323
|
-
validators =
|
|
351
|
+
validators = _stop_validators(project_root)
|
|
324
352
|
failures, truncated = run_validators(
|
|
325
353
|
project_root, validators, files, sid, deadline=deadline
|
|
326
354
|
) if validators else ([], False)
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Programmatic task-file index: parse TASK statuses/depends, compute DAG batches.
|
|
3
3
|
|
|
4
|
-
Single worktrees allow one active TASK at a time
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
Single worktrees allow one active TASK at a time; batches list *independently
|
|
5
|
+
developable* tasks (no dependency path between them). `cf-task:start` may
|
|
6
|
+
dispatch batch tasks in their own worktrees for parallel subagents — the
|
|
7
|
+
single-active constraint stays per worktree.
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
from __future__ import annotations
|
|
@@ -116,7 +117,7 @@ def index_data(task_file: str) -> dict[str, object]:
|
|
|
116
117
|
"batches": task_batches(nodes),
|
|
117
118
|
"independent_groups": independent_groups(nodes),
|
|
118
119
|
"dependency_components": dependency_components(nodes),
|
|
119
|
-
"note": "batches 组内无依赖;dependency_components 仅组间独立(independent_groups 为兼容别名);单 worktree 一次仅激活一个 TASK",
|
|
120
|
+
"note": "batches 组内无依赖;dependency_components 仅组间独立(independent_groups 为兼容别名);单 worktree 一次仅激活一个 TASK,批次内独立任务可由 cf-task:start 在各 worktree 并行派发",
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
|