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.
Files changed (33) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +4 -3
  3. package/package.json +1 -1
  4. package/template/.claude/hooks/inject-workflow-state.py +12 -98
  5. package/template/.codex/hooks/inject-workflow-state.py +13 -89
  6. package/template/.cowork-flow/.version +1 -1
  7. package/template/.cowork-flow/scripts/adapters/cli/task.py +4 -1
  8. package/template/.cowork-flow/scripts/adapters/cli/task_context_commands.py +65 -0
  9. package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +23 -0
  10. package/template/.cowork-flow/scripts/adapters/host/claude_code_policy.py +52 -0
  11. package/template/.cowork-flow/scripts/adapters/host/codex_policy.py +61 -0
  12. package/template/.cowork-flow/scripts/adapters/host/inject.py +249 -0
  13. package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +280 -35
  14. package/template/.cowork-flow/scripts/adapters/host/zcode_policy.py +230 -0
  15. package/template/.cowork-flow/scripts/adapters/mcp/state_server.py +2 -5
  16. package/template/.cowork-flow/scripts/runtime/session_state.py +19 -9
  17. package/template/.cowork-flow/scripts/services/context_discovery.py +18 -5
  18. package/template/.cowork-flow/scripts/services/fact_view.py +14 -0
  19. package/template/.cowork-flow/scripts/services/spec_check.py +34 -12
  20. package/template/.cowork-flow/scripts/services/task_context.py +0 -1
  21. package/template/.cowork-flow/spec/contracts/context-injection.md +24 -20
  22. package/template/.cowork-flow/spec/contracts/fact-layer-access.md +43 -8
  23. package/template/.cowork-flow/spec/contracts/spec-checks.md +40 -8
  24. package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +21 -0
  25. package/template/.cowork-flow/spec/runtime/contract-registry.json +12 -0
  26. package/template/.opencode/plugins/cowork-flow.js +12 -0
  27. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  28. package/template/.zcode/hooks/inject-context.js +95 -971
  29. package/template/skills/agent-dispatch/SKILL.md +15 -0
  30. package/template/skills/runtime-health/scripts/doctor.py +90 -0
  31. package/template/skills/task-planning/SKILL.md +1 -0
  32. package/template/skills/task-review/SKILL.md +1 -0
  33. 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
- * Reads .cowork-flow/ state, parses workflow-state-templates.md, computes contract digest.
5
- * Project files are installed by explicit cowork-flow init/sync, not by hook scaffolding.
6
- * Output: ZCode/Claude Code hook format (stdout JSON).
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 { readFileSync, existsSync, readdirSync } from "fs";
10
- import { join, dirname } from "path";
11
- import { createHash } from "crypto";
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 DIR_WORKFLOW = ".cowork-flow";
15
- const FILE_TASK_JSON = "task.json";
25
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
26
 
17
- const TAG_RE = /\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n(.*?)\n\s*\[\/workflow-state:\1\]/gs;
18
- const RUNTIME_CONTEXT_PROMPT_RE = /^\s*cowork_runtime_context_id\s*:\s*([A-Za-z0-9._-]+)\s*$/gim;
19
- const DEFAULT_CONTRACT_REGISTRY = {
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
- const raw = readFileSync(0, "utf8").trim();
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
- function detectEventName(input) {
46
- if (typeof input?.hook_event_name === "string" && input.hook_event_name.trim()) {
47
- return input.hook_event_name.trim();
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(input) {
58
+ function findProjectRoot(parsedInput) {
74
59
  const candidates = [
75
- input?.cwd,
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("&", "&amp;")
404
- .replaceAll("<", "&lt;")
405
- .replaceAll(">", "&gt;")
406
- .replaceAll('"', "&quot;");
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 spawnSpecCheckEdit(root, filePath) {
858
- const script = join(
859
- root,
860
- DIR_WORKFLOW,
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
- "cli",
864
- "spec_check.py"
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 (process.env.COWORK_FLOW_HOOKS === "0" || process.env.COWORK_FLOW_DISABLE_HOOKS === "1") {
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 input = readHookInput();
116
+ const { raw, parsed } = readHookInput();
948
117
 
949
- // Mid-turn refresh: only lifecycle commands may re-inject state; every
950
- // other Bash call stays silent so tool streams are not spammed.
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 input?.hook_event_name === "string" &&
953
- input.hook_event_name.trim() === "PostToolUse"
122
+ typeof parsed?.hook_event_name === "string" &&
123
+ parsed.hook_event_name.trim() === "PostToolUse" &&
124
+ String(parsed?.tool_name || "") === "Bash"
954
125
  ) {
955
- // Edit-scope short path: one warning line at most, never the full block
956
- // (an edit storm must not multiply the whole injection payload).
957
- const toolName = String(input?.tool_name || "");
958
- const editedPath = input?.tool_input?.file_path;
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(".cowork-flow/run") &&
972
- !/\brun(?:\.cmd)?\s+(?:task|subagent|resume)\b/.test(command)
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 event = detectEventName(input);
979
- const format = outputFormat();
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
- 请通过显式 init/sync 安装 cowork-flow 模板后再继续;hook 不会在注入阶段创建或复制项目文件。
994
- </workflow-state>`;
995
- } else {
996
- // PRIORITY 1: Detect delegated_subtask from runtime context
997
- const userPrompt = typeof input.prompt === "string" ? input.prompt : "";
998
- const delegated = detectDelegatedSubtask(effectiveRoot, userPrompt);
999
- if (delegated) {
1000
- context = buildDelegatedSubtask(delegated.contextId, delegated.ctx, effectiveRoot);
1001
- } else {
1002
- // PRIORITY 2: Normal session workflow state
1003
- const activeTask = readActiveTask(effectiveRoot, resolveSessionKey(input));
1004
- const breadcrumbs = loadBreadcrumbs(effectiveRoot);
1005
- context = buildContext(effectiveRoot, activeTask, breadcrumbs);
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
- const missingFiles = effectiveRoot ? checkEssentialFiles(effectiveRoot) : [];
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