cowork-flow 1.2.0 → 1.3.0
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/CHANGELOG.md +33 -0
- package/README.md +4 -3
- package/package.json +1 -1
- package/template/.claude/hooks/inject-workflow-state.py +12 -98
- package/template/.codex/hooks/inject-workflow-state.py +13 -89
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/adapters/cli/task.py +4 -1
- package/template/.cowork-flow/scripts/adapters/cli/task_context_commands.py +65 -0
- package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +23 -0
- package/template/.cowork-flow/scripts/adapters/host/claude_code_policy.py +52 -0
- package/template/.cowork-flow/scripts/adapters/host/codex_policy.py +61 -0
- package/template/.cowork-flow/scripts/adapters/host/inject.py +249 -0
- package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +280 -35
- package/template/.cowork-flow/scripts/adapters/host/zcode_policy.py +230 -0
- package/template/.cowork-flow/scripts/adapters/mcp/state_server.py +2 -5
- package/template/.cowork-flow/scripts/runtime/session_state.py +19 -9
- package/template/.cowork-flow/scripts/services/context_discovery.py +18 -5
- package/template/.cowork-flow/scripts/services/fact_view.py +14 -0
- package/template/.cowork-flow/scripts/services/spec_check.py +34 -12
- package/template/.cowork-flow/scripts/services/task_context.py +0 -1
- package/template/.cowork-flow/spec/contracts/context-injection.md +24 -20
- package/template/.cowork-flow/spec/contracts/fact-layer-access.md +43 -8
- package/template/.cowork-flow/spec/contracts/spec-checks.md +40 -8
- package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +21 -0
- package/template/.cowork-flow/spec/runtime/contract-registry.json +12 -0
- package/template/.opencode/plugins/cowork-flow.js +12 -0
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/.zcode/hooks/inject-context.js +95 -971
- package/template/skills/agent-dispatch/SKILL.md +15 -0
- package/template/skills/runtime-health/scripts/doctor.py +90 -0
- package/template/skills/task-planning/SKILL.md +1 -0
- package/template/skills/task-review/SKILL.md +1 -0
- package/template/.zcode/hooks/inject-context.selfcheck.mjs +0 -795
|
@@ -1,64 +1,49 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* cowork-flow ZCode plugin hook.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* cowork-flow ZCode plugin hook — transport shim.
|
|
4
|
+
*
|
|
5
|
+
* All workflow facts (workflow-state, contract digest, decision anchor,
|
|
6
|
+
* stage contract, scope/spec edit warnings) are rendered by the single
|
|
7
|
+
* Python source: <scripts>/adapters/host/inject.py. This shim keeps only
|
|
8
|
+
* transport duties:
|
|
9
|
+
* 1. cheap PostToolUse(Bash) filter — non-lifecycle commands exit 0
|
|
10
|
+
* without spawning anything (the highest-frequency event);
|
|
11
|
+
* 2. interpreter location (COWORK_FLOW_PYTHON → python3 → python → py -3);
|
|
12
|
+
* 3. stdin passthrough to inject.py --host zcode, stdout/exit forwarding.
|
|
13
|
+
*
|
|
14
|
+
* The project's own runtime copy is preferred (same root the CLI resolves);
|
|
15
|
+
* the plugin cache copy (hooks/runtime/scripts) renders only for directories
|
|
16
|
+
* outside any cowork-flow project. Windows never spawns .cmd shims directly
|
|
17
|
+
* (Node 24+ EINVAL) — interpreters resolve to .exe entries.
|
|
7
18
|
*/
|
|
8
19
|
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
20
|
+
import { existsSync, readFileSync } from "fs";
|
|
21
|
+
import { dirname, join } from "path";
|
|
22
|
+
import { fileURLToPath } from "url";
|
|
12
23
|
import { spawnSync } from "child_process";
|
|
13
24
|
|
|
14
|
-
const
|
|
15
|
-
const FILE_TASK_JSON = "task.json";
|
|
25
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
26
|
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
contracts: [
|
|
21
|
-
{
|
|
22
|
-
id: "RUNTIME_CONTEXT_DISPATCH_V2",
|
|
23
|
-
path: ".cowork-flow/spec/contracts/subagent-dispatch.md",
|
|
24
|
-
digest: [
|
|
25
|
-
"Formal subagent work is keyed by cowork_runtime_context_id.",
|
|
26
|
-
"Explicit shim bind records bound_context_key before formal output is accepted.",
|
|
27
|
-
],
|
|
28
|
-
readWhen: ["before formal subagent dispatch", "when checking subagent health"],
|
|
29
|
-
},
|
|
30
|
-
],
|
|
31
|
-
};
|
|
27
|
+
const DIR_WORKFLOW = ".cowork-flow";
|
|
28
|
+
const LIFECYCLE_BASH_RE = /\brun(?:\.cmd)?\s+(?:task|subagent|resume)\b/;
|
|
29
|
+
const SPAWN_TIMEOUT_MS = 4800; // hooks.json grants 5s per event
|
|
32
30
|
|
|
33
31
|
function readHookInput() {
|
|
34
|
-
if (process.stdin.isTTY) return {};
|
|
32
|
+
if (process.stdin.isTTY) return { raw: "", parsed: {} };
|
|
33
|
+
let raw = "";
|
|
35
34
|
try {
|
|
36
|
-
|
|
37
|
-
if (!raw) return {};
|
|
38
|
-
const parsed = JSON.parse(raw);
|
|
39
|
-
return parsed && typeof parsed === "object" ? parsed : {};
|
|
35
|
+
raw = readFileSync(0, "utf8").trim();
|
|
40
36
|
} catch {
|
|
41
|
-
return {};
|
|
37
|
+
return { raw: "", parsed: {} };
|
|
42
38
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(raw);
|
|
41
|
+
return { raw, parsed: parsed && typeof parsed === "object" ? parsed : {} };
|
|
42
|
+
} catch {
|
|
43
|
+
return { raw: "", parsed: {} };
|
|
48
44
|
}
|
|
49
|
-
if (process.env.CURSOR_PLUGIN_ROOT) return "SessionStart";
|
|
50
|
-
return "UserPromptSubmit";
|
|
51
45
|
}
|
|
52
46
|
|
|
53
|
-
function outputFormat() {
|
|
54
|
-
if (process.env.CURSOR_PLUGIN_ROOT) return "cursor";
|
|
55
|
-
if (process.env.CLAUDE_PLUGIN_ROOT && !process.env.COPILOT_CLI) return "claude";
|
|
56
|
-
return "generic";
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// ---------------------------------------------------------------------------
|
|
60
|
-
// Find project root
|
|
61
|
-
// ---------------------------------------------------------------------------
|
|
62
47
|
function findWorkflowRoot(startDir) {
|
|
63
48
|
if (typeof startDir !== "string" || !startDir.trim()) return null;
|
|
64
49
|
let current = startDir;
|
|
@@ -70,9 +55,9 @@ function findWorkflowRoot(startDir) {
|
|
|
70
55
|
return existsSync(join(current, DIR_WORKFLOW)) ? current : null;
|
|
71
56
|
}
|
|
72
57
|
|
|
73
|
-
function findProjectRoot(
|
|
58
|
+
function findProjectRoot(parsedInput) {
|
|
74
59
|
const candidates = [
|
|
75
|
-
|
|
60
|
+
parsedInput?.cwd,
|
|
76
61
|
process.env.ZCODE_PROJECT_DIR,
|
|
77
62
|
process.env.CLAUDE_PROJECT_DIR,
|
|
78
63
|
process.cwd(),
|
|
@@ -84,767 +69,6 @@ function findProjectRoot(input) {
|
|
|
84
69
|
return null;
|
|
85
70
|
}
|
|
86
71
|
|
|
87
|
-
// ---------------------------------------------------------------------------
|
|
88
|
-
// Read active task from .cowork-flow/.runtime/sessions/
|
|
89
|
-
// ---------------------------------------------------------------------------
|
|
90
|
-
// Sanitize rules mirror Python session_state._sanitize so both sides resolve
|
|
91
|
-
// the same session file name for one context key.
|
|
92
|
-
function sanitizeContextKey(raw) {
|
|
93
|
-
const safe = String(raw ?? "")
|
|
94
|
-
.trim()
|
|
95
|
-
.replace(/[^A-Za-z0-9._-]+/g, "_")
|
|
96
|
-
.replace(/^[._-]+|[._-]+$/g, "");
|
|
97
|
-
return safe.slice(0, 160);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function resolveSessionKey(input) {
|
|
101
|
-
// Python session_state resolves an explicit COWORK_FLOW_CONTEXT_ID as the
|
|
102
|
-
// raw context key - it must not gain a host prefix here either.
|
|
103
|
-
const explicit = process.env.COWORK_FLOW_CONTEXT_ID;
|
|
104
|
-
if (typeof explicit === "string" && explicit.trim()) {
|
|
105
|
-
const key = sanitizeContextKey(explicit);
|
|
106
|
-
if (key) return key;
|
|
107
|
-
}
|
|
108
|
-
const candidates = [
|
|
109
|
-
process.env.ZCODE_SESSION_ID,
|
|
110
|
-
input?.zcode_session_id,
|
|
111
|
-
input?.ZCODE_SESSION_ID,
|
|
112
|
-
input?.sessionId,
|
|
113
|
-
input?.session_id,
|
|
114
|
-
];
|
|
115
|
-
for (const candidate of candidates) {
|
|
116
|
-
if (typeof candidate === "string" && candidate.trim()) {
|
|
117
|
-
const key = sanitizeContextKey(candidate);
|
|
118
|
-
if (key) return `zcode_${key}`;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
return null;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function loadSessionEntries(sessionsDir) {
|
|
125
|
-
const entries = [];
|
|
126
|
-
try {
|
|
127
|
-
const files = readdirSync(sessionsDir).filter((f) => f.endsWith(".json"));
|
|
128
|
-
for (const file of files) {
|
|
129
|
-
try {
|
|
130
|
-
const data = JSON.parse(readFileSync(join(sessionsDir, file), "utf8"));
|
|
131
|
-
if (data && typeof data === "object") {
|
|
132
|
-
entries.push({ name: file, data });
|
|
133
|
-
}
|
|
134
|
-
} catch {
|
|
135
|
-
// skip malformed session file
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
} catch {
|
|
139
|
-
// sessions dir unreadable
|
|
140
|
-
}
|
|
141
|
-
return entries;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function toActiveTask(data) {
|
|
145
|
-
return {
|
|
146
|
-
taskPath: data.active_task_path,
|
|
147
|
-
scope: data.scope || "main",
|
|
148
|
-
platform: data.platform || "unknown",
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// One-level scan of bindable tasks for rebind hints; tasks/archive lives two
|
|
153
|
-
// levels deeper so a plain readdir already excludes it.
|
|
154
|
-
function listActiveTasks(repoRoot) {
|
|
155
|
-
const tasksDir = join(repoRoot, DIR_WORKFLOW, "tasks");
|
|
156
|
-
const out = [];
|
|
157
|
-
try {
|
|
158
|
-
for (const name of readdirSync(tasksDir)) {
|
|
159
|
-
const relative = `${DIR_WORKFLOW}/tasks/${name}`;
|
|
160
|
-
const { status, missing } = readTaskStatus(repoRoot, relative);
|
|
161
|
-
if (!missing && status !== "completed") out.push(`${relative} (${status})`);
|
|
162
|
-
}
|
|
163
|
-
} catch {
|
|
164
|
-
// tasks dir unreadable
|
|
165
|
-
}
|
|
166
|
-
return out;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function formatRebindHints(repoRoot) {
|
|
170
|
-
const active = listActiveTasks(repoRoot);
|
|
171
|
-
if (active.length === 0) return "";
|
|
172
|
-
return [
|
|
173
|
-
"",
|
|
174
|
-
"活动任务(可用 ./.cowork-flow/run task next <dir> 改绑):",
|
|
175
|
-
...active.map((entry) => `- ${entry}`),
|
|
176
|
-
].join("\n");
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function readActiveTask(repoRoot, sessionKey = null) {
|
|
180
|
-
const sessionsDir = join(repoRoot, DIR_WORKFLOW, ".runtime", "sessions");
|
|
181
|
-
if (!existsSync(sessionsDir)) return null;
|
|
182
|
-
const entries = loadSessionEntries(sessionsDir);
|
|
183
|
-
|
|
184
|
-
// The current session's own binding wins even when another host wrote a
|
|
185
|
-
// newer session file; a dead path here is reported as-is by buildContext.
|
|
186
|
-
if (sessionKey) {
|
|
187
|
-
const own = entries.find((entry) => entry.name === `${sessionKey}.json`);
|
|
188
|
-
if (own?.data.active_task_path) return toActiveTask(own.data);
|
|
189
|
-
// An explicitly identified session without its own file must not adopt
|
|
190
|
-
// another session's binding - the CLI reports no_task for the same state.
|
|
191
|
-
if (!own) return null;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Global fallback: newest first, but skip subagent-scoped files and entries
|
|
195
|
-
// whose task directory no longer exists so stale sessions cannot poison
|
|
196
|
-
// fresh sessions.
|
|
197
|
-
const candidates = entries
|
|
198
|
-
.filter((entry) => entry.data.active_task_path && entry.data.scope !== "subagent")
|
|
199
|
-
.sort((a, b) => String(b.data.last_seen_at || "").localeCompare(String(a.data.last_seen_at || "")));
|
|
200
|
-
for (const entry of candidates) {
|
|
201
|
-
const taskPath = entry.data.active_task_path;
|
|
202
|
-
if (
|
|
203
|
-
existsSync(join(repoRoot, taskPath)) &&
|
|
204
|
-
existsSync(join(repoRoot, taskPath, FILE_TASK_JSON))
|
|
205
|
-
) {
|
|
206
|
-
return toActiveTask(entry.data);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
return null;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// ---------------------------------------------------------------------------
|
|
213
|
-
// Read task status from task.json
|
|
214
|
-
// ---------------------------------------------------------------------------
|
|
215
|
-
function readTaskStatus(repoRoot, taskPath) {
|
|
216
|
-
const taskDir = join(repoRoot, taskPath);
|
|
217
|
-
if (!existsSync(taskDir)) return { status: "missing", missing: true };
|
|
218
|
-
const taskJsonPath = join(taskDir, FILE_TASK_JSON);
|
|
219
|
-
if (!existsSync(taskJsonPath)) return { status: "missing", missing: true };
|
|
220
|
-
try {
|
|
221
|
-
const data = JSON.parse(readFileSync(taskJsonPath, "utf8"));
|
|
222
|
-
return { status: (data.status || "unknown").trim(), missing: false };
|
|
223
|
-
} catch {
|
|
224
|
-
return { status: "unknown", missing: false };
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// ---------------------------------------------------------------------------
|
|
229
|
-
// Load workflow-state breadcrumb texts from templates.md
|
|
230
|
-
// ---------------------------------------------------------------------------
|
|
231
|
-
function loadBreadcrumbs(repoRoot) {
|
|
232
|
-
const templatesFile = join(repoRoot, DIR_WORKFLOW, "spec", "contracts", "workflow-state-templates.md");
|
|
233
|
-
try {
|
|
234
|
-
const text = readFileSync(templatesFile, "utf8");
|
|
235
|
-
return Object.fromEntries(
|
|
236
|
-
[...text.matchAll(TAG_RE)].map((m) => [m[1], m[2].trim()])
|
|
237
|
-
);
|
|
238
|
-
} catch {
|
|
239
|
-
return {};
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// ---------------------------------------------------------------------------
|
|
244
|
-
// Contract digest computation
|
|
245
|
-
// ---------------------------------------------------------------------------
|
|
246
|
-
function loadContractRegistry(repoRoot) {
|
|
247
|
-
const registryFile = join(repoRoot, DIR_WORKFLOW, "spec", "runtime", "contract-registry.json");
|
|
248
|
-
try {
|
|
249
|
-
const data = JSON.parse(readFileSync(registryFile, "utf8"));
|
|
250
|
-
const contracts = data?.contracts;
|
|
251
|
-
return Array.isArray(contracts)
|
|
252
|
-
? contracts.filter((c) => c && typeof c === "object")
|
|
253
|
-
: DEFAULT_CONTRACT_REGISTRY.contracts;
|
|
254
|
-
} catch {
|
|
255
|
-
return DEFAULT_CONTRACT_REGISTRY.contracts;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// Stable-sorted serialization shared by every host implementation of the
|
|
260
|
-
// contract fingerprint (Python sort_keys and the opencode plugin use the
|
|
261
|
-
// same semantics), so the digest stays identical across hosts.
|
|
262
|
-
function stableStringify(value) {
|
|
263
|
-
if (Array.isArray(value)) {
|
|
264
|
-
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
265
|
-
}
|
|
266
|
-
if (value && typeof value === "object") {
|
|
267
|
-
return `{${Object.keys(value)
|
|
268
|
-
.sort()
|
|
269
|
-
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
|
|
270
|
-
.join(",")}}`;
|
|
271
|
-
}
|
|
272
|
-
return JSON.stringify(value);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function contractFingerprint(root, contracts) {
|
|
276
|
-
const hash = createHash("sha256");
|
|
277
|
-
hash.update(stableStringify(contracts));
|
|
278
|
-
for (const contract of contracts) {
|
|
279
|
-
const p = contract?.path;
|
|
280
|
-
if (typeof p !== "string" || !p.trim()) continue;
|
|
281
|
-
try {
|
|
282
|
-
hash.update(readFileSync(join(root, p), "utf8"));
|
|
283
|
-
} catch {
|
|
284
|
-
hash.update(`missing:${p}`);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
return hash.digest("hex").slice(0, 16);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function buildContractDigest(root) {
|
|
291
|
-
const contracts = loadContractRegistry(root);
|
|
292
|
-
const fingerprint = contractFingerprint(root, contracts);
|
|
293
|
-
const lines = [
|
|
294
|
-
`<cowork-runtime host="zcode" adapter="zcode.plugin">`,
|
|
295
|
-
`<contract-digest fingerprint="${fingerprint}">`,
|
|
296
|
-
"policy: repeat fingerprint every hook; read full spec files only before listed actions.",
|
|
297
|
-
];
|
|
298
|
-
for (const contract of contracts) {
|
|
299
|
-
const id = contract?.id;
|
|
300
|
-
const path = contract?.path;
|
|
301
|
-
if (typeof id !== "string" || !id.trim()) continue;
|
|
302
|
-
const pathText = typeof path === "string" && path.trim() ? path : "<missing-path>";
|
|
303
|
-
lines.push(`- ${id}: ${pathText}`);
|
|
304
|
-
const digest = Array.isArray(contract?.digest) ? contract.digest : [];
|
|
305
|
-
for (const item of digest.slice(0, 2)) {
|
|
306
|
-
if (typeof item === "string") lines.push(` digest: ${item}`);
|
|
307
|
-
}
|
|
308
|
-
const readWhen = Array.isArray(contract?.readWhen) ? contract.readWhen : [];
|
|
309
|
-
if (readWhen.length > 0) {
|
|
310
|
-
lines.push(` read_before: ${readWhen.join("; ")}`);
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
lines.push(`</contract-digest>`);
|
|
314
|
-
lines.push(`</cowork-runtime>`);
|
|
315
|
-
return lines.join("\n");
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// ---------------------------------------------------------------------------
|
|
319
|
-
// Check essential project files
|
|
320
|
-
// ---------------------------------------------------------------------------
|
|
321
|
-
function checkEssentialFiles(repoRoot) {
|
|
322
|
-
const missing = [];
|
|
323
|
-
const essentials = [
|
|
324
|
-
["AGENTS.md", "AGENTS.md"],
|
|
325
|
-
[".cowork-flow/config.yaml", ".cowork-flow/config.yaml"],
|
|
326
|
-
[".cowork-flow/run", ".cowork-flow/run"],
|
|
327
|
-
[".cowork-flow/spec/runtime/contract-registry.json", ".cowork-flow/spec/runtime/contract-registry.json"],
|
|
328
|
-
[".cowork-flow/spec/contracts/workflow-state-templates.md", ".cowork-flow/spec/contracts/workflow-state-templates.md"],
|
|
329
|
-
];
|
|
330
|
-
for (const [relPath, label] of essentials) {
|
|
331
|
-
if (!existsSync(join(repoRoot, relPath))) missing.push(label);
|
|
332
|
-
}
|
|
333
|
-
return missing;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// ---------------------------------------------------------------------------
|
|
337
|
-
// Runtime context detection
|
|
338
|
-
// ---------------------------------------------------------------------------
|
|
339
|
-
function extractRuntimeContextId(promptText) {
|
|
340
|
-
if (typeof promptText !== "string") return null;
|
|
341
|
-
RUNTIME_CONTEXT_PROMPT_RE.lastIndex = 0;
|
|
342
|
-
const match = RUNTIME_CONTEXT_PROMPT_RE.exec(promptText);
|
|
343
|
-
return match ? match[1] : null;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function detectDelegatedSubtask(repoRoot, userPrompt) {
|
|
347
|
-
const envId = process.env.COWORK_FLOW_RUNTIME_CONTEXT_ID;
|
|
348
|
-
const contextId = envId || extractRuntimeContextId(userPrompt);
|
|
349
|
-
if (!contextId) return null;
|
|
350
|
-
|
|
351
|
-
const ctxFile = join(repoRoot, DIR_WORKFLOW, ".runtime", "subagents", `${contextId}.json`);
|
|
352
|
-
try {
|
|
353
|
-
const ctx = JSON.parse(readFileSync(ctxFile, "utf8"));
|
|
354
|
-
if (ctx.scope === "subagent" && ctx.status !== "closed") {
|
|
355
|
-
return { contextId, ctx };
|
|
356
|
-
}
|
|
357
|
-
} catch {
|
|
358
|
-
// missing/invalid context
|
|
359
|
-
}
|
|
360
|
-
return { contextId, ctx: null };
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// ---------------------------------------------------------------------------
|
|
364
|
-
// State snapshot (written atomically by lifecycle transitions)
|
|
365
|
-
// ---------------------------------------------------------------------------
|
|
366
|
-
function loadStateSnapshot(repoRoot) {
|
|
367
|
-
try {
|
|
368
|
-
const data = JSON.parse(
|
|
369
|
-
readFileSync(
|
|
370
|
-
join(repoRoot, DIR_WORKFLOW, ".runtime", "state-snapshot.json"),
|
|
371
|
-
"utf8"
|
|
372
|
-
)
|
|
373
|
-
);
|
|
374
|
-
return data && typeof data === "object" ? data : null;
|
|
375
|
-
} catch {
|
|
376
|
-
return null;
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
function resolveBreadcrumbKey(repoRoot, activeTask, status) {
|
|
381
|
-
// A lifecycle-written snapshot describing exactly this task is more
|
|
382
|
-
// authoritative than re-deriving the breadcrumb key from status.
|
|
383
|
-
const snapshot = loadStateSnapshot(repoRoot);
|
|
384
|
-
if (
|
|
385
|
-
snapshot &&
|
|
386
|
-
typeof snapshot.breadcrumbKey === "string" &&
|
|
387
|
-
snapshot.breadcrumbKey.trim() &&
|
|
388
|
-
snapshot.activeTaskPath === activeTask.taskPath &&
|
|
389
|
-
snapshot.status === status
|
|
390
|
-
) {
|
|
391
|
-
return snapshot.breadcrumbKey;
|
|
392
|
-
}
|
|
393
|
-
return status;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
// ---------------------------------------------------------------------------
|
|
397
|
-
// Build context blocks
|
|
398
|
-
// ---------------------------------------------------------------------------
|
|
399
|
-
const DECISION_ANCHOR_STATES = ["planning", "in_progress", "review"];
|
|
400
|
-
|
|
401
|
-
function xmlAttr(value) {
|
|
402
|
-
return String(value)
|
|
403
|
-
.replaceAll("&", "&")
|
|
404
|
-
.replaceAll("<", "<")
|
|
405
|
-
.replaceAll(">", ">")
|
|
406
|
-
.replaceAll('"', """);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
// Mirrors services/fact_view.py parse_decision_anchor (line-level, same
|
|
410
|
-
// section names) so the JS lines inject the same decision facts.
|
|
411
|
-
const STAGE_CONTRACT_STATES = ["in_progress", "review"];
|
|
412
|
-
const STAGE_CONTRACT_SCOPE_LIMIT = 8;
|
|
413
|
-
const STAGE_CONTRACT_SPECS_LIMIT = 4;
|
|
414
|
-
const STAGE_CONTRACT_VERIFY_LIMIT = 3;
|
|
415
|
-
const STAGE_CONTRACT_BUDGET = 1200;
|
|
416
|
-
const GATES_TEXT =
|
|
417
|
-
"Gates: edits outside Scope are review blockers; CLAUDE.md and workflow " +
|
|
418
|
-
"files are protected; spec/ edits may be allowed by review policy; " +
|
|
419
|
-
"scope is agent-mutable (self-declared via task context add)";
|
|
420
|
-
// Delegated subtasks render the parent task's scope as a read-only reference.
|
|
421
|
-
const GATES_TEXT_READONLY =
|
|
422
|
-
"Gates: edits outside Scope are review blockers; CLAUDE.md and workflow " +
|
|
423
|
-
"files are protected; spec/ edits may be allowed by review policy; " +
|
|
424
|
-
"scope is inherited from the parent task (read-only reference)";
|
|
425
|
-
|
|
426
|
-
function parseDecisionAnchor(text) {
|
|
427
|
-
const result = {
|
|
428
|
-
goal: "",
|
|
429
|
-
acceptanceCriteria: [],
|
|
430
|
-
rejectedOptions: [],
|
|
431
|
-
validationCommands: [],
|
|
432
|
-
scopeBoundary: "",
|
|
433
|
-
};
|
|
434
|
-
const goalLines = [];
|
|
435
|
-
let section = null;
|
|
436
|
-
for (const raw of String(text ?? "").split("\n")) {
|
|
437
|
-
const line = raw.trim();
|
|
438
|
-
const heading = line.match(/^##\s*(.+?)\s*$/);
|
|
439
|
-
if (heading) {
|
|
440
|
-
section = ["目标", "验收标准", "被拒方案", "验证命令", "范围边界"].includes(heading[1])
|
|
441
|
-
? heading[1]
|
|
442
|
-
: null;
|
|
443
|
-
continue;
|
|
444
|
-
}
|
|
445
|
-
if (section === "目标") {
|
|
446
|
-
if (line) goalLines.push(line);
|
|
447
|
-
} else if (section === "验收标准") {
|
|
448
|
-
const match = raw.match(/^\s*-\s*\[[ xX]?\]\s*(AC-[A-Za-z0-9-]+)\s*[::]\s*(.+?)\s*$/);
|
|
449
|
-
if (match) result.acceptanceCriteria.push({ id: match[1], text: match[2] });
|
|
450
|
-
} else if (section === "被拒方案") {
|
|
451
|
-
const match = raw.match(/^\s*-\s*\*\*(.+?)\*\*/);
|
|
452
|
-
if (match) result.rejectedOptions.push(match[1].trim());
|
|
453
|
-
} else if (section === "验证命令") {
|
|
454
|
-
let command = line;
|
|
455
|
-
if (command.startsWith("- ")) command = command.slice(2).trim();
|
|
456
|
-
if (command && result.validationCommands.length < 5) {
|
|
457
|
-
result.validationCommands.push(command.slice(0, 120));
|
|
458
|
-
}
|
|
459
|
-
} else if (section === "范围边界") {
|
|
460
|
-
if (line && !result.scopeBoundary) result.scopeBoundary = line.slice(0, 160);
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
result.goal = goalLines.join("\n").slice(0, 300);
|
|
464
|
-
return result;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
function decisionAnchorBlock(repoRoot, taskPath, status) {
|
|
468
|
-
if (!taskPath) return null;
|
|
469
|
-
let effective = status;
|
|
470
|
-
if (status === "delegated_subtask") {
|
|
471
|
-
const taskStatus = readTaskStatus(repoRoot, taskPath);
|
|
472
|
-
if (taskStatus.missing || typeof taskStatus.status !== "string") return null;
|
|
473
|
-
effective = taskStatus.status;
|
|
474
|
-
}
|
|
475
|
-
if (!DECISION_ANCHOR_STATES.includes(effective)) return null;
|
|
476
|
-
let parsed;
|
|
477
|
-
try {
|
|
478
|
-
parsed = parseDecisionAnchor(
|
|
479
|
-
readFileSync(join(repoRoot, taskPath, "decision-anchor.md"), "utf8")
|
|
480
|
-
);
|
|
481
|
-
} catch {
|
|
482
|
-
return null;
|
|
483
|
-
}
|
|
484
|
-
if (!parsed.goal && parsed.acceptanceCriteria.length === 0) return null;
|
|
485
|
-
const lines = [`<decision-anchor task="${xmlAttr(taskPath)}">`];
|
|
486
|
-
if (parsed.goal) {
|
|
487
|
-
lines.push(`Goal: ${parsed.goal.split("\n")[0].slice(0, 160)}`);
|
|
488
|
-
}
|
|
489
|
-
if (parsed.acceptanceCriteria.length > 0) {
|
|
490
|
-
lines.push(
|
|
491
|
-
`Acceptance: ${parsed.acceptanceCriteria
|
|
492
|
-
.slice(0, 8)
|
|
493
|
-
.map((item) => `${item.id} ${item.text.slice(0, 80)}`)
|
|
494
|
-
.join("; ")}`
|
|
495
|
-
);
|
|
496
|
-
}
|
|
497
|
-
if (parsed.rejectedOptions.length > 0) {
|
|
498
|
-
lines.push(`Rejected: ${parsed.rejectedOptions.slice(0, 6).join("; ")}`);
|
|
499
|
-
}
|
|
500
|
-
lines.push("</decision-anchor>");
|
|
501
|
-
return lines.join("\n");
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
function withDecisionAnchor(block, repoRoot, taskPath, status) {
|
|
505
|
-
const anchor = decisionAnchorBlock(repoRoot, taskPath, status);
|
|
506
|
-
return anchor ? `${anchor}\n\n${block}` : block;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
// Mirrors services/fact_view.py file_scope_whitelist + spec_pointer_files +
|
|
510
|
-
// build_stage_contract. Keep the line formatting byte-identical to Python —
|
|
511
|
-
// the cross-host equality test locks it.
|
|
512
|
-
function readImplementEntries(repoRoot, taskPath) {
|
|
513
|
-
let text;
|
|
514
|
-
try {
|
|
515
|
-
text = readFileSync(join(repoRoot, taskPath, "implement.jsonl"), "utf8");
|
|
516
|
-
} catch {
|
|
517
|
-
return [];
|
|
518
|
-
}
|
|
519
|
-
const entries = [];
|
|
520
|
-
for (const line of String(text ?? "").split("\n")) {
|
|
521
|
-
const trimmed = line.trim();
|
|
522
|
-
if (!trimmed) continue;
|
|
523
|
-
try {
|
|
524
|
-
const entry = JSON.parse(trimmed);
|
|
525
|
-
if (entry && typeof entry === "object") entries.push(entry);
|
|
526
|
-
} catch {
|
|
527
|
-
// Skip malformed lines; Python side reports them separately.
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
return entries;
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
// Mirrors the shipped .cowork-flow/spec/runtime/scope-rules.json; loaded from
|
|
534
|
-
// disk at runtime so the rules are a single source across hosts (python +
|
|
535
|
-
// these JS mirrors). Keep both sides in sync (locked by the node matrix test
|
|
536
|
-
// and tests/test_scope_rules.py default-equivalence).
|
|
537
|
-
const DEFAULT_SCOPE_RULES = {
|
|
538
|
-
schemaVersion: 1,
|
|
539
|
-
scopeFilter: {
|
|
540
|
-
allowedTypes: ["file", "planned-file", "deleted-file"],
|
|
541
|
-
wildcardChars: ["*", "?", "[", "]"],
|
|
542
|
-
rejectedSegments: ["", ".", ".."],
|
|
543
|
-
driveLetterPattern: "^[A-Za-z]:",
|
|
544
|
-
trailingSlashRejectedTypes: ["planned-file", "deleted-file"],
|
|
545
|
-
},
|
|
546
|
-
stageContract: { budget: 1200, scopeLimit: 8, specLimit: 4, verifyLimit: 3 },
|
|
547
|
-
};
|
|
548
|
-
|
|
549
|
-
function readScopeRules(repoRoot) {
|
|
550
|
-
try {
|
|
551
|
-
const loaded = JSON.parse(
|
|
552
|
-
readFileSync(
|
|
553
|
-
join(repoRoot, DIR_WORKFLOW, "spec", "runtime", "scope-rules.json"),
|
|
554
|
-
"utf8"
|
|
555
|
-
)
|
|
556
|
-
);
|
|
557
|
-
if (loaded && loaded.schemaVersion === 1) return loaded;
|
|
558
|
-
} catch {
|
|
559
|
-
// Missing or malformed rules file: degrade to the shipped defaults.
|
|
560
|
-
}
|
|
561
|
-
return DEFAULT_SCOPE_RULES;
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
// Port of services/context_paths.py::_is_valid_context_path: the JS side must
|
|
565
|
-
// skip exactly the entries the Python whitelist drops (absolute paths, drive
|
|
566
|
-
// letters, dot segments, wildcards, trailing slash on planned/deleted files).
|
|
567
|
-
// Rules come from scope-rules.json; empty lists are meaningful (never "or"ed
|
|
568
|
-
// away), so nullish coalescing is used for defaults only.
|
|
569
|
-
function isValidScopePath(normalized, raw, type, rules) {
|
|
570
|
-
const sf = (rules || {}).scopeFilter || {};
|
|
571
|
-
const wildcards = sf.wildcardChars ?? ["*", "?", "[", "]"];
|
|
572
|
-
const segments = normalized.split("/");
|
|
573
|
-
const rejected = sf.rejectedSegments ?? ["", ".", ".."];
|
|
574
|
-
if (!normalized || normalized.startsWith("/")) return false;
|
|
575
|
-
if (new RegExp(sf.driveLetterPattern ?? "^[A-Za-z]:").test(normalized)) return false;
|
|
576
|
-
if (segments.some((segment) => rejected.includes(segment))) return false;
|
|
577
|
-
if (wildcards.some((character) => normalized.includes(character))) return false;
|
|
578
|
-
const trailing = sf.trailingSlashRejectedTypes ?? ["planned-file", "deleted-file"];
|
|
579
|
-
if (trailing.includes(type) && /[\\/]$/.test(raw)) return false;
|
|
580
|
-
return true;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
// Mirrors services/fact_view.py file_scope_whitelist + spec_pointer_files:
|
|
584
|
-
// directory entries authorize nothing, non-canonical entries are dropped,
|
|
585
|
-
// and the whitelist is the single source for both the Scope row and the
|
|
586
|
-
// per-edit warning check.
|
|
587
|
-
function buildScopeWhitelist(entries, rules) {
|
|
588
|
-
const whitelist = [];
|
|
589
|
-
const specFiles = [];
|
|
590
|
-
const sf = (rules || {}).scopeFilter || {};
|
|
591
|
-
const allowedTypes = sf.allowedTypes ?? ["file", "planned-file", "deleted-file"];
|
|
592
|
-
for (const entry of entries) {
|
|
593
|
-
const file = typeof entry.file === "string" ? entry.file : "";
|
|
594
|
-
let normalized = file.replaceAll("\\", "/");
|
|
595
|
-
while (normalized.startsWith("./")) {
|
|
596
|
-
normalized = normalized.slice(2);
|
|
597
|
-
}
|
|
598
|
-
const type = typeof entry.type === "string" && entry.type ? entry.type : "file";
|
|
599
|
-
if (type === "directory") continue;
|
|
600
|
-
if (!allowedTypes.includes(type)) continue;
|
|
601
|
-
if (!isValidScopePath(normalized, file, type, rules)) continue;
|
|
602
|
-
whitelist.push({ file: normalized, type });
|
|
603
|
-
if (normalized.startsWith(".cowork-flow/spec/")) specFiles.push(normalized);
|
|
604
|
-
}
|
|
605
|
-
return { whitelist, specFiles };
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
function scopeRow(entries, total, suffix) {
|
|
609
|
-
const text = entries.length > 0 ? entries.join("; ") : "(empty)";
|
|
610
|
-
const more = total - entries.length;
|
|
611
|
-
const extra = more > 0 ? ` (+${more} more in implement.jsonl)` : "";
|
|
612
|
-
return `Scope: ${text}${extra} ${suffix}`;
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
// Mirrors services/fact_view.py spec_digest_items: the h2 heading tree of
|
|
616
|
-
// each bound spec, injected as the Specs-row entry-name index. Format is
|
|
617
|
-
// pinned byte-for-byte with the Python source (contract fingerprint tests):
|
|
618
|
-
// path(h2a/h2b), at most 6 headings, each truncated to 24 chars after
|
|
619
|
-
// stripping "();" characters; missing files stay unannotated.
|
|
620
|
-
const SPEC_DIGEST_MAX_HEADINGS = 6;
|
|
621
|
-
const SPEC_DIGEST_MAX_CHARS = 24;
|
|
622
|
-
|
|
623
|
-
function specHeadingDigest(specFile, repoRoot) {
|
|
624
|
-
let text;
|
|
625
|
-
try {
|
|
626
|
-
text = readFileSync(join(repoRoot, specFile), "utf8");
|
|
627
|
-
} catch {
|
|
628
|
-
return null;
|
|
629
|
-
}
|
|
630
|
-
const headings = [];
|
|
631
|
-
for (const line of text.split(/\r?\n/)) {
|
|
632
|
-
if (line.startsWith("## ") && !line.startsWith("###")) {
|
|
633
|
-
const cleaned = line
|
|
634
|
-
.slice(3)
|
|
635
|
-
.replace(/[();]/g, "")
|
|
636
|
-
.trim()
|
|
637
|
-
.slice(0, SPEC_DIGEST_MAX_CHARS);
|
|
638
|
-
if (cleaned) {
|
|
639
|
-
headings.push(cleaned);
|
|
640
|
-
if (headings.length >= SPEC_DIGEST_MAX_HEADINGS) break;
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
return headings.length > 0 ? headings.join("/") : null;
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
function specDigestItems(repoRoot, specFiles) {
|
|
648
|
-
const map = {};
|
|
649
|
-
for (const item of specFiles) {
|
|
650
|
-
const digest = specHeadingDigest(item, repoRoot);
|
|
651
|
-
if (digest) map[item] = digest;
|
|
652
|
-
}
|
|
653
|
-
return map;
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
// Mirrors services/fact_view.py::_fit_stage_contract: degrade an over-budget
|
|
657
|
-
// block without ever emitting a malformed one — the closing tag and the guard
|
|
658
|
-
// rows (Scope/Gates) always survive. Keep the row-role rules and drop order
|
|
659
|
-
// identical with the Python side. budget comes from scope-rules.json.
|
|
660
|
-
function fitStageContract(lines, scopeEntries, scopeTotal, mutable, budget = STAGE_CONTRACT_BUDGET) {
|
|
661
|
-
if (lines.join("\n").length <= budget) return lines;
|
|
662
|
-
let removable = [];
|
|
663
|
-
for (let i = 1; i < lines.length - 1; i++) {
|
|
664
|
-
if (!lines[i].startsWith("Scope:") && !lines[i].startsWith("Gates:")) {
|
|
665
|
-
removable.push(i);
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
for (let i = removable.length - 1; i >= 0; i--) {
|
|
669
|
-
const reduced = lines.filter((_, j) => j !== removable[i]);
|
|
670
|
-
if (reduced.join("\n").length <= budget) return reduced;
|
|
671
|
-
lines = reduced;
|
|
672
|
-
}
|
|
673
|
-
const suffix = mutable ? "[agent-mutable]" : "[read-only]";
|
|
674
|
-
let pool = scopeEntries.slice();
|
|
675
|
-
while (pool.length > 1) {
|
|
676
|
-
pool = pool.slice(0, -1);
|
|
677
|
-
const candidate = lines.map((line) =>
|
|
678
|
-
line.startsWith("Scope:") ? scopeRow(pool, scopeTotal, suffix) : line
|
|
679
|
-
);
|
|
680
|
-
if (candidate.join("\n").length <= budget) return candidate;
|
|
681
|
-
lines = candidate;
|
|
682
|
-
}
|
|
683
|
-
if (lines.join("\n").length <= budget) return lines;
|
|
684
|
-
const closing = lines[lines.length - 1];
|
|
685
|
-
// The final join inserts one newline between the cut body and the closing
|
|
686
|
-
// tag — reserve it so the block stays within budget byte-for-byte.
|
|
687
|
-
const room = budget - closing.length - 1;
|
|
688
|
-
const body = lines.slice(0, -1).join("\n");
|
|
689
|
-
if (body.length <= room) return lines;
|
|
690
|
-
const head = body.slice(0, room).replace(/\s+$/, "");
|
|
691
|
-
if (!head) return lines;
|
|
692
|
-
return [head, closing];
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
function stageContractBlock(repoRoot, taskPath, status, readonly = false) {
|
|
696
|
-
if (!taskPath) return null;
|
|
697
|
-
let effective = status;
|
|
698
|
-
if (status === "delegated_subtask") {
|
|
699
|
-
const taskStatus = readTaskStatus(repoRoot, taskPath);
|
|
700
|
-
if (taskStatus.missing || typeof taskStatus.status !== "string") return null;
|
|
701
|
-
effective = taskStatus.status;
|
|
702
|
-
}
|
|
703
|
-
if (!STAGE_CONTRACT_STATES.includes(effective)) return null;
|
|
704
|
-
|
|
705
|
-
const rules = readScopeRules(repoRoot);
|
|
706
|
-
const { whitelist, specFiles } = buildScopeWhitelist(readImplementEntries(repoRoot, taskPath), rules);
|
|
707
|
-
const stage = rules.stageContract || {};
|
|
708
|
-
const scopeLimit = stage.scopeLimit ?? STAGE_CONTRACT_SCOPE_LIMIT;
|
|
709
|
-
const specLimit = stage.specLimit ?? STAGE_CONTRACT_SPECS_LIMIT;
|
|
710
|
-
const verifyLimit = stage.verifyLimit ?? STAGE_CONTRACT_VERIFY_LIMIT;
|
|
711
|
-
const budget = stage.budget ?? STAGE_CONTRACT_BUDGET;
|
|
712
|
-
|
|
713
|
-
let parsed = {
|
|
714
|
-
goal: "",
|
|
715
|
-
acceptanceCriteria: [],
|
|
716
|
-
rejectedOptions: [],
|
|
717
|
-
validationCommands: [],
|
|
718
|
-
scopeBoundary: "",
|
|
719
|
-
};
|
|
720
|
-
try {
|
|
721
|
-
parsed = parseDecisionAnchor(
|
|
722
|
-
readFileSync(join(repoRoot, taskPath, "decision-anchor.md"), "utf8")
|
|
723
|
-
);
|
|
724
|
-
} catch {
|
|
725
|
-
// Absent anchor: the contract still carries scope/gates.
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
const suffix = readonly ? "[read-only]" : "[agent-mutable]";
|
|
729
|
-
const lines = [`<stage-contract task="${xmlAttr(taskPath)}">`];
|
|
730
|
-
lines.push(scopeRow(
|
|
731
|
-
whitelist.slice(0, scopeLimit).map((e) => e.file),
|
|
732
|
-
whitelist.length,
|
|
733
|
-
suffix
|
|
734
|
-
));
|
|
735
|
-
if (specFiles.length > 0) {
|
|
736
|
-
const specItems = specFiles.slice(0, specLimit);
|
|
737
|
-
const digestMap = specDigestItems(repoRoot, specFiles);
|
|
738
|
-
const parts = specItems.map((item) => {
|
|
739
|
-
const digest = digestMap[item];
|
|
740
|
-
return digest ? `${item}(${digest})` : item;
|
|
741
|
-
});
|
|
742
|
-
let specsText = parts.join("; ");
|
|
743
|
-
const specMore = specFiles.length - specItems.length;
|
|
744
|
-
if (specMore > 0) specsText += ` (+${specMore} more)`;
|
|
745
|
-
lines.push(`Specs: ${specsText}`);
|
|
746
|
-
}
|
|
747
|
-
lines.push(readonly ? GATES_TEXT_READONLY : GATES_TEXT);
|
|
748
|
-
if (parsed.validationCommands.length > 0) {
|
|
749
|
-
lines.push(
|
|
750
|
-
"Verify: " +
|
|
751
|
-
parsed.validationCommands.slice(0, verifyLimit).join("; ")
|
|
752
|
-
);
|
|
753
|
-
}
|
|
754
|
-
lines.push("</stage-contract>");
|
|
755
|
-
return fitStageContract(
|
|
756
|
-
lines,
|
|
757
|
-
whitelist.slice(0, scopeLimit).map((e) => e.file),
|
|
758
|
-
whitelist.length,
|
|
759
|
-
!readonly,
|
|
760
|
-
budget
|
|
761
|
-
).join("\n");
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
function withStageFacts(block, repoRoot, taskPath, status, readonly = false) {
|
|
765
|
-
const anchor = decisionAnchorBlock(repoRoot, taskPath, status);
|
|
766
|
-
const contract = stageContractBlock(repoRoot, taskPath, status, readonly);
|
|
767
|
-
const parts = [anchor, contract].filter(Boolean);
|
|
768
|
-
return parts.length > 0 ? `${parts.join("\n\n")}\n\n${block}` : block;
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
function normalizeScopePath(value) {
|
|
772
|
-
let normalized = String(value ?? "").trim().replaceAll("\\", "/");
|
|
773
|
-
while (normalized.startsWith("./")) {
|
|
774
|
-
normalized = normalized.slice(2);
|
|
775
|
-
}
|
|
776
|
-
return normalized;
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
// Per-edit out-of-scope warning (zcode-only capability: editScopeWarning).
|
|
780
|
-
// Short path by design — returns at most one warning line, or "" when the
|
|
781
|
-
// edit is in scope, there is no active in_progress/review task, or the
|
|
782
|
-
// session is a subagent (delegated sessions stay silent, per the capability
|
|
783
|
-
// matrix fallback to the static stage-contract preview).
|
|
784
|
-
function editScopeWarning(input, filePath) {
|
|
785
|
-
const root = findProjectRoot(input);
|
|
786
|
-
if (!root) return "";
|
|
787
|
-
const activeTask = readActiveTask(root, resolveSessionKey(input));
|
|
788
|
-
if (!activeTask || !activeTask.taskPath) return "";
|
|
789
|
-
if (activeTask.scope === "subagent") return "";
|
|
790
|
-
const { status, missing } = readTaskStatus(root, activeTask.taskPath);
|
|
791
|
-
if (missing || !STAGE_CONTRACT_STATES.includes(status)) return "";
|
|
792
|
-
|
|
793
|
-
const target = normalizeScopePath(filePath);
|
|
794
|
-
const { whitelist } = buildScopeWhitelist(readImplementEntries(root, activeTask.taskPath), readScopeRules(root));
|
|
795
|
-
const inScope = whitelist.some((entry) => entry.file === target);
|
|
796
|
-
if (inScope) return "";
|
|
797
|
-
|
|
798
|
-
const payload = {
|
|
799
|
-
hookSpecificOutput: {
|
|
800
|
-
hookEventName: "PostToolUse",
|
|
801
|
-
additionalContext:
|
|
802
|
-
`⚠️ ${target} is outside the task's declared scope. ` +
|
|
803
|
-
"If intended, add it with `task context add` (agent-mutable) or revert the edit.",
|
|
804
|
-
},
|
|
805
|
-
};
|
|
806
|
-
return JSON.stringify(payload, null, 2);
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
// Editor-phase spec-check short path (mirrors workflow_state_hook.py
|
|
810
|
-
// spec_edit_warning): executes the single-source spec_check.py CLI in
|
|
811
|
-
// throttled single-line mode. Same silence rules as editScopeWarning —
|
|
812
|
-
// only active in_progress/review main sessions get advisories. The 3.8s
|
|
813
|
-
// spawn ceiling keeps the whole hook inside its 5s budget (the check
|
|
814
|
-
// itself clamps to 3s; throttle state lives in .cowork-flow/.runtime so
|
|
815
|
-
// edit storms cannot multiply command costs).
|
|
816
|
-
function mergedEditWarning(input, filePath) {
|
|
817
|
-
const scopeJson = editScopeWarning(input, filePath);
|
|
818
|
-
const specLine = specEditWarningLine(input, filePath);
|
|
819
|
-
if (!specLine) return scopeJson;
|
|
820
|
-
let context = "";
|
|
821
|
-
if (scopeJson) {
|
|
822
|
-
try {
|
|
823
|
-
context = JSON.parse(scopeJson)?.hookSpecificOutput?.additionalContext || "";
|
|
824
|
-
} catch {
|
|
825
|
-
context = "";
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
context = context ? `${context}\n${specLine}` : specLine;
|
|
829
|
-
return JSON.stringify({
|
|
830
|
-
hookSpecificOutput: {
|
|
831
|
-
hookEventName: "PostToolUse",
|
|
832
|
-
additionalContext: context,
|
|
833
|
-
},
|
|
834
|
-
});
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
function specEditWarningLine(input, filePath) {
|
|
838
|
-
const root = findProjectRoot(input);
|
|
839
|
-
if (!root) return "";
|
|
840
|
-
const activeTask = readActiveTask(root, resolveSessionKey(input));
|
|
841
|
-
if (!activeTask || !activeTask.taskPath) return "";
|
|
842
|
-
if (activeTask.scope === "subagent") return "";
|
|
843
|
-
const { status, missing } = readTaskStatus(root, activeTask.taskPath);
|
|
844
|
-
if (missing || !STAGE_CONTRACT_STATES.includes(status)) return "";
|
|
845
|
-
return spawnSpecCheckEdit(root, filePath);
|
|
846
|
-
}
|
|
847
|
-
|
|
848
72
|
function pythonCandidates() {
|
|
849
73
|
const list = [];
|
|
850
74
|
const env = (process.env.COWORK_FLOW_PYTHON || "").trim();
|
|
@@ -854,189 +78,89 @@ function pythonCandidates() {
|
|
|
854
78
|
return list;
|
|
855
79
|
}
|
|
856
80
|
|
|
857
|
-
function
|
|
858
|
-
const
|
|
859
|
-
|
|
860
|
-
|
|
81
|
+
function resolveInjectScript(parsedInput) {
|
|
82
|
+
const root = findProjectRoot(parsedInput);
|
|
83
|
+
if (root) {
|
|
84
|
+
const projectInject = join(
|
|
85
|
+
root,
|
|
86
|
+
DIR_WORKFLOW,
|
|
87
|
+
"scripts",
|
|
88
|
+
"adapters",
|
|
89
|
+
"host",
|
|
90
|
+
"inject.py"
|
|
91
|
+
);
|
|
92
|
+
if (existsSync(projectInject)) return projectInject;
|
|
93
|
+
}
|
|
94
|
+
// Plugin-cache runtime copy: renders the not-initialized context for
|
|
95
|
+
// directories outside any cowork-flow project.
|
|
96
|
+
const cacheInject = join(
|
|
97
|
+
__dirname,
|
|
98
|
+
"runtime",
|
|
861
99
|
"scripts",
|
|
862
100
|
"adapters",
|
|
863
|
-
"
|
|
864
|
-
"
|
|
865
|
-
);
|
|
866
|
-
for (const candidate of pythonCandidates()) {
|
|
867
|
-
let result;
|
|
868
|
-
try {
|
|
869
|
-
result = spawnSync(candidate[0], [...candidate.slice(1), script, "--phase", "edit", "--file", filePath, "--throttled"], {
|
|
870
|
-
cwd: root,
|
|
871
|
-
timeout: 3800,
|
|
872
|
-
encoding: "utf8",
|
|
873
|
-
});
|
|
874
|
-
} catch {
|
|
875
|
-
continue;
|
|
876
|
-
}
|
|
877
|
-
if (result.error || result.status === null) continue;
|
|
878
|
-
return (result.stdout || "").split(/\r?\n/)[0].trim();
|
|
879
|
-
}
|
|
880
|
-
return "";
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
function buildContext(repoRoot, activeTask, breadcrumbs) { const fallback = "Run ./.cowork-flow/run task next for the current workflow step.";
|
|
884
|
-
|
|
885
|
-
if (!activeTask) {
|
|
886
|
-
const body = breadcrumbs.no_task || fallback;
|
|
887
|
-
const hints = formatRebindHints(repoRoot);
|
|
888
|
-
return `<workflow-state status="no_task" source="task next / ${DIR_WORKFLOW}/spec">
|
|
889
|
-
${body}${hints ? `\n${hints}` : ""}
|
|
890
|
-
</workflow-state>`;
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
const { status, missing } = readTaskStatus(repoRoot, activeTask.taskPath);
|
|
894
|
-
const readonly = activeTask.scope === "subagent";
|
|
895
|
-
|
|
896
|
-
if (missing) {
|
|
897
|
-
const hints = formatRebindHints(repoRoot);
|
|
898
|
-
return `<workflow-state status="no_task" source="runtime-session">
|
|
899
|
-
Session 指向的任务目录不存在(${activeTask.taskPath})。
|
|
900
|
-
当前项目无有效任务。请运行 ./.cowork-flow/run task next --run --title "<title>" --slug <task-name> --assignee <name> 创建新任务。${hints ? `\n${hints}` : ""}
|
|
901
|
-
</workflow-state>`;
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
const breadcrumbKey = resolveBreadcrumbKey(repoRoot, activeTask, status);
|
|
905
|
-
const body = breadcrumbs[breadcrumbKey] || breadcrumbs[status] || fallback;
|
|
906
|
-
return withStageFacts(
|
|
907
|
-
`<workflow-state task="${xmlAttr(activeTask.taskPath)}" status="${xmlAttr(status)}" source="runtime-session">
|
|
908
|
-
${body}
|
|
909
|
-
</workflow-state>`,
|
|
910
|
-
repoRoot,
|
|
911
|
-
activeTask.taskPath,
|
|
912
|
-
status,
|
|
913
|
-
readonly
|
|
914
|
-
);
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
function buildDelegatedSubtask(contextId, ctx, repoRoot) {
|
|
918
|
-
const taskPath = ctx?.task_dir || "unknown";
|
|
919
|
-
const lines = [
|
|
920
|
-
`<workflow-state task="${xmlAttr(taskPath)}" status="delegated_subtask" source="runtime-context:${xmlAttr(contextId)}">`,
|
|
921
|
-
`Runtime context: ${contextId}`,
|
|
922
|
-
`Agent: ${ctx?.agent_type || "unknown"}`,
|
|
923
|
-
`Do not run start/resume/task start/archive/commit/spawn.`,
|
|
924
|
-
];
|
|
925
|
-
const goal = ctx?.assignment?.goal;
|
|
926
|
-
if (typeof goal === "string" && goal.trim()) {
|
|
927
|
-
lines.push(`Goal: ${goal.trim()}`);
|
|
928
|
-
}
|
|
929
|
-
lines.push(`</workflow-state>`);
|
|
930
|
-
return withStageFacts(
|
|
931
|
-
lines.join("\n"),
|
|
932
|
-
repoRoot,
|
|
933
|
-
taskPath,
|
|
934
|
-
"delegated_subtask",
|
|
935
|
-
true
|
|
101
|
+
"host",
|
|
102
|
+
"inject.py"
|
|
936
103
|
);
|
|
104
|
+
if (existsSync(cacheInject)) return cacheInject;
|
|
105
|
+
return null;
|
|
937
106
|
}
|
|
938
107
|
|
|
939
|
-
// ---------------------------------------------------------------------------
|
|
940
|
-
// Main
|
|
941
|
-
// ---------------------------------------------------------------------------
|
|
942
108
|
function main() {
|
|
943
|
-
if (
|
|
109
|
+
if (
|
|
110
|
+
process.env.COWORK_FLOW_HOOKS === "0" ||
|
|
111
|
+
process.env.COWORK_FLOW_DISABLE_HOOKS === "1"
|
|
112
|
+
) {
|
|
944
113
|
process.exit(0);
|
|
945
114
|
}
|
|
946
115
|
|
|
947
|
-
const
|
|
116
|
+
const { raw, parsed } = readHookInput();
|
|
948
117
|
|
|
949
|
-
//
|
|
950
|
-
//
|
|
118
|
+
// Cheap first: a PostToolUse Bash call only matters when it is a workflow
|
|
119
|
+
// lifecycle command (mid-turn state refresh); every other Bash call exits
|
|
120
|
+
// without spawning Python.
|
|
951
121
|
if (
|
|
952
|
-
typeof
|
|
953
|
-
|
|
122
|
+
typeof parsed?.hook_event_name === "string" &&
|
|
123
|
+
parsed.hook_event_name.trim() === "PostToolUse" &&
|
|
124
|
+
String(parsed?.tool_name || "") === "Bash"
|
|
954
125
|
) {
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
if (
|
|
960
|
-
["Edit", "Write", "MultiEdit"].includes(toolName) &&
|
|
961
|
-
typeof editedPath === "string" &&
|
|
962
|
-
editedPath.trim()
|
|
963
|
-
) {
|
|
964
|
-
process.stdout.write(mergedEditWarning(input, editedPath.trim()));
|
|
965
|
-
process.exit(0);
|
|
966
|
-
}
|
|
967
|
-
// Normalize separators so Windows run.cmd invocations still match; the
|
|
968
|
-
// bare-run fallback covers `cd .cowork-flow && ./run task ...` forms.
|
|
969
|
-
const command = String(input?.tool_input?.command || "").replaceAll("\\", "/");
|
|
126
|
+
const command = String(parsed?.tool_input?.command || "").replaceAll(
|
|
127
|
+
"\\",
|
|
128
|
+
"/"
|
|
129
|
+
);
|
|
970
130
|
if (
|
|
971
|
-
!command.includes(
|
|
972
|
-
|
|
131
|
+
!command.includes(`${DIR_WORKFLOW}/run`) &&
|
|
132
|
+
!LIFECYCLE_BASH_RE.test(command)
|
|
973
133
|
) {
|
|
974
134
|
process.exit(0);
|
|
975
135
|
}
|
|
976
136
|
}
|
|
977
137
|
|
|
978
|
-
const
|
|
979
|
-
|
|
980
|
-
const repoRoot = findProjectRoot(input);
|
|
981
|
-
|
|
982
|
-
let context;
|
|
983
|
-
|
|
984
|
-
const envDir = process.env.ZCODE_PROJECT_DIR || process.env.CLAUDE_PROJECT_DIR;
|
|
985
|
-
const effectiveRoot = repoRoot || findWorkflowRoot(envDir);
|
|
986
|
-
|
|
987
|
-
if (!effectiveRoot) {
|
|
988
|
-
context = `<workflow-state>
|
|
989
|
-
Status: not_initialized
|
|
990
|
-
Source: cowork-flow-plugin
|
|
991
|
-
⚠️ 项目未初始化 cowork-flow 工作流。
|
|
138
|
+
const injectScript = resolveInjectScript(parsed);
|
|
139
|
+
if (!injectScript) process.exit(0);
|
|
992
140
|
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
// Inject contract digest: the full block rides session start (startup,
|
|
1010
|
-
// clear, compact re-runs it); later prompts only repeat the fingerprint
|
|
1011
|
-
// so long sessions don't pay for the full listing on every message.
|
|
1012
|
-
const rootForDigest = effectiveRoot || envDir || input.cwd;
|
|
1013
|
-
if (rootForDigest) {
|
|
1014
|
-
// Use the normalized event so legacy hosts whose only signal is the
|
|
1015
|
-
// plugin-root env (detectEventName guesses SessionStart) keep receiving
|
|
1016
|
-
// the full block they got before slimming.
|
|
1017
|
-
if (event === "SessionStart") {
|
|
1018
|
-
context = `${buildContractDigest(rootForDigest)}\n\n${context}`;
|
|
1019
|
-
} else {
|
|
1020
|
-
const contracts = loadContractRegistry(rootForDigest);
|
|
1021
|
-
const fingerprint = contractFingerprint(rootForDigest, contracts);
|
|
1022
|
-
context = `<contract-fingerprint value="${fingerprint}"/>\n\n${context}`;
|
|
141
|
+
for (const candidate of pythonCandidates()) {
|
|
142
|
+
let result;
|
|
143
|
+
try {
|
|
144
|
+
result = spawnSync(
|
|
145
|
+
candidate[0],
|
|
146
|
+
[...candidate.slice(1), injectScript, "--host", "zcode"],
|
|
147
|
+
{
|
|
148
|
+
input: raw,
|
|
149
|
+
timeout: SPAWN_TIMEOUT_MS,
|
|
150
|
+
encoding: "utf8",
|
|
151
|
+
windowsHide: true,
|
|
152
|
+
}
|
|
153
|
+
);
|
|
154
|
+
} catch {
|
|
155
|
+
continue;
|
|
1023
156
|
}
|
|
157
|
+
if (result.error || result.status === null) continue;
|
|
158
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
159
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
160
|
+
process.exit(result.status);
|
|
1024
161
|
}
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
if (missingFiles.length > 0) {
|
|
1028
|
-
context += `
|
|
1029
|
-
|
|
1030
|
-
⚠️ 缺少必要文件:${missingFiles.join(", ")}。
|
|
1031
|
-
请立即创建这些文件以保障工作流正常运行。`;
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
const payload =
|
|
1035
|
-
format === "cursor"
|
|
1036
|
-
? { additional_context: context }
|
|
1037
|
-
: { hookSpecificOutput: { hookEventName: event, additionalContext: context } };
|
|
1038
|
-
|
|
1039
|
-
process.stdout.write(JSON.stringify(payload, null, 2));
|
|
162
|
+
// No interpreter could run the entry: stay silent rather than break the
|
|
163
|
+
// host's event stream (fail-open, same contract as the spec-edit path).
|
|
1040
164
|
process.exit(0);
|
|
1041
165
|
}
|
|
1042
166
|
|