@michaelxwb/code-flow 0.6.3 → 0.6.5

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.
Files changed (54) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/adapters/claude/commands/cf-init.md +1 -0
  4. package/src/adapters/claude/commands/cf-spec.md +7 -3
  5. package/src/adapters/claude/commands/cf-task/archive.md +2 -0
  6. package/src/adapters/claude/commands/cf-task/graph.md +1 -1
  7. package/src/adapters/claude/commands/cf-task/plan.md +2 -2
  8. package/src/adapters/claude/commands/cf-task/start.md +99 -10
  9. package/src/adapters/claude/commands/cf-task/verify-e2e.md +6 -1
  10. package/src/adapters/claude/commands/cf-validate.md +3 -0
  11. package/src/adapters/codex/skills/cf-init/SKILL.md +1 -0
  12. package/src/adapters/codex/skills/cf-spec/SKILL.md +6 -2
  13. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +2 -0
  14. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +1 -1
  15. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +2 -2
  16. package/src/adapters/codex/skills/cf-task-start/SKILL.md +99 -10
  17. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +6 -1
  18. package/src/adapters/codex/skills/cf-validate/SKILL.md +3 -0
  19. package/src/adapters/costrict/commands/cf-init.md +1 -0
  20. package/src/adapters/costrict/commands/cf-spec.md +7 -3
  21. package/src/adapters/costrict/commands/cf-task/archive.md +2 -0
  22. package/src/adapters/costrict/commands/cf-task/graph.md +1 -1
  23. package/src/adapters/costrict/commands/cf-task/plan.md +2 -2
  24. package/src/adapters/costrict/commands/cf-task/start.md +99 -10
  25. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +6 -1
  26. package/src/adapters/costrict/commands/cf-validate.md +3 -0
  27. package/src/adapters/opencode/commands/cf-init.md +9 -4
  28. package/src/adapters/opencode/commands/cf-spec.md +6 -2
  29. package/src/adapters/opencode/commands/cf-task/archive.md +2 -0
  30. package/src/adapters/opencode/commands/cf-task/graph.md +1 -1
  31. package/src/adapters/opencode/commands/cf-task/plan.md +2 -2
  32. package/src/adapters/opencode/commands/cf-task/start.md +99 -10
  33. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +6 -1
  34. package/src/adapters/opencode/commands/cf-validate.md +3 -0
  35. package/src/adapters/opencode/opencode.json +1 -2
  36. package/src/adapters/opencode/plugins/code-flow/index.js +138 -78
  37. package/src/adapters/opencode/plugins/code-flow/package.json +1 -1
  38. package/src/cli.js +51 -1
  39. package/src/core/code-flow/config.yml +2 -1
  40. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +54 -8
  41. package/src/core/code-flow/scripts/cf_core.py +28 -1
  42. package/src/core/code-flow/scripts/cf_post_hook.py +5 -4
  43. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +14 -3
  44. package/src/core/code-flow/scripts/cf_spec_context.py +53 -16
  45. package/src/core/code-flow/scripts/cf_spec_gate.py +28 -4
  46. package/src/core/code-flow/scripts/cf_spec_metadata.py +34 -3
  47. package/src/core/code-flow/scripts/cf_spec_router.py +1 -1
  48. package/src/core/code-flow/scripts/cf_spec_verify.py +107 -9
  49. package/src/core/code-flow/scripts/cf_stop_hook.py +31 -3
  50. package/src/core/code-flow/scripts/cf_task_index.py +5 -4
  51. package/src/core/code-flow/scripts/cf_task_parallel.py +394 -0
  52. package/src/core/code-flow/scripts/cf_task_runtime.py +49 -10
  53. package/src/core/code-flow/scripts/cf_task_workflow.py +111 -13
  54. package/src/core/code-flow/validation.yml +5 -0
@@ -2,12 +2,20 @@ import { execFile } from "child_process";
2
2
  import { join } from "path";
3
3
  import { appendFileSync, mkdirSync } from "fs";
4
4
 
5
+ // Note: intentionally no `import { Plugin } from "@opencode/plugin"`.
6
+ // The server does not auto-provide that package for local plugins, and
7
+ // `Plugin.define` is only an identity wrapper — a plain default export
8
+ // with `id` + `setup` satisfies the v2 plugin schema dependency-free,
9
+ // so `code-flow init` works offline without npm install.
10
+
5
11
  const SCRIPT_DIR = ".code-flow/scripts";
12
+ const STOP_CHECK_TIMEOUT = 35000;
13
+ const HOOK_TIMEOUT = 5000;
6
14
 
7
15
  // Per-session queued feedback. Single map, append-only: idle/stop-check and
8
- // post-check feedback accumulate here and are consumed by the next
9
- // system.transform. Never overwrite — chat.message arriving between idle and
10
- // transform must not drop queued stop-check feedback.
16
+ // post-check feedback accumulate here and are consumed by the next context
17
+ // hook. Never overwrite — a prompt hook arriving between idle and context
18
+ // must not drop queued stop-check feedback.
11
19
  const sessionContext = new Map();
12
20
 
13
21
  export function mergePending(existing, incoming) {
@@ -30,10 +38,10 @@ function pythonPath(projectRoot, script) {
30
38
  return join(projectRoot, SCRIPT_DIR, script);
31
39
  }
32
40
 
33
- function callHook(projectRoot, script, input, timeout = 5000) {
41
+ function callHook(projectRoot, script, input, timeout = HOOK_TIMEOUT) {
34
42
  // Async child process: never block the plugin event thread. opencode's
35
43
  // idle event cannot interrupt a turn, so stop-check failures queue into
36
- // sessionContext for the next system.transform (documented platform gap
44
+ // sessionContext for the next context hook (documented platform gap
37
45
  // vs. blocking Stop hooks).
38
46
  return new Promise((resolve) => {
39
47
  const child = execFile(
@@ -66,88 +74,140 @@ function callHook(projectRoot, script, input, timeout = 5000) {
66
74
  });
67
75
  }
68
76
 
69
- function extractPromptText(output) {
70
- if (!output.parts) return "";
71
- return output.parts
72
- .filter((p) => p.type === "text" && !p.synthetic && !p.ignored)
73
- .map((p) => p.text)
74
- .join("\n");
77
+ function queueFeedback(sessionID, text) {
78
+ if (!sessionID || !text) return;
79
+ sessionContext.set(sessionID, mergePending(sessionContext.get(sessionID), text));
80
+ }
81
+
82
+ function consumeFeedback(sessionID) {
83
+ const pending = sessionContext.get(sessionID);
84
+ if (pending) sessionContext.delete(sessionID);
85
+ return pending;
86
+ }
87
+
88
+ function toolInputFilePath(input) {
89
+ if (!input || typeof input !== "object") return "";
90
+ const args = input;
91
+ return args.filePath || args.file_path || args.path || "";
92
+ }
93
+
94
+ async function runStopCheck(projectRoot, sessionID) {
95
+ const result = await callHook(projectRoot, "cf_stop_hook.py", { session_id: sessionID }, STOP_CHECK_TIMEOUT);
96
+ if (result?.reason) {
97
+ // idle 无法阻断,校验失败排队到下一轮 context hook
98
+ queueFeedback(sessionID, result.reason);
99
+ debugLog(projectRoot, `stop-check feedback queued`);
100
+ }
101
+ }
102
+
103
+ async function registerPromptHook(ctx, projectRoot) {
104
+ await ctx.session.hook("prompt", async (event) => {
105
+ const promptText = event.prompt?.text || "";
106
+ if (!promptText) return;
107
+ debugLog(projectRoot, `prompt sid=${event.sessionID} prompt_len=${promptText.length}`);
108
+ const result = await callHook(projectRoot, "cf_user_prompt_hook.py", {
109
+ prompt: promptText,
110
+ session_id: event.sessionID,
111
+ });
112
+ const additional = result?.hookSpecificOutput?.additionalContext;
113
+ if (additional) {
114
+ // Append, never overwrite: queued stop-check feedback survives.
115
+ queueFeedback(event.sessionID, additional);
116
+ debugLog(projectRoot, `hook matched — context ${additional.length} chars cached`);
117
+ } else {
118
+ debugLog(projectRoot, `hook returned no context`);
119
+ }
120
+ });
75
121
  }
76
122
 
77
- export const CodeFlow = async (ctx) => {
78
- const projectRoot = ctx.directory;
123
+ async function registerToolHook(ctx, projectRoot) {
124
+ await ctx.tool.hook("execute.after", async (event) => {
125
+ const tool = String(event.tool || "").toLowerCase();
126
+ if (!["edit", "write", "multiedit", "patch"].includes(tool)) return;
127
+ const filePath = toolInputFilePath(event.input);
128
+ if (!filePath) return;
129
+ const result = await callHook(projectRoot, "cf_post_hook.py", {
130
+ tool_name: tool === "write" ? "Write" : "Edit",
131
+ tool_input: { file_path: filePath },
132
+ session_id: event.sessionID || "",
133
+ });
134
+ const additional = result?.hookSpecificOutput?.additionalContext;
135
+ if (additional) {
136
+ // 反馈排队,下一轮 context hook 注入(opencode 无法当轮插话)
137
+ queueFeedback(event.sessionID, additional);
138
+ debugLog(projectRoot, `post-check feedback queued ${additional.length} chars`);
139
+ }
140
+ });
141
+ }
79
142
 
143
+ async function registerContextHook(ctx) {
144
+ await ctx.session.hook("context", (event) => {
145
+ const pending = consumeFeedback(event.sessionID);
146
+ if (pending) {
147
+ event.system.push({ type: "text", text: pending });
148
+ debugLog(ctx.location.directory, `context — injected ${pending.length} chars`);
149
+ }
150
+ });
151
+ }
152
+
153
+ // 子会话(子 agent / worktree 并行任务)的 idle 不得触发主工作区 stop-check:
154
+ // 子会话的编辑发生在自己的工作区,主区既无 active marker 也没有对应文件,
155
+ // 触发只会重复执行主区 validators(全量测试)。用 session.created.parentID 标记子会话。
156
+ export function createSessionRegistry() {
157
+ const children = new Set();
80
158
  return {
81
- event: async (input) => {
82
- if (input.event?.type === "session.created") {
83
- const sid = input.event?.properties?.info?.id || "";
84
- debugLog(projectRoot, `session.created sid=${sid}`);
85
- if (sid) sessionContext.delete(sid);
159
+ observe(event) {
160
+ if (!event || typeof event.type !== "string") return null;
161
+ const sid = event.data?.sessionID || "";
162
+ if (!sid) return null;
163
+ if (event.type === "session.created") {
164
+ if (event.data?.parentID) children.add(sid);
165
+ return { type: "created", sid };
86
166
  }
87
- if (input.event?.type === "session.idle") {
88
- const sid =
89
- input.event?.properties?.sessionID ||
90
- input.event?.properties?.info?.id || "";
91
- const result = await callHook(projectRoot, "cf_stop_hook.py", { session_id: sid }, 35000);
92
- if (result?.reason) {
93
- // idle 无法阻断,校验失败排队到下一轮 system prompt
94
- sessionContext.set(sid, mergePending(sessionContext.get(sid), result.reason));
95
- debugLog(projectRoot, `stop-check feedback queued`);
96
- }
167
+ if (event.type === "session.idle") {
168
+ return { type: "idle", sid, child: children.has(sid) };
97
169
  }
170
+ return null;
98
171
  },
172
+ };
173
+ }
99
174
 
100
- "chat.message": async (input, output) => {
101
- const promptText = extractPromptText(output);
102
- if (!promptText) return;
103
-
104
- debugLog(projectRoot, `chat.message sid=${input.sessionID} prompt_len=${promptText.length}`);
105
-
106
- const result = await callHook(projectRoot, "cf_user_prompt_hook.py", {
107
- prompt: promptText,
108
- session_id: input.sessionID,
109
- });
110
-
111
- if (result?.hookSpecificOutput?.additionalContext) {
112
- const ctxLen = result.hookSpecificOutput.additionalContext.length;
113
- debugLog(projectRoot, `hook matched — context ${ctxLen} chars cached`);
114
- // Append, never overwrite: queued stop-check feedback survives.
115
- sessionContext.set(
116
- input.sessionID,
117
- mergePending(sessionContext.get(input.sessionID), result.hookSpecificOutput.additionalContext)
118
- );
119
- } else {
120
- debugLog(projectRoot, `hook returned no context`);
175
+ function startEventSubscription(ctx, projectRoot) {
176
+ const controller = new AbortController();
177
+ const registry = createSessionRegistry();
178
+ void (async () => {
179
+ try {
180
+ for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
181
+ const observed = registry.observe(event);
182
+ if (!observed) continue;
183
+ if (observed.type === "created") {
184
+ debugLog(projectRoot, `session.created sid=${observed.sid}`);
185
+ sessionContext.delete(observed.sid);
186
+ } else if (observed.child) {
187
+ debugLog(projectRoot, `session.idle sid=${observed.sid} (child) — stop-check skipped`);
188
+ } else {
189
+ await runStopCheck(projectRoot, observed.sid);
190
+ }
121
191
  }
122
- },
123
-
124
- "tool.execute.after": async (input, output) => {
125
- const tool = String(input?.tool || "").toLowerCase();
126
- if (!["edit", "write", "multiedit", "patch"].includes(tool)) return;
127
- const args = output?.args || input?.args || {};
128
- const filePath = args.filePath || args.file_path || args.path;
129
- if (!filePath) return;
130
- const result = await callHook(projectRoot, "cf_post_hook.py", {
131
- tool_name: tool === "write" ? "Write" : "Edit",
132
- tool_input: { file_path: filePath },
133
- session_id: input?.sessionID || "",
134
- });
135
- const ctx = result?.hookSpecificOutput?.additionalContext;
136
- if (ctx) {
137
- // 反馈排队,下一轮 system.transform 注入(opencode 无法当轮插话)
138
- const sid = input?.sessionID || "";
139
- sessionContext.set(sid, mergePending(sessionContext.get(sid), ctx));
140
- debugLog(projectRoot, `post-check feedback queued ${ctx.length} chars`);
192
+ } catch (err) {
193
+ if (err?.name !== "AbortError") {
194
+ debugLog(projectRoot, `event subscription ended: ${err?.message || err}`);
141
195
  }
142
- },
196
+ }
197
+ })();
198
+ return () => controller.abort();
199
+ }
143
200
 
144
- "experimental.chat.system.transform": async (input, output) => {
145
- const ctx = sessionContext.get(input.sessionID);
146
- if (ctx) {
147
- output.system.push(ctx);
148
- debugLog(projectRoot, `system.transform — injected ${ctx.length} chars, system now ${output.system.length} parts`);
149
- sessionContext.delete(input.sessionID);
150
- }
151
- },
152
- };
201
+ const CodeFlowPlugin = {
202
+ id: "code-flow",
203
+ async setup(ctx) {
204
+ const projectRoot = ctx.location.directory;
205
+ await registerPromptHook(ctx, projectRoot);
206
+ await registerToolHook(ctx, projectRoot);
207
+ await registerContextHook(ctx);
208
+ const stopSubscription = startEventSubscription(ctx, projectRoot);
209
+ return () => stopSubscription();
210
+ },
153
211
  };
212
+
213
+ export default CodeFlowPlugin;
@@ -2,5 +2,5 @@
2
2
  "name": "code-flow-opencode",
3
3
  "type": "module",
4
4
  "main": "index.js",
5
- "description": "code-flow spec injection plugin for OpenCode"
5
+ "description": "code-flow spec injection plugin for OpenCode v2"
6
6
  }
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: mergeSettingsJson,
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
- normalized = level.strip().lower()
31
- if normalized == "manual":
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 normalized == "e2e":
34
- return "e2e" # Keep as separate kind, not functional
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 extract_manifest(task_file: str) -> dict[str, object]:
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": _kind(fields[1]),
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 or manifest.get("task_sha256") != expected["task_sha256"]:
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, "correction_capture": 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
- project_root = os.getcwd()
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 _log, ensure_utf8_io, load_config, normalize_path, resolve_enforcement, resolve_session_id, timing_log
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
- root = os.getcwd()
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)