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,795 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Self-check for inject-context.js
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "fs";
|
|
7
|
-
import { join, dirname } from "path";
|
|
8
|
-
import { spawnSync } from "child_process";
|
|
9
|
-
import assert from "assert/strict";
|
|
10
|
-
|
|
11
|
-
const tmpRoot = join(import.meta.dirname, ".selfcheck-tmp");
|
|
12
|
-
const hookScript = join(import.meta.dirname, "inject-context.js");
|
|
13
|
-
|
|
14
|
-
let passed = 0;
|
|
15
|
-
let failed = 0;
|
|
16
|
-
|
|
17
|
-
function test(name, fn) {
|
|
18
|
-
try {
|
|
19
|
-
fn();
|
|
20
|
-
console.log(` ✓ ${name}`);
|
|
21
|
-
passed++;
|
|
22
|
-
} catch (err) {
|
|
23
|
-
console.error(` ✗ ${name}`);
|
|
24
|
-
console.error(` ${err.message}`);
|
|
25
|
-
failed++;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function runHook(env = {}, stdin) {
|
|
30
|
-
const result = spawnSync(process.execPath, [hookScript], {
|
|
31
|
-
env: { ...process.env, ...env },
|
|
32
|
-
encoding: "utf8",
|
|
33
|
-
cwd: import.meta.dirname,
|
|
34
|
-
input: stdin,
|
|
35
|
-
});
|
|
36
|
-
if (result.status !== 0) {
|
|
37
|
-
const error = new Error(result.stderr || `hook exited with ${result.status}`);
|
|
38
|
-
error.status = result.status;
|
|
39
|
-
throw error;
|
|
40
|
-
}
|
|
41
|
-
return result.stdout;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function writeProjectFile(repoRoot, relativePath, content) {
|
|
45
|
-
const dest = join(repoRoot, relativePath);
|
|
46
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
47
|
-
writeFileSync(dest, content, "utf8");
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function writeMinimalWorkflowSpec(repoRoot) {
|
|
51
|
-
writeProjectFile(repoRoot, "AGENTS.md", "# Test Project\n");
|
|
52
|
-
writeProjectFile(repoRoot, ".cowork-flow/config.yaml", "codex:\n dispatch_mode: inline\n");
|
|
53
|
-
writeProjectFile(repoRoot, ".cowork-flow/run", "#!/usr/bin/env sh\n");
|
|
54
|
-
writeProjectFile(
|
|
55
|
-
repoRoot,
|
|
56
|
-
".cowork-flow/spec/runtime/contract-registry.json",
|
|
57
|
-
JSON.stringify({
|
|
58
|
-
schemaVersion: 1,
|
|
59
|
-
contracts: [
|
|
60
|
-
{
|
|
61
|
-
id: "RUNTIME_CONTEXT_DISPATCH_V2",
|
|
62
|
-
path: ".cowork-flow/spec/contracts/subagent-dispatch.md",
|
|
63
|
-
digest: [
|
|
64
|
-
"Formal subagent work is keyed by cowork_runtime_context_id.",
|
|
65
|
-
"Explicit shim bind records bound_context_key before formal output is accepted.",
|
|
66
|
-
],
|
|
67
|
-
readWhen: ["before formal subagent dispatch"],
|
|
68
|
-
},
|
|
69
|
-
],
|
|
70
|
-
}, null, 2) + "\n"
|
|
71
|
-
);
|
|
72
|
-
writeProjectFile(
|
|
73
|
-
repoRoot,
|
|
74
|
-
".cowork-flow/spec/contracts/subagent-dispatch.md",
|
|
75
|
-
"# Subagent Dispatch\n\nFormal subagent work is keyed by cowork_runtime_context_id.\n"
|
|
76
|
-
);
|
|
77
|
-
writeProjectFile(
|
|
78
|
-
repoRoot,
|
|
79
|
-
".cowork-flow/spec/contracts/workflow-state-templates.md",
|
|
80
|
-
[
|
|
81
|
-
"[workflow-state:no_task]",
|
|
82
|
-
"STOP - no active task. Use task next --run to create or start a task.",
|
|
83
|
-
"[/workflow-state:no_task]",
|
|
84
|
-
"",
|
|
85
|
-
"[workflow-state:in_progress]",
|
|
86
|
-
"活动任务正在执行。",
|
|
87
|
-
"[/workflow-state:in_progress]",
|
|
88
|
-
].join("\n")
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function setupFakeProject() {
|
|
93
|
-
const coworkDir = join(tmpRoot, ".cowork-flow");
|
|
94
|
-
const sessionsDir = join(coworkDir, ".runtime", "sessions");
|
|
95
|
-
const taskDir = join(coworkDir, "tasks", "07-02-test-task");
|
|
96
|
-
mkdirSync(sessionsDir, { recursive: true });
|
|
97
|
-
mkdirSync(taskDir, { recursive: true });
|
|
98
|
-
|
|
99
|
-
const sessData = JSON.stringify({ active_task_path: ".cowork-flow/tasks/07-02-test-task", scope: "main", platform: "claude", last_seen_at: "2026-07-02T13:00:00Z" });
|
|
100
|
-
writeFileSync(join(sessionsDir, "claude_abc.json"), sessData, "utf8");
|
|
101
|
-
const taskData = JSON.stringify({ title: "Test Task", status: "in_progress" });
|
|
102
|
-
writeFileSync(join(taskDir, "task.json"), taskData, "utf8");
|
|
103
|
-
|
|
104
|
-
// Install minimal project specs so breadcrumb parsing can be tested without plugin scaffold.
|
|
105
|
-
writeMinimalWorkflowSpec(tmpRoot);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function cleanup() {
|
|
109
|
-
rmSync(tmpRoot, { recursive: true, force: true });
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
console.log("inject-context self-check\n");
|
|
113
|
-
|
|
114
|
-
try {
|
|
115
|
-
setupFakeProject();
|
|
116
|
-
|
|
117
|
-
test("outputs valid JSON to stdout via env var", () => {
|
|
118
|
-
const result = runHook({ ZCODE_PROJECT_DIR: tmpRoot });
|
|
119
|
-
const parsed = JSON.parse(result);
|
|
120
|
-
assert.ok(parsed.hookSpecificOutput, "should have hookSpecificOutput");
|
|
121
|
-
assert.ok(parsed.hookSpecificOutput.additionalContext, "should have additionalContext");
|
|
122
|
-
assert.equal(parsed.hookSpecificOutput.hookEventName, "UserPromptSubmit");
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
test("reads active task and outputs correct Task/Status", () => {
|
|
126
|
-
const result = runHook({ ZCODE_PROJECT_DIR: tmpRoot });
|
|
127
|
-
const parsed = JSON.parse(result);
|
|
128
|
-
const ctx = parsed.hookSpecificOutput.additionalContext;
|
|
129
|
-
assert.match(ctx, /task="\.cowork-flow\/tasks\/07-02-test-task"/);
|
|
130
|
-
assert.match(ctx, /status="in_progress"/);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
test("reports not_initialized without scaffolding when ZCODE_PROJECT_DIR has none", () => {
|
|
134
|
-
const scaffoldTmp = join(import.meta.dirname, ".scaffold-test");
|
|
135
|
-
if (existsSync(scaffoldTmp)) rmSync(scaffoldTmp, { recursive: true, force: true });
|
|
136
|
-
const emptyDir = join(scaffoldTmp, "fresh-proj");
|
|
137
|
-
mkdirSync(emptyDir, { recursive: true });
|
|
138
|
-
const result = runHook({ ZCODE_PROJECT_DIR: emptyDir });
|
|
139
|
-
const parsed = JSON.parse(result);
|
|
140
|
-
const ctx = parsed.hookSpecificOutput.additionalContext;
|
|
141
|
-
assert.match(ctx, /not_initialized/);
|
|
142
|
-
assert.match(ctx, /hook 不会在注入阶段创建或复制项目文件/);
|
|
143
|
-
assert.ok(!existsSync(join(emptyDir, ".cowork-flow")), ".cowork-flow should not be created");
|
|
144
|
-
assert.ok(!existsSync(join(emptyDir, "AGENTS.md")), "AGENTS.md should not be created");
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
test("workflow-state block is well-formed XML", () => {
|
|
148
|
-
const result = runHook({ ZCODE_PROJECT_DIR: tmpRoot });
|
|
149
|
-
const parsed = JSON.parse(result);
|
|
150
|
-
const ctx = parsed.hookSpecificOutput.additionalContext;
|
|
151
|
-
assert.match(ctx, /<workflow-state [a-z]+="/);
|
|
152
|
-
assert.match(ctx, /<\/workflow-state>/);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
test("additionalContext is non-empty string", () => {
|
|
156
|
-
const result = runHook({ ZCODE_PROJECT_DIR: tmpRoot });
|
|
157
|
-
const parsed = JSON.parse(result);
|
|
158
|
-
assert.equal(typeof parsed.hookSpecificOutput.additionalContext, "string");
|
|
159
|
-
assert.ok(parsed.hookSpecificOutput.additionalContext.length > 50);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test("exit code is 0 on success", () => {
|
|
163
|
-
try {
|
|
164
|
-
runHook({ ZCODE_PROJECT_DIR: tmpRoot });
|
|
165
|
-
assert.ok(true);
|
|
166
|
-
} catch (err) {
|
|
167
|
-
assert.fail(`Hook exited with code ${err.status}`);
|
|
168
|
-
}
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
test("cursor platform outputs additional_context (no hookSpecificOutput)", () => {
|
|
172
|
-
const result = runHook({ ZCODE_PROJECT_DIR: tmpRoot, CURSOR_PLUGIN_ROOT: "/x" });
|
|
173
|
-
const parsed = JSON.parse(result);
|
|
174
|
-
assert.ok(parsed.additional_context, "cursor format uses additional_context");
|
|
175
|
-
assert.ok(!parsed.hookSpecificOutput, "cursor format should not have hookSpecificOutput");
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
test("UserPromptSubmit outputs a single fingerprint line without contract details", () => {
|
|
179
|
-
const result = runHook(
|
|
180
|
-
{ ZCODE_PROJECT_DIR: tmpRoot },
|
|
181
|
-
JSON.stringify({ hook_event_name: "UserPromptSubmit" })
|
|
182
|
-
);
|
|
183
|
-
const parsed = JSON.parse(result);
|
|
184
|
-
const ctx = parsed.hookSpecificOutput.additionalContext;
|
|
185
|
-
assert.match(ctx, /<contract-fingerprint value="[a-f0-9]+"\/>/, "should carry the repeated fingerprint");
|
|
186
|
-
assert.doesNotMatch(ctx, /<contract-digest/, "digest block must not appear on user prompts");
|
|
187
|
-
assert.doesNotMatch(ctx, /RUNTIME_CONTEXT_DISPATCH_V2/, "contract detail lines must not appear");
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
test("SessionStart outputs the full contract-digest block", () => {
|
|
191
|
-
const result = runHook(
|
|
192
|
-
{ ZCODE_PROJECT_DIR: tmpRoot },
|
|
193
|
-
JSON.stringify({ hook_event_name: "SessionStart" })
|
|
194
|
-
);
|
|
195
|
-
const parsed = JSON.parse(result);
|
|
196
|
-
const ctx = parsed.hookSpecificOutput.additionalContext;
|
|
197
|
-
assert.match(ctx, /<cowork-runtime host="zcode"/, "should have cowork-runtime block");
|
|
198
|
-
assert.match(ctx, /<contract-digest fingerprint="[a-f0-9]+">/, "should have contract-digest with hex fingerprint");
|
|
199
|
-
assert.match(ctx, /RUNTIME_CONTEXT_DISPATCH_V2/, "should list contracts from registry");
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
test("fingerprint is stable across events and reacts to spec changes", () => {
|
|
203
|
-
const promptFingerprint = () => {
|
|
204
|
-
const parsed = JSON.parse(
|
|
205
|
-
runHook({ ZCODE_PROJECT_DIR: tmpRoot }, JSON.stringify({ hook_event_name: "UserPromptSubmit" }))
|
|
206
|
-
);
|
|
207
|
-
return parsed.hookSpecificOutput.additionalContext.match(/<contract-fingerprint value="([a-f0-9]+)"\/>/)[1];
|
|
208
|
-
};
|
|
209
|
-
const before = promptFingerprint();
|
|
210
|
-
const sessionParsed = JSON.parse(
|
|
211
|
-
runHook({ ZCODE_PROJECT_DIR: tmpRoot }, JSON.stringify({ hook_event_name: "SessionStart" }))
|
|
212
|
-
);
|
|
213
|
-
const sessionFingerprint = sessionParsed.hookSpecificOutput.additionalContext.match(/fingerprint="([a-f0-9]+)"/)[1];
|
|
214
|
-
assert.equal(before, sessionFingerprint, "identical spec state must yield identical fingerprints");
|
|
215
|
-
|
|
216
|
-
const specPath = join(tmpRoot, ".cowork-flow", "spec", "contracts", "subagent-dispatch.md");
|
|
217
|
-
writeFileSync(specPath, "# Subagent Dispatch\n\nChanged content moves the fingerprint.\n", "utf8");
|
|
218
|
-
const after = promptFingerprint();
|
|
219
|
-
assert.notEqual(before, after, "spec content changes must move the fingerprint");
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
test("fingerprint is invariant under registry key reordering (stable serialization)", () => {
|
|
223
|
-
const registryPath = join(tmpRoot, ".cowork-flow", "spec", "runtime", "contract-registry.json");
|
|
224
|
-
const original = JSON.parse(readFileSync(registryPath, "utf8"));
|
|
225
|
-
const contract = original.contracts[0];
|
|
226
|
-
const reordered = {
|
|
227
|
-
...original,
|
|
228
|
-
contracts: [
|
|
229
|
-
{
|
|
230
|
-
readWhen: contract.readWhen,
|
|
231
|
-
path: contract.path,
|
|
232
|
-
digest: contract.digest,
|
|
233
|
-
id: contract.id,
|
|
234
|
-
},
|
|
235
|
-
],
|
|
236
|
-
};
|
|
237
|
-
const promptFingerprint = () => {
|
|
238
|
-
const parsed = JSON.parse(
|
|
239
|
-
runHook({ ZCODE_PROJECT_DIR: tmpRoot }, JSON.stringify({ hook_event_name: "UserPromptSubmit" }))
|
|
240
|
-
);
|
|
241
|
-
return parsed.hookSpecificOutput.additionalContext.match(/<contract-fingerprint value="([a-f0-9]+)"\/>/)[1];
|
|
242
|
-
};
|
|
243
|
-
const before = promptFingerprint();
|
|
244
|
-
writeFileSync(registryPath, `${JSON.stringify(reordered, null, 2)}\n`, "utf8");
|
|
245
|
-
const after = promptFingerprint();
|
|
246
|
-
assert.equal(before, after, "key order inside the registry must not move the fingerprint");
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
test("per-edit out-of-scope warning fires for Edit outside the whitelist", () => {
|
|
250
|
-
const result = runHook(
|
|
251
|
-
{ ZCODE_PROJECT_DIR: tmpRoot },
|
|
252
|
-
JSON.stringify({
|
|
253
|
-
hook_event_name: "PostToolUse",
|
|
254
|
-
tool_name: "Edit",
|
|
255
|
-
tool_input: { file_path: "src/outside.py" },
|
|
256
|
-
})
|
|
257
|
-
);
|
|
258
|
-
const ctx = JSON.parse(result).hookSpecificOutput.additionalContext;
|
|
259
|
-
assert.match(ctx, /src\/outside\.py is outside the task's declared scope/);
|
|
260
|
-
assert.match(ctx, /task context add.{0,20}or revert/);
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
test("per-edit warning stays silent inside scope and for subagents", () => {
|
|
264
|
-
// In-scope edit: the whitelist contains the task's own context file.
|
|
265
|
-
writeProjectFile(
|
|
266
|
-
tmpRoot,
|
|
267
|
-
".cowork-flow/tasks/07-02-test-task/implement.jsonl",
|
|
268
|
-
JSON.stringify({
|
|
269
|
-
file: ".cowork-flow/tasks/07-02-test-task/task.json",
|
|
270
|
-
reason: "context",
|
|
271
|
-
}) + "\n"
|
|
272
|
-
);
|
|
273
|
-
const inScope = runHook(
|
|
274
|
-
{ ZCODE_PROJECT_DIR: tmpRoot },
|
|
275
|
-
JSON.stringify({
|
|
276
|
-
hook_event_name: "PostToolUse",
|
|
277
|
-
tool_name: "Edit",
|
|
278
|
-
tool_input: {
|
|
279
|
-
file_path: ".cowork-flow/tasks/07-02-test-task/task.json",
|
|
280
|
-
},
|
|
281
|
-
})
|
|
282
|
-
);
|
|
283
|
-
assert.equal(inScope.trim(), "");
|
|
284
|
-
rmSync(
|
|
285
|
-
join(tmpRoot, ".cowork-flow/tasks/07-02-test-task/implement.jsonl"),
|
|
286
|
-
{ force: true }
|
|
287
|
-
);
|
|
288
|
-
|
|
289
|
-
// Subagent-scoped sessions stay silent (capability-matrix fallback).
|
|
290
|
-
const subSession = join(tmpRoot, ".cowork-flow/.runtime/sessions/zcode_sub.json");
|
|
291
|
-
writeFileSync(
|
|
292
|
-
subSession,
|
|
293
|
-
JSON.stringify({
|
|
294
|
-
active_task_path: ".cowork-flow/tasks/07-02-test-task",
|
|
295
|
-
scope: "subagent",
|
|
296
|
-
platform: "zcode",
|
|
297
|
-
last_seen_at: "2026-07-03T10:00:00Z",
|
|
298
|
-
})
|
|
299
|
-
);
|
|
300
|
-
const subagentResult = runHook(
|
|
301
|
-
{ ZCODE_PROJECT_DIR: tmpRoot },
|
|
302
|
-
JSON.stringify({
|
|
303
|
-
hook_event_name: "PostToolUse",
|
|
304
|
-
tool_name: "Edit",
|
|
305
|
-
tool_input: { file_path: "src/outside.py" },
|
|
306
|
-
session_id: "sub",
|
|
307
|
-
})
|
|
308
|
-
);
|
|
309
|
-
assert.equal(subagentResult.trim(), "");
|
|
310
|
-
rmSync(subSession, { force: true });
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
test("stage-contract rides with active states and honors the char budget", () => {
|
|
314
|
-
writeProjectFile(tmpRoot, ".cowork-flow/tasks/07-02-test-task/implement.jsonl",
|
|
315
|
-
JSON.stringify({ file: "src/demo.py", reason: "main" }) + "\n");
|
|
316
|
-
const result = runHook(
|
|
317
|
-
{ ZCODE_PROJECT_DIR: tmpRoot },
|
|
318
|
-
JSON.stringify({ hook_event_name: "UserPromptSubmit" })
|
|
319
|
-
);
|
|
320
|
-
const ctx = JSON.parse(result).hookSpecificOutput.additionalContext;
|
|
321
|
-
const block = ctx.match(/<stage-contract task="[^"]*">[\s\S]*?<\/stage-contract>/);
|
|
322
|
-
assert.ok(block, "stage-contract must ride with in_progress tasks");
|
|
323
|
-
assert.match(block[0], /Scope: src\/demo\.py \[agent-mutable\]/);
|
|
324
|
-
assert.match(block[0], /Gates: edits outside Scope are review blockers/);
|
|
325
|
-
assert.ok(block[0].length <= 1200, `stage-contract budget: ${block[0].length}`);
|
|
326
|
-
assert.match(ctx, /status="in_progress"/);
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
test("./-prefixed entries never crash the hook and stay canonical", () => {
|
|
330
|
-
writeProjectFile(tmpRoot, ".cowork-flow/tasks/07-02-test-task/implement.jsonl",
|
|
331
|
-
JSON.stringify({ file: "./src/demo.py", reason: "hand-written" }) + "\n");
|
|
332
|
-
const result = runHook(
|
|
333
|
-
{ ZCODE_PROJECT_DIR: tmpRoot },
|
|
334
|
-
JSON.stringify({ hook_event_name: "UserPromptSubmit" })
|
|
335
|
-
);
|
|
336
|
-
const ctx = JSON.parse(result).hookSpecificOutput.additionalContext;
|
|
337
|
-
const block = ctx.match(/<stage-contract task="[^"]*">[\s\S]*?<\/stage-contract>/);
|
|
338
|
-
assert.ok(block, "stage-contract must survive ./-prefixed entries");
|
|
339
|
-
assert.match(block[0], /Scope: src\/demo\.py \[agent-mutable\]/);
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
test("invalid scope entries are dropped like the python whitelist drops them", () => {
|
|
343
|
-
writeProjectFile(tmpRoot, ".cowork-flow/tasks/07-02-test-task/implement.jsonl",
|
|
344
|
-
[
|
|
345
|
-
JSON.stringify({ file: "../escape.py", reason: "escape" }),
|
|
346
|
-
JSON.stringify({ file: "/abs.py", reason: "absolute" }),
|
|
347
|
-
JSON.stringify({ file: "src/a*.py", reason: "wildcard" }),
|
|
348
|
-
JSON.stringify({ file: "guide.md", type: "guide", reason: "bad type" }),
|
|
349
|
-
].join("\n") + "\n");
|
|
350
|
-
const result = runHook(
|
|
351
|
-
{ ZCODE_PROJECT_DIR: tmpRoot },
|
|
352
|
-
JSON.stringify({ hook_event_name: "UserPromptSubmit" })
|
|
353
|
-
);
|
|
354
|
-
const ctx = JSON.parse(result).hookSpecificOutput.additionalContext;
|
|
355
|
-
const block = ctx.match(/<stage-contract task="[^"]*">[\s\S]*?<\/stage-contract>/);
|
|
356
|
-
assert.ok(block, "stage-contract must ride with in_progress tasks");
|
|
357
|
-
assert.match(block[0], /Scope: \(empty\) \[agent-mutable\]/);
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
test("delegated sessions render the parent scope as read-only", () => {
|
|
361
|
-
writeProjectFile(tmpRoot, ".cowork-flow/tasks/07-02-test-task/implement.jsonl",
|
|
362
|
-
JSON.stringify({ file: "src/demo.py", reason: "main" }) + "\n");
|
|
363
|
-
// Add one sibling session file without wiping the others: resetSessions
|
|
364
|
-
// would clear the shared fixture for the tests that follow.
|
|
365
|
-
writeProjectFile(tmpRoot, ".cowork-flow/.runtime/sessions/zcode_child.json",
|
|
366
|
-
JSON.stringify({
|
|
367
|
-
active_task_path: ".cowork-flow/tasks/07-02-test-task",
|
|
368
|
-
scope: "subagent",
|
|
369
|
-
platform: "zcode",
|
|
370
|
-
last_seen_at: "2026-08-30T13:00:00Z",
|
|
371
|
-
}));
|
|
372
|
-
const result = runHook(
|
|
373
|
-
{ ZCODE_PROJECT_DIR: tmpRoot },
|
|
374
|
-
JSON.stringify({ hook_event_name: "UserPromptSubmit", session_id: "child" })
|
|
375
|
-
);
|
|
376
|
-
const ctx = JSON.parse(result).hookSpecificOutput.additionalContext;
|
|
377
|
-
const block = ctx.match(/<stage-contract task="[^"]*">[\s\S]*?<\/stage-contract>/);
|
|
378
|
-
assert.ok(block, "delegated sessions keep the stage contract");
|
|
379
|
-
assert.match(block[0], /Scope: src\/demo\.py \[read-only\]/);
|
|
380
|
-
assert.match(block[0], /scope is inherited from the parent task \(read-only reference\)/);
|
|
381
|
-
assert.doesNotMatch(ctx, /Scope: subagent/);
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
test("scope-rules.json drives the whitelist and budget", () => {
|
|
385
|
-
// Wildcards admitted + tight budget: both prove the rules file is
|
|
386
|
-
// really consumed by the hook.
|
|
387
|
-
writeProjectFile(tmpRoot, ".cowork-flow/tasks/07-02-test-task/implement.jsonl",
|
|
388
|
-
[
|
|
389
|
-
JSON.stringify({ file: "src/a*.py", reason: "wildcard" }),
|
|
390
|
-
JSON.stringify({ file: "src/demo.py", reason: "main" }),
|
|
391
|
-
].join("\n") + "\n");
|
|
392
|
-
writeProjectFile(tmpRoot, ".cowork-flow/spec/runtime/scope-rules.json",
|
|
393
|
-
JSON.stringify({
|
|
394
|
-
schemaVersion: 1,
|
|
395
|
-
scopeFilter: {
|
|
396
|
-
allowedTypes: ["file", "planned-file", "deleted-file"],
|
|
397
|
-
wildcardChars: [],
|
|
398
|
-
rejectedSegments: ["", ".", ".."],
|
|
399
|
-
driveLetterPattern: "^[A-Za-z]:",
|
|
400
|
-
trailingSlashRejectedTypes: ["planned-file", "deleted-file"],
|
|
401
|
-
},
|
|
402
|
-
stageContract: { budget: 400, scopeLimit: 8, specLimit: 4, verifyLimit: 3 },
|
|
403
|
-
}));
|
|
404
|
-
const result = runHook(
|
|
405
|
-
{ ZCODE_PROJECT_DIR: tmpRoot },
|
|
406
|
-
JSON.stringify({ hook_event_name: "UserPromptSubmit" })
|
|
407
|
-
);
|
|
408
|
-
const ctx = JSON.parse(result).hookSpecificOutput.additionalContext;
|
|
409
|
-
const block = ctx.match(/<stage-contract task="[^"]*">[\s\S]*?<\/stage-contract>/);
|
|
410
|
-
assert.ok(block, "stage-contract must ride with rules present");
|
|
411
|
-
assert.match(block[0], /Scope: src\/a\*\.py; src\/demo\.py \[agent-mutable\]/,
|
|
412
|
-
"emptied wildcardChars must admit the wildcard entry");
|
|
413
|
-
assert.ok(block[0].length <= 400, `rules budget must hold: ${block[0].length}`);
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
test("default rules file keeps output byte-identical with no file", () => {
|
|
417
|
-
writeProjectFile(tmpRoot, ".cowork-flow/tasks/07-02-test-task/implement.jsonl",
|
|
418
|
-
JSON.stringify({ file: "src/demo.py", reason: "main" }) + "\n");
|
|
419
|
-
// Rewrite the rules file with the exact shipped defaults (a stale
|
|
420
|
-
// custom file may exist from the previous test).
|
|
421
|
-
writeProjectFile(tmpRoot, ".cowork-flow/spec/runtime/scope-rules.json",
|
|
422
|
-
JSON.stringify({
|
|
423
|
-
schemaVersion: 1,
|
|
424
|
-
scopeFilter: {
|
|
425
|
-
allowedTypes: ["file", "planned-file", "deleted-file"],
|
|
426
|
-
wildcardChars: ["*", "?", "[", "]"],
|
|
427
|
-
rejectedSegments: ["", ".", ".."],
|
|
428
|
-
driveLetterPattern: "^[A-Za-z]:",
|
|
429
|
-
trailingSlashRejectedTypes: ["planned-file", "deleted-file"],
|
|
430
|
-
},
|
|
431
|
-
stageContract: { budget: 1200, scopeLimit: 8, specLimit: 4, verifyLimit: 3 },
|
|
432
|
-
}));
|
|
433
|
-
const withFile = runHook(
|
|
434
|
-
{ ZCODE_PROJECT_DIR: tmpRoot },
|
|
435
|
-
JSON.stringify({ hook_event_name: "UserPromptSubmit" })
|
|
436
|
-
);
|
|
437
|
-
rmSync(join(tmpRoot, ".cowork-flow", "spec", "runtime", "scope-rules.json"), { force: true });
|
|
438
|
-
const withoutFile = runHook(
|
|
439
|
-
{ ZCODE_PROJECT_DIR: tmpRoot },
|
|
440
|
-
JSON.stringify({ hook_event_name: "UserPromptSubmit" })
|
|
441
|
-
);
|
|
442
|
-
const extract = (out) =>
|
|
443
|
-
JSON.parse(out).hookSpecificOutput.additionalContext.match(/<stage-contract task="[^"]*">[\s\S]*?<\/stage-contract>/)[0];
|
|
444
|
-
assert.equal(extract(withFile), extract(withoutFile),
|
|
445
|
-
"default-content rules file must equal the no-file fallback");
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
test("decision anchor essentials ride along for active states", () => {
|
|
449
|
-
writeProjectFile(
|
|
450
|
-
tmpRoot,
|
|
451
|
-
".cowork-flow/tasks/07-02-test-task/decision-anchor.md",
|
|
452
|
-
[
|
|
453
|
-
"## 目标",
|
|
454
|
-
"让注入可被机器解析。",
|
|
455
|
-
"",
|
|
456
|
-
"## 验收标准",
|
|
457
|
-
"- [ ] AC-001: 属性头三线一致",
|
|
458
|
-
"",
|
|
459
|
-
"## 被拒方案",
|
|
460
|
-
"- **方案B(双写行)**: 冗余",
|
|
461
|
-
].join("\n")
|
|
462
|
-
);
|
|
463
|
-
const result = runHook(
|
|
464
|
-
{ ZCODE_PROJECT_DIR: tmpRoot },
|
|
465
|
-
JSON.stringify({ hook_event_name: "UserPromptSubmit" })
|
|
466
|
-
);
|
|
467
|
-
const ctx = JSON.parse(result).hookSpecificOutput.additionalContext;
|
|
468
|
-
assert.match(ctx, /<decision-anchor task="\.cowork-flow\/tasks\/07-02-test-task">/);
|
|
469
|
-
assert.match(ctx, /Goal: 让注入可被机器解析。/);
|
|
470
|
-
assert.match(ctx, /AC-001/);
|
|
471
|
-
assert.match(ctx, /Rejected: 方案B(双写行)/);
|
|
472
|
-
});
|
|
473
|
-
|
|
474
|
-
test("parses workflow-state-templates.md for breadcrumb text", () => {
|
|
475
|
-
// Remove session files to simulate no active task
|
|
476
|
-
const sessionsDir = join(tmpRoot, ".cowork-flow", ".runtime", "sessions");
|
|
477
|
-
if (existsSync(sessionsDir)) {
|
|
478
|
-
rmSync(sessionsDir, { recursive: true, force: true });
|
|
479
|
-
}
|
|
480
|
-
const result = runHook({ ZCODE_PROJECT_DIR: tmpRoot });
|
|
481
|
-
const parsed = JSON.parse(result);
|
|
482
|
-
const ctx = parsed.hookSpecificOutput.additionalContext;
|
|
483
|
-
const m = ctx.match(/<workflow-state[^>]*>([\s\S]*?)<\/workflow-state>/);
|
|
484
|
-
assert.ok(m, "should have workflow-state block");
|
|
485
|
-
assert.match(m[1], /STOP/, "no_task breadcrumb should contain STOP marker from templates.md");
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
test("outputs in_progress breadcrumb when task is active", () => {
|
|
489
|
-
const sessionsDir = join(tmpRoot, ".cowork-flow", ".runtime", "sessions");
|
|
490
|
-
const taskDir = join(tmpRoot, ".cowork-flow", "tasks", "test-active");
|
|
491
|
-
mkdirSync(sessionsDir, { recursive: true });
|
|
492
|
-
mkdirSync(taskDir, { recursive: true });
|
|
493
|
-
const sessData = JSON.stringify({ active_task_path: ".cowork-flow/tasks/test-active", scope: "main", platform: "zcode", last_seen_at: "2026-07-02T16:00:00Z" });
|
|
494
|
-
writeFileSync(join(sessionsDir, "test_sess.json"), sessData, "utf8");
|
|
495
|
-
const taskData = JSON.stringify({ status: "in_progress" });
|
|
496
|
-
writeFileSync(join(taskDir, "task.json"), taskData, "utf8");
|
|
497
|
-
const result = runHook({ ZCODE_PROJECT_DIR: tmpRoot });
|
|
498
|
-
const parsed = JSON.parse(result);
|
|
499
|
-
const ctx = parsed.hookSpecificOutput.additionalContext;
|
|
500
|
-
assert.match(ctx, /status="in_progress"/, "should show in_progress status");
|
|
501
|
-
assert.match(ctx, /活动任务正在执行/, "should contain in_progress breadcrumb from templates.md");
|
|
502
|
-
});
|
|
503
|
-
|
|
504
|
-
test("detects delegated_subtask from prompt context ID", () => {
|
|
505
|
-
const subagentsDir = join(tmpRoot, ".cowork-flow", ".runtime", "subagents");
|
|
506
|
-
mkdirSync(subagentsDir, { recursive: true });
|
|
507
|
-
const contextId = "rtx_test_001";
|
|
508
|
-
const taskId = ".cowork-flow/tasks/test-active";
|
|
509
|
-
const contextData = JSON.stringify({ scope: "subagent", agent_type: "cowork-implement", task_dir: taskId, status: "bound", assignment: { goal: "Implement feature X" } });
|
|
510
|
-
writeFileSync(join(subagentsDir, `${contextId}.json`), contextData, "utf8");
|
|
511
|
-
const result = runHook({ ZCODE_PROJECT_DIR: tmpRoot, COWORK_FLOW_RUNTIME_CONTEXT_ID: contextId });
|
|
512
|
-
const parsed = JSON.parse(result);
|
|
513
|
-
const ctx = parsed.hookSpecificOutput.additionalContext;
|
|
514
|
-
assert.match(ctx, /status="delegated_subtask"/, "should detect delegated_subtask");
|
|
515
|
-
// Scope ownership moved into the stage-contract block: no bare
|
|
516
|
-
// "Scope: subagent" line survives in delegated workflow-state bodies.
|
|
517
|
-
assert.doesNotMatch(ctx, /Scope: subagent/, "scope rows must not duplicate");
|
|
518
|
-
});
|
|
519
|
-
|
|
520
|
-
// --- Stale-session poisoning regression (per-session selection) ---
|
|
521
|
-
|
|
522
|
-
function resetSessions(entries) {
|
|
523
|
-
const sessionsDir = join(tmpRoot, ".cowork-flow", ".runtime", "sessions");
|
|
524
|
-
rmSync(sessionsDir, { recursive: true, force: true });
|
|
525
|
-
mkdirSync(sessionsDir, { recursive: true });
|
|
526
|
-
for (const [name, data] of Object.entries(entries)) {
|
|
527
|
-
writeFileSync(
|
|
528
|
-
join(sessionsDir, name),
|
|
529
|
-
JSON.stringify(data),
|
|
530
|
-
"utf8"
|
|
531
|
-
);
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
function ensureTask(taskName, status = "in_progress") {
|
|
536
|
-
const taskDir = join(tmpRoot, ".cowork-flow", "tasks", taskName);
|
|
537
|
-
mkdirSync(taskDir, { recursive: true });
|
|
538
|
-
writeFileSync(join(taskDir, "task.json"), JSON.stringify({ status }), "utf8");
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
test("prefers the session file matching hook input sessionId over a newer global entry", () => {
|
|
542
|
-
ensureTask("07-02-test-task");
|
|
543
|
-
ensureTask("07-03-newer-task");
|
|
544
|
-
resetSessions({
|
|
545
|
-
"zcode_my-session.json": { active_task_path: ".cowork-flow/tasks/07-02-test-task", scope: "main", platform: "zcode", last_seen_at: "2026-07-02T13:00:00Z" },
|
|
546
|
-
"claude_other.json": { active_task_path: ".cowork-flow/tasks/07-03-newer-task", scope: "main", platform: "claude", last_seen_at: "2026-07-03T18:00:00Z" },
|
|
547
|
-
});
|
|
548
|
-
const result = runHook(
|
|
549
|
-
{ ZCODE_PROJECT_DIR: tmpRoot },
|
|
550
|
-
JSON.stringify({ session_id: "my-session" })
|
|
551
|
-
);
|
|
552
|
-
const parsed = JSON.parse(result);
|
|
553
|
-
const ctx = parsed.hookSpecificOutput.additionalContext;
|
|
554
|
-
assert.match(ctx, /task="\.cowork-flow\/tasks\/07-02-test-task"/, "own session binding wins over newer global entry");
|
|
555
|
-
});
|
|
556
|
-
|
|
557
|
-
test("skips stale sessions whose task directory is gone and falls back to the newest valid one", () => {
|
|
558
|
-
ensureTask("07-02-test-task");
|
|
559
|
-
resetSessions({
|
|
560
|
-
"zcode_gone.json": { active_task_path: ".cowork-flow/tasks/06-21-deleted-task", scope: "main", platform: "zcode", last_seen_at: "2026-08-01T09:00:00Z" },
|
|
561
|
-
"claude_abc.json": { active_task_path: ".cowork-flow/tasks/07-02-test-task", scope: "main", platform: "claude", last_seen_at: "2026-07-02T13:00:00Z" },
|
|
562
|
-
});
|
|
563
|
-
const result = runHook({ ZCODE_PROJECT_DIR: tmpRoot });
|
|
564
|
-
const parsed = JSON.parse(result);
|
|
565
|
-
const ctx = parsed.hookSpecificOutput.additionalContext;
|
|
566
|
-
assert.match(ctx, /task="\.cowork-flow\/tasks\/07-02-test-task"/, "newest valid session wins after skipping stale");
|
|
567
|
-
assert.doesNotMatch(ctx, /任务目录不存在/, "stale path must not leak into injected state");
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
test("reports clean no_task when every session points to a missing task", () => {
|
|
571
|
-
resetSessions({
|
|
572
|
-
"zcode_gone.json": { active_task_path: ".cowork-flow/tasks/06-21-deleted-task", scope: "main", platform: "zcode", last_seen_at: "2026-08-01T09:00:00Z" },
|
|
573
|
-
});
|
|
574
|
-
const result = runHook({ ZCODE_PROJECT_DIR: tmpRoot });
|
|
575
|
-
const parsed = JSON.parse(result);
|
|
576
|
-
const ctx = parsed.hookSpecificOutput.additionalContext;
|
|
577
|
-
const m = ctx.match(/<workflow-state[^>]*>([\s\S]*?)<\/workflow-state>/);
|
|
578
|
-
assert.ok(m, "should have workflow-state block");
|
|
579
|
-
assert.match(m[0], /status="no_task"/, "should fall back to clean no_task");
|
|
580
|
-
assert.doesNotMatch(m[1], /06-21-deleted-task/, "must not reference the dead task path");
|
|
581
|
-
});
|
|
582
|
-
|
|
583
|
-
test("main-session fallback ignores subagent-scoped session files", () => {
|
|
584
|
-
ensureTask("07-02-test-task");
|
|
585
|
-
resetSessions({
|
|
586
|
-
"subagent_child.json": { active_task_path: ".cowork-flow/tasks/sub-child", scope: "subagent", platform: "zcode", last_seen_at: "2026-08-05T09:00:00Z" },
|
|
587
|
-
"claude_abc.json": { active_task_path: ".cowork-flow/tasks/07-02-test-task", scope: "main", platform: "claude", last_seen_at: "2026-07-02T13:00:00Z" },
|
|
588
|
-
});
|
|
589
|
-
const result = runHook({ ZCODE_PROJECT_DIR: tmpRoot });
|
|
590
|
-
const parsed = JSON.parse(result);
|
|
591
|
-
const ctx = parsed.hookSpecificOutput.additionalContext;
|
|
592
|
-
assert.match(ctx, /task="\.cowork-flow\/tasks\/07-02-test-task"/, "main-scope session is selected, not the newer subagent one");
|
|
593
|
-
});
|
|
594
|
-
|
|
595
|
-
test("explicit sessionId without own session file reports no_task with rebind hints", () => {
|
|
596
|
-
ensureTask("07-02-test-task");
|
|
597
|
-
resetSessions({
|
|
598
|
-
"claude_other.json": { active_task_path: ".cowork-flow/tasks/07-02-test-task", scope: "main", platform: "claude", last_seen_at: "2026-07-03T18:00:00Z" },
|
|
599
|
-
});
|
|
600
|
-
const result = runHook(
|
|
601
|
-
{ ZCODE_PROJECT_DIR: tmpRoot },
|
|
602
|
-
JSON.stringify({ hook_event_name: "UserPromptSubmit", session_id: "brand-new" })
|
|
603
|
-
);
|
|
604
|
-
const parsed = JSON.parse(result);
|
|
605
|
-
const ctx = parsed.hookSpecificOutput.additionalContext;
|
|
606
|
-
const m = ctx.match(/<workflow-state[^>]*>([\s\S]*?)<\/workflow-state>/);
|
|
607
|
-
assert.ok(m, "should have workflow-state block");
|
|
608
|
-
assert.match(m[0], /status="no_task"/, "unbound explicit session must be no_task");
|
|
609
|
-
assert.doesNotMatch(m[0], /task="\."/, "must not adopt another session's task");
|
|
610
|
-
assert.match(m[1], /07-02-test-task/, "should list active tasks for rebinding");
|
|
611
|
-
});
|
|
612
|
-
|
|
613
|
-
test("dead-path session shows rebind hints for remaining active tasks", () => {
|
|
614
|
-
ensureTask("07-02-test-task");
|
|
615
|
-
resetSessions({
|
|
616
|
-
"zcode_stale.json": { active_task_path: ".cowork-flow/tasks/06-21-deleted", scope: "main", platform: "zcode", last_seen_at: "2026-08-01T09:00:00Z" },
|
|
617
|
-
});
|
|
618
|
-
const result = runHook(
|
|
619
|
-
{ ZCODE_PROJECT_DIR: tmpRoot },
|
|
620
|
-
JSON.stringify({ hook_event_name: "UserPromptSubmit", session_id: "stale" })
|
|
621
|
-
);
|
|
622
|
-
const parsed = JSON.parse(result);
|
|
623
|
-
const ctx = parsed.hookSpecificOutput.additionalContext;
|
|
624
|
-
const m = ctx.match(/<workflow-state[^>]*>([\s\S]*?)<\/workflow-state>/);
|
|
625
|
-
assert.ok(m, "should have workflow-state block");
|
|
626
|
-
assert.match(m[1], /06-21-deleted/, "own stale binding is reported as-is");
|
|
627
|
-
assert.match(m[1], /07-02-test-task/, "remaining active task offered for rebinding");
|
|
628
|
-
});
|
|
629
|
-
|
|
630
|
-
test("unbound session with zero active tasks stays clean without hints", () => {
|
|
631
|
-
rmSync(join(tmpRoot, ".cowork-flow", "tasks"), { recursive: true, force: true });
|
|
632
|
-
resetSessions({});
|
|
633
|
-
const result = runHook(
|
|
634
|
-
{ ZCODE_PROJECT_DIR: tmpRoot },
|
|
635
|
-
JSON.stringify({ hook_event_name: "UserPromptSubmit", session_id: "fresh" })
|
|
636
|
-
);
|
|
637
|
-
const parsed = JSON.parse(result);
|
|
638
|
-
const ctx = parsed.hookSpecificOutput.additionalContext;
|
|
639
|
-
const m = ctx.match(/<workflow-state[^>]*>([\s\S]*?)<\/workflow-state>/);
|
|
640
|
-
assert.ok(m, "should have workflow-state block");
|
|
641
|
-
assert.match(m[0], /status="no_task"/);
|
|
642
|
-
assert.doesNotMatch(m[1], /改绑|rebind/i, "no hints when nothing to rebind to");
|
|
643
|
-
});
|
|
644
|
-
|
|
645
|
-
test("PostToolUse refreshes state when the command runs task lifecycle", () => {
|
|
646
|
-
ensureTask("07-02-test-task");
|
|
647
|
-
resetSessions({
|
|
648
|
-
"claude_abc.json": { active_task_path: ".cowork-flow/tasks/07-02-test-task", scope: "main", platform: "claude", last_seen_at: "2026-07-02T13:00:00Z" },
|
|
649
|
-
});
|
|
650
|
-
const result = runHook(
|
|
651
|
-
{ ZCODE_PROJECT_DIR: tmpRoot },
|
|
652
|
-
JSON.stringify({
|
|
653
|
-
hook_event_name: "PostToolUse",
|
|
654
|
-
tool_input: { command: "./.cowork-flow/run task next --run --title X" },
|
|
655
|
-
})
|
|
656
|
-
);
|
|
657
|
-
const parsed = JSON.parse(result);
|
|
658
|
-
const ctx = parsed.hookSpecificOutput.additionalContext;
|
|
659
|
-
assert.equal(parsed.hookSpecificOutput.hookEventName, "PostToolUse");
|
|
660
|
-
assert.match(ctx, /status="in_progress"/, "mid-turn refresh carries updated state");
|
|
661
|
-
assert.match(ctx, /<contract-fingerprint value="[a-f0-9]+"\/>/, "fingerprint line rides the refresh");
|
|
662
|
-
assert.doesNotMatch(ctx, /<contract-digest/, "no full digest outside session start");
|
|
663
|
-
});
|
|
664
|
-
|
|
665
|
-
test("PostToolUse stays silent for unrelated commands", () => {
|
|
666
|
-
resetSessions({});
|
|
667
|
-
const stdout = runHook(
|
|
668
|
-
{ ZCODE_PROJECT_DIR: tmpRoot },
|
|
669
|
-
JSON.stringify({
|
|
670
|
-
hook_event_name: "PostToolUse",
|
|
671
|
-
tool_input: { command: "ls -la" },
|
|
672
|
-
})
|
|
673
|
-
);
|
|
674
|
-
assert.equal(stdout.trim(), "", "unrelated commands must produce no output");
|
|
675
|
-
});
|
|
676
|
-
|
|
677
|
-
test("consistent state snapshot drives breadcrumb key selection", () => {
|
|
678
|
-
ensureTask("07-02-test-task");
|
|
679
|
-
resetSessions({
|
|
680
|
-
"claude_abc.json": { active_task_path: ".cowork-flow/tasks/07-02-test-task", scope: "main", platform: "claude", last_seen_at: "2026-07-02T13:00:00Z" },
|
|
681
|
-
});
|
|
682
|
-
const templatesPath = join(tmpRoot, ".cowork-flow", "spec", "contracts", "workflow-state-templates.md");
|
|
683
|
-
const templates = readFileSync(templatesPath, "utf8");
|
|
684
|
-
writeFileSync(
|
|
685
|
-
templatesPath,
|
|
686
|
-
`${templates}\n[workflow-state:snapshot-probe]\n快照键生效。\n[/workflow-state:snapshot-probe]\n`,
|
|
687
|
-
"utf8"
|
|
688
|
-
);
|
|
689
|
-
const runtimeDir = join(tmpRoot, ".cowork-flow", ".runtime");
|
|
690
|
-
mkdirSync(runtimeDir, { recursive: true });
|
|
691
|
-
writeFileSync(
|
|
692
|
-
join(runtimeDir, "state-snapshot.json"),
|
|
693
|
-
JSON.stringify({
|
|
694
|
-
schemaVersion: 1,
|
|
695
|
-
generatedAt: "2026-07-02T13:05:00Z",
|
|
696
|
-
activeTaskPath: ".cowork-flow/tasks/07-02-test-task",
|
|
697
|
-
status: "in_progress",
|
|
698
|
-
breadcrumbKey: "snapshot-probe",
|
|
699
|
-
}),
|
|
700
|
-
"utf8"
|
|
701
|
-
);
|
|
702
|
-
const result = runHook({ ZCODE_PROJECT_DIR: tmpRoot });
|
|
703
|
-
const parsed = JSON.parse(result);
|
|
704
|
-
const ctx = parsed.hookSpecificOutput.additionalContext;
|
|
705
|
-
assert.match(ctx, /task="\.cowork-flow\/tasks\/07-02-test-task"/);
|
|
706
|
-
assert.match(ctx, /快照键生效。/, "consistent snapshot key wins over status convention");
|
|
707
|
-
assert.doesNotMatch(ctx, /活动任务正在执行/, "status-derived fallback must not run");
|
|
708
|
-
});
|
|
709
|
-
|
|
710
|
-
test("mismatched or missing snapshot falls back to status-derived breadcrumb", () => {
|
|
711
|
-
ensureTask("07-02-test-task");
|
|
712
|
-
resetSessions({
|
|
713
|
-
"claude_abc.json": { active_task_path: ".cowork-flow/tasks/07-02-test-task", scope: "main", platform: "claude", last_seen_at: "2026-07-02T13:00:00Z" },
|
|
714
|
-
});
|
|
715
|
-
const runtimeDir = join(tmpRoot, ".cowork-flow", ".runtime");
|
|
716
|
-
mkdirSync(runtimeDir, { recursive: true });
|
|
717
|
-
const snapshotPath = join(runtimeDir, "state-snapshot.json");
|
|
718
|
-
writeFileSync(
|
|
719
|
-
snapshotPath,
|
|
720
|
-
JSON.stringify({
|
|
721
|
-
schemaVersion: 1,
|
|
722
|
-
generatedAt: "2026-07-02T13:05:00Z",
|
|
723
|
-
activeTaskPath: ".cowork-flow/tasks/some-other-task",
|
|
724
|
-
status: "review",
|
|
725
|
-
breadcrumbKey: "review",
|
|
726
|
-
})
|
|
727
|
-
);
|
|
728
|
-
const parsedFor = () => {
|
|
729
|
-
const result = runHook({ ZCODE_PROJECT_DIR: tmpRoot });
|
|
730
|
-
return JSON.parse(result).hookSpecificOutput.additionalContext;
|
|
731
|
-
};
|
|
732
|
-
assert.match(parsedFor(), /活动任务正在执行/, "foreign snapshot must be ignored");
|
|
733
|
-
rmSync(snapshotPath);
|
|
734
|
-
assert.match(parsedFor(), /活动任务正在执行/, "missing snapshot falls back cleanly");
|
|
735
|
-
});
|
|
736
|
-
|
|
737
|
-
test("explicit COWORK_FLOW_CONTEXT_ID resolves its raw key like the CLI", () => {
|
|
738
|
-
ensureTask("07-02-test-task");
|
|
739
|
-
resetSessions({
|
|
740
|
-
"main-session.json": { active_task_path: ".cowork-flow/tasks/07-02-test-task", scope: "main", platform: "zcode", last_seen_at: "2026-07-02T13:00:00Z" },
|
|
741
|
-
});
|
|
742
|
-
const result = runHook(
|
|
743
|
-
{ ZCODE_PROJECT_DIR: tmpRoot, COWORK_FLOW_CONTEXT_ID: "main-session" },
|
|
744
|
-
JSON.stringify({ hook_event_name: "UserPromptSubmit" })
|
|
745
|
-
);
|
|
746
|
-
const ctx = JSON.parse(result).hookSpecificOutput.additionalContext;
|
|
747
|
-
assert.match(ctx, /task="\.cowork-flow\/tasks\/07-02-test-task"/, "raw explicit key must hit the same file as the CLI");
|
|
748
|
-
});
|
|
749
|
-
|
|
750
|
-
test("PostToolUse refreshes for cd-and-bare-run command forms", () => {
|
|
751
|
-
ensureTask("07-02-test-task");
|
|
752
|
-
resetSessions({
|
|
753
|
-
"claude_abc.json": { active_task_path: ".cowork-flow/tasks/07-02-test-task", scope: "main", platform: "claude", last_seen_at: "2026-07-02T13:00:00Z" },
|
|
754
|
-
});
|
|
755
|
-
for (const command of [
|
|
756
|
-
"cd .cowork-flow && ./run task next --run",
|
|
757
|
-
".\\run.cmd task next .cowork-flow/tasks/x",
|
|
758
|
-
]) {
|
|
759
|
-
const result = runHook(
|
|
760
|
-
{ ZCODE_PROJECT_DIR: tmpRoot },
|
|
761
|
-
JSON.stringify({ hook_event_name: "PostToolUse", tool_input: { command } })
|
|
762
|
-
);
|
|
763
|
-
const ctx = JSON.parse(result).hookSpecificOutput.additionalContext;
|
|
764
|
-
assert.match(ctx, /status="in_progress"/, `refresh expected for: ${command}`);
|
|
765
|
-
}
|
|
766
|
-
});
|
|
767
|
-
|
|
768
|
-
test("rebind hints exclude terminal completed tasks", () => {
|
|
769
|
-
ensureTask("07-02-test-task");
|
|
770
|
-
ensureTask("07-01-done", "completed");
|
|
771
|
-
resetSessions({
|
|
772
|
-
"zcode_stale.json": { active_task_path: ".cowork-flow/tasks/06-21-deleted", scope: "main", platform: "zcode", last_seen_at: "2026-08-01T09:00:00Z" },
|
|
773
|
-
});
|
|
774
|
-
const result = runHook(
|
|
775
|
-
{ ZCODE_PROJECT_DIR: tmpRoot },
|
|
776
|
-
JSON.stringify({ hook_event_name: "UserPromptSubmit", session_id: "stale" })
|
|
777
|
-
);
|
|
778
|
-
const m = JSON.parse(result).hookSpecificOutput.additionalContext.match(/<workflow-state[^>]*>([\s\S]*?)<\/workflow-state>/);
|
|
779
|
-
assert.ok(m);
|
|
780
|
-
assert.match(m[1], /07-02-test-task/);
|
|
781
|
-
assert.doesNotMatch(m[1], /07-01-done/, "completed tasks are not bindable candidates");
|
|
782
|
-
});
|
|
783
|
-
|
|
784
|
-
test("cursor legacy format keeps receiving the full contract digest", () => {
|
|
785
|
-
const result = runHook({ ZCODE_PROJECT_DIR: tmpRoot, CURSOR_PLUGIN_ROOT: "/x" });
|
|
786
|
-
const ctx = JSON.parse(result).additional_context;
|
|
787
|
-
assert.match(ctx, /<contract-digest fingerprint="[a-f0-9]+">/, "legacy cursor hosts get the full block as before slimming");
|
|
788
|
-
});
|
|
789
|
-
|
|
790
|
-
} finally {
|
|
791
|
-
cleanup();
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
795
|
-
process.exit(failed > 0 ? 1 : 0);
|