@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.
- 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 +2 -0
- 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 +99 -10
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +6 -1
- 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-archive/SKILL.md +2 -0
- 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 +99 -10
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +6 -1
- 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/archive.md +2 -0
- 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 +99 -10
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +6 -1
- 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/archive.md +2 -0
- 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 +99 -10
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +6 -1
- 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 +53 -16
- package/src/core/code-flow/scripts/cf_spec_gate.py +28 -4
- package/src/core/code-flow/scripts/cf_spec_metadata.py +34 -3
- package/src/core/code-flow/scripts/cf_spec_router.py +1 -1
- package/src/core/code-flow/scripts/cf_spec_verify.py +107 -9
- 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 +394 -0
- package/src/core/code-flow/scripts/cf_task_runtime.py +49 -10
- package/src/core/code-flow/scripts/cf_task_workflow.py +111 -13
- 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
|
-
//
|
|
10
|
-
//
|
|
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 =
|
|
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
|
|
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
|
|
70
|
-
if (!
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 (
|
|
88
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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;
|
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)
|