cowork-flow 1.9.1 → 2.0.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 (65) hide show
  1. package/CHANGELOG.md +31 -1
  2. package/docs/architecture.md +1 -34
  3. package/docs/direction.md +1 -1
  4. package/docs/hosts.md +13 -9
  5. package/package.json +1 -1
  6. package/presets/claude-code/.claude-plugin/plugin.json +1 -1
  7. package/presets/codex/.codex-plugin/plugin.json +1 -1
  8. package/presets/dsh/plugins/workflow-state.js +31 -16
  9. package/presets/kimi-code/.kimi-plugin/plugin.json +1 -1
  10. package/presets/opencode/cowork-flow/plugin-core.js +42 -87
  11. package/presets/opencode/plugins/cowork-flow.js +7 -9
  12. package/presets/qoder/.qoder-plugin/plugin.json +1 -1
  13. package/presets/qoder/hooks/hooks.json +1 -1
  14. package/presets/zcode/.zcode-plugin/plugin.json +1 -1
  15. package/src/commands/host.js +23 -2
  16. package/src/commands/registry.js +4 -2
  17. package/template/.cowork-flow/.version +1 -1
  18. package/template/.cowork-flow/adapters/claude-code/adapter.yaml +1 -1
  19. package/template/.cowork-flow/adapters/codex/adapter.yaml +1 -1
  20. package/template/.cowork-flow/adapters/dsh/adapter.yaml +1 -1
  21. package/template/.cowork-flow/adapters/opencode/adapter.yaml +1 -1
  22. package/template/.cowork-flow/adapters/qoder/adapter.yaml +1 -1
  23. package/template/.cowork-flow/scripts/adapters/cli/subagent.py +111 -7
  24. package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +0 -1
  25. package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +0 -7
  26. package/template/.cowork-flow/scripts/adapters/host/claude_code_policy.py +6 -4
  27. package/template/.cowork-flow/scripts/adapters/host/codex_policy.py +8 -6
  28. package/template/.cowork-flow/scripts/adapters/host/edit_scope.py +113 -0
  29. package/template/.cowork-flow/scripts/adapters/host/opencode_policy.py +12 -0
  30. package/template/.cowork-flow/scripts/adapters/host/qoder_policy.py +7 -5
  31. package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +0 -91
  32. package/template/.cowork-flow/scripts/adapters/host/zcode_policy.py +15 -77
  33. package/template/.cowork-flow/scripts/run.py +0 -2
  34. package/template/.cowork-flow/scripts/runtime/session_state.py +19 -0
  35. package/template/.cowork-flow/scripts/services/context_paths.py +2 -2
  36. package/template/.cowork-flow/scripts/services/fact_view.py +49 -21
  37. package/template/.cowork-flow/scripts/services/lifecycle_checks.py +1 -216
  38. package/template/.cowork-flow/scripts/services/lifecycle_policy.py +0 -5
  39. package/template/.cowork-flow/scripts/services/task_gate_profiles.py +1 -1
  40. package/template/.cowork-flow/scripts/services/task_lifecycle.py +14 -31
  41. package/template/.cowork-flow/scripts/services/workflow_runtime.py +140 -0
  42. package/template/.cowork-flow/spec/backend/error-handling.md +0 -14
  43. package/template/.cowork-flow/spec/contracts/context-injection.md +49 -22
  44. package/template/.cowork-flow/spec/contracts/decision-anchor.md +3 -3
  45. package/template/.cowork-flow/spec/contracts/error-codes.md +4 -5
  46. package/template/.cowork-flow/spec/contracts/evidence-completion.md +6 -8
  47. package/template/.cowork-flow/spec/contracts/index.md +1 -2
  48. package/template/.cowork-flow/spec/contracts/plan-binding.md +3 -2
  49. package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +31 -22
  50. package/template/.cowork-flow/spec/contracts/task-adopt.md +1 -1
  51. package/template/.cowork-flow/spec/runtime/contract-registry.json +0 -13
  52. package/template/.cowork-flow/spec/runtime/host-assets.json +10 -7
  53. package/template/.cowork-flow/spec/runtime/scope-rules.json +2 -2
  54. package/template/.opencode/cowork-flow/plugin-core.js +42 -87
  55. package/template/.opencode/plugins/cowork-flow.js +7 -9
  56. package/template/AGENTS.md +0 -1
  57. package/template/skills/agent-dispatch/SKILL.md +2 -15
  58. package/template/skills/cowork-flow/SKILL.md +9 -0
  59. package/template/skills/runtime-health/scripts/doctor.py +57 -49
  60. package/template/skills/spec-sync/SKILL.md +0 -1
  61. package/template/skills/task-planning/SKILL.md +1 -1
  62. package/template/skills/task-review/SKILL.md +3 -3
  63. package/template/.cowork-flow/scripts/adapters/cli/spec_check.py +0 -178
  64. package/template/.cowork-flow/scripts/services/spec_check.py +0 -573
  65. package/template/.cowork-flow/spec/contracts/spec-checks.md +0 -281
@@ -13,7 +13,7 @@ capabilities:
13
13
  runtimeContextDispatch: native
14
14
  runtimeContextBinding: shim
15
15
  runtimeContextCleanup: native
16
- editScopeWarning: unsupported
16
+ editScopeWarning: plugin
17
17
  dispatch:
18
18
  primitive: task
19
19
  agentsPath: .opencode/agents
@@ -13,7 +13,7 @@ capabilities:
13
13
  runtimeContextDispatch: native
14
14
  runtimeContextBinding: shim
15
15
  runtimeContextCleanup: native
16
- editScopeWarning: unsupported
16
+ editScopeWarning: plugin
17
17
  dispatch:
18
18
  primitive: subagent
19
19
  # Host assets ship as a machine-level plugin; their layout is owned by
@@ -34,6 +34,7 @@ from services.review_receipt import ReviewReceiptError, submit_review_receipt
34
34
  from services.workflow_runtime import (
35
35
  RuntimeContextError,
36
36
  RuntimeContextService,
37
+ normalize_relative_path,
37
38
  )
38
39
  from adapters.cli.execution_context_args import build_internal_execution_context_parser
39
40
  from infra.paths import get_repo_root
@@ -201,6 +202,23 @@ def _initial_context(
201
202
  {"file": item, "reason": "prompt-named context"}
202
203
  for item in args.allowed_context
203
204
  ]
205
+ assignment: dict = {
206
+ "title": args.title,
207
+ "goal": args.goal or args.title,
208
+ "allowed_context": allowed_context,
209
+ "expected_output": args.expected_output,
210
+ "source": args.source,
211
+ }
212
+ slice_name = (getattr(args, "slice", None) or "").strip()
213
+ if slice_name:
214
+ assignment["slice"] = slice_name
215
+ owned_paths: list[str] = []
216
+ for path in getattr(args, "owns", None) or []:
217
+ normalized = normalize_relative_path(path)
218
+ if normalized:
219
+ owned_paths.append(normalized)
220
+ if owned_paths:
221
+ assignment["owned_paths"] = owned_paths
204
222
  return {
205
223
  "schema_version": 2,
206
224
  "runtime_context_id": runtime_context_id,
@@ -215,13 +233,7 @@ def _initial_context(
215
233
  "kind": "prompt",
216
234
  "key": "cowork_runtime_context_id",
217
235
  },
218
- "assignment": {
219
- "title": args.title,
220
- "goal": args.goal or args.title,
221
- "allowed_context": allowed_context,
222
- "expected_output": args.expected_output,
223
- "source": args.source,
224
- },
236
+ "assignment": assignment,
225
237
  "authority": {
226
238
  "may_start_task": False,
227
239
  "may_resume_main": False,
@@ -293,6 +305,76 @@ def cmd_status(args: argparse.Namespace) -> int:
293
305
  return 0
294
306
 
295
307
 
308
+ def cmd_prompt(args: argparse.Namespace) -> int:
309
+ """Render a paste-ready dispatch prompt for one runtime context.
310
+
311
+ Host-neutral by design: the two transport lines the runtime parses,
312
+ then generic pointers the child resolves itself via the CLI.
313
+ """
314
+ repo_root = get_repo_root()
315
+ try:
316
+ context = _find_subagent(repo_root, args.subagent_id)
317
+ except FileNotFoundError:
318
+ print(f"Error: subagent not found: {args.subagent_id}", file=sys.stderr)
319
+ return 1
320
+ assignment = context.get("assignment")
321
+ assignment = assignment if isinstance(assignment, dict) else {}
322
+ host_context_key = _suggest_host_context_key(
323
+ str(context.get("host") or ""),
324
+ args.subagent_id,
325
+ )
326
+ lines = [
327
+ f"cowork_runtime_context_id: {args.subagent_id}",
328
+ f"cowork_host_context_key: {host_context_key}",
329
+ "",
330
+ "First read your assignment; do not infer it from this prompt:",
331
+ f".cowork-flow/run subagent status {args.subagent_id}",
332
+ f"Goal: {assignment.get('goal') or context.get('title') or ''}",
333
+ f"Expected output: {assignment.get('expected_output') or ''}",
334
+ ]
335
+ slice_name = assignment.get("slice")
336
+ owned = [str(path) for path in (assignment.get("owned_paths") or [])]
337
+ if slice_name or owned:
338
+ lines.append(f"Slice: {slice_name or '(unnamed)'}")
339
+ if owned:
340
+ lines.append(f"Owned paths: {', '.join(owned)}")
341
+ print("\n".join(lines))
342
+ return 0
343
+
344
+
345
+ def cmd_list(args: argparse.Namespace) -> int:
346
+ repo_root = get_repo_root()
347
+ task_dir = getattr(args, "task", None)
348
+ if task_dir:
349
+ candidate = Path(task_dir)
350
+ if candidate.is_absolute():
351
+ try:
352
+ candidate = candidate.resolve().relative_to(repo_root.resolve())
353
+ except ValueError:
354
+ pass
355
+ task_dir = candidate.as_posix()
356
+ contexts = RuntimeContextService(repo_root).list_contexts(
357
+ task_dir=task_dir,
358
+ open_only=bool(getattr(args, "open", False)),
359
+ )
360
+ if not contexts:
361
+ print("no subagent contexts")
362
+ return 0
363
+ for context in contexts:
364
+ assignment = context.get("assignment")
365
+ assignment = assignment if isinstance(assignment, dict) else {}
366
+ owned = [str(path) for path in (assignment.get("owned_paths") or [])]
367
+ print(
368
+ f"{context.get('runtime_context_id', '?')} "
369
+ f"{context.get('status', 'unknown')} "
370
+ f"{context.get('agent_type', 'unknown')} "
371
+ f"{context.get('task_dir') or '-'} "
372
+ f"slice={assignment.get('slice') or '-'} "
373
+ f"owns={','.join(owned) or '-'}"
374
+ )
375
+ return 0
376
+
377
+
296
378
  def cmd_update(args: argparse.Namespace) -> int:
297
379
  if args.status not in VALID_STATUSES:
298
380
  print(f"Error: status must be one of {', '.join(sorted(VALID_STATUSES))}", file=sys.stderr)
@@ -421,6 +503,13 @@ def build_parser() -> argparse.ArgumentParser:
421
503
  init.add_argument("--goal")
422
504
  init.add_argument("--expected-output", default="Files changed, validation commands, and blockers.")
423
505
  init.add_argument("--allowed-context", action="append", default=[])
506
+ init.add_argument("--slice", default=None, help="Slice name this child owns")
507
+ init.add_argument(
508
+ "--owns",
509
+ action="append",
510
+ default=[],
511
+ help="Owned repository-relative path (repeatable); overlapping claims of an open implement context fail closed",
512
+ )
424
513
  init.add_argument("--host", default=None)
425
514
  init.add_argument("--adapter", default=None)
426
515
  init.set_defaults(func=cmd_init)
@@ -429,6 +518,21 @@ def build_parser() -> argparse.ArgumentParser:
429
518
  status.add_argument("subagent_id")
430
519
  status.set_defaults(func=cmd_status)
431
520
 
521
+ prompt = subparsers.add_parser(
522
+ "prompt",
523
+ help="Render a paste-ready dispatch prompt for a runtime context",
524
+ )
525
+ prompt.add_argument("subagent_id")
526
+ prompt.set_defaults(func=cmd_prompt)
527
+
528
+ listing = subparsers.add_parser(
529
+ "list",
530
+ help="List subagent runtime contexts (open-first inventory)",
531
+ )
532
+ listing.add_argument("--task", default=None, help="Only contexts of this task directory")
533
+ listing.add_argument("--open", action="store_true", help="Only contexts not yet closed")
534
+ listing.set_defaults(func=cmd_list)
535
+
432
536
  update = subparsers.add_parser("update", help="Update subagent runtime status")
433
537
  update.add_argument("subagent_id")
434
538
  update.add_argument("--status", required=True)
@@ -441,7 +441,6 @@ def cmd_complete(args: argparse.Namespace) -> int:
441
441
  result = service.complete(
442
442
  task_dir,
443
443
  execution_context=execution_context,
444
- allow_unchecked_specs=bool(getattr(args, "allow_unchecked", False)),
445
444
  allow_missing_evidence=bool(
446
445
  getattr(args, "allow_missing_evidence", False)
447
446
  ),
@@ -95,13 +95,6 @@ def _add_next_output_options(parser: argparse.ArgumentParser) -> None:
95
95
  action="store_true",
96
96
  help="Validate task context JSONL without changing task state",
97
97
  )
98
- parser.add_argument(
99
- "--allow-unchecked",
100
- dest="allow_unchecked",
101
- action="store_true",
102
- help="With task completion: allow unchecked spec-check commands "
103
- "(missing/timeout) and record the exemption in task.json meta",
104
- )
105
98
  parser.add_argument(
106
99
  "--allow-missing-evidence",
107
100
  dest="allow_missing_evidence",
@@ -4,8 +4,7 @@ workflow hook.
4
4
 
5
5
  Rendering stays in workflow_state_hook.py; this module owns only the
6
6
  claude-code transport specifics — the runtime preamble block and the
7
- PostToolUse spec-advisory path (stderr + exit 2, shared with codex via the
8
- neutral helper).
7
+ PostToolUse edit-scope advisory.
9
8
  """
10
9
 
11
10
  from __future__ import annotations
@@ -13,8 +12,8 @@ from __future__ import annotations
13
12
  from pathlib import Path
14
13
  from typing import Any
15
14
 
15
+ from adapters.host.edit_scope import edit_scope_warning
16
16
  from adapters.host.policy_base import HostPolicy
17
- from adapters.host.workflow_state_hook import spec_only_post_tool_use
18
17
 
19
18
  CLAUDE_PREAMBLE = (
20
19
  (
@@ -30,7 +29,10 @@ def preamble(root: Path) -> tuple[str, ...]:
30
29
 
31
30
 
32
31
  def post_tool_use(root: Path, hook_input: dict[str, Any]) -> tuple[str, int]:
33
- return spec_only_post_tool_use(root, hook_input, POLICY)
32
+ """PostToolUse transport: the edit-scope advisory rides the standard
33
+ additionalContext envelope, exit 0 — the edit already happened, so
34
+ this decorates the result and never blocks it."""
35
+ return edit_scope_warning(root, hook_input), 0
34
36
 
35
37
 
36
38
  POLICY = HostPolicy(
@@ -3,20 +3,19 @@
3
3
 
4
4
  Rendering stays in workflow_state_hook.py; this module owns only the codex
5
5
  transport specifics — the dispatch-mode preamble (config-driven, the codex
6
- wrapper surfaces it above every injection) and the probe-based session-start
6
+ wrapper surfaces it above every injection), the probe-based session-start
7
7
  detection (codex registers UserPromptSubmit only, so the digest shape derives
8
- from the session state file probe). PostToolUse reuses the neutral spec-only
9
- stderr path shared with claude-code.
8
+ from the session state file probe), and the PostToolUse edit-scope advisory.
10
9
  """
11
10
 
12
11
  from __future__ import annotations
13
12
 
14
- import sys
15
13
  from pathlib import Path
16
14
  from typing import Any
17
15
 
16
+ from adapters.host.edit_scope import edit_scope_warning
18
17
  from adapters.host.policy_base import HostPolicy
19
- from adapters.host.workflow_state_hook import _load_common, spec_only_post_tool_use
18
+ from adapters.host.workflow_state_hook import _load_common
20
19
 
21
20
 
22
21
  def codex_dispatch_mode(root: Path) -> str:
@@ -47,7 +46,10 @@ def preamble(root: Path) -> tuple[str, ...]:
47
46
 
48
47
 
49
48
  def post_tool_use(root: Path, hook_input: dict[str, Any]) -> tuple[str, int]:
50
- return spec_only_post_tool_use(root, hook_input, POLICY)
49
+ """PostToolUse transport: the edit-scope advisory rides the standard
50
+ additionalContext envelope, exit 0. apply_patch payloads carry their
51
+ paths inside the patch text, which the shared extractor covers."""
52
+ return edit_scope_warning(root, hook_input), 0
51
53
 
52
54
 
53
55
  POLICY = HostPolicy(
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env python3
2
+ """Shared edit-phase scope advisory (the PostToolUse short path).
3
+
4
+ At most one line when an edited file is outside the active task's
5
+ file-scope whitelist; empty string (silent) when in scope, when the task
6
+ is not in a spec-contract state, for subagent sessions, or on any error.
7
+ Hosts differ only in transport: inject.py hosts reach this through their
8
+ policy `post_tool_use` slot, the dsh preset plugin through its own Python
9
+ protocol. The wording is frozen by context-injection.md — never fork it
10
+ per host.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from adapters.host.workflow_state_hook import (
20
+ STAGE_CONTRACT_STATES,
21
+ _get_active_task_with_fallback,
22
+ _session_scope,
23
+ )
24
+
25
+ _APPLY_PATCH_FILE_RE = re.compile(
26
+ r"^\*\*\* (?:Update|Add|Delete) File: (.+?)\s*$", re.MULTILINE
27
+ )
28
+
29
+
30
+ def extract_edited_paths(tool_input: Any) -> list[str]:
31
+ """Best-effort edited file paths from a PostToolUse tool_input payload.
32
+
33
+ Hosts differ: claude-code, zcode, qoder and opencode pass file_path,
34
+ while codex's apply_patch carries the paths inside the patch text.
35
+ Returning every extractable path keeps a multi-file patch from
36
+ silently skipping the warning; deduplication keeps one edit from
37
+ checking the same file twice."""
38
+ if not isinstance(tool_input, dict):
39
+ return []
40
+ paths: list[str] = []
41
+ for key in ("file_path", "filePath", "path"):
42
+ value = tool_input.get(key)
43
+ if isinstance(value, str) and value.strip():
44
+ paths.append(value.strip())
45
+ for key in ("input", "patch", "patchText"):
46
+ text = tool_input.get(key)
47
+ if isinstance(text, str):
48
+ paths.extend(
49
+ match.strip() for match in _APPLY_PATCH_FILE_RE.findall(text)
50
+ )
51
+ seen: set[str] = set()
52
+ unique: list[str] = []
53
+ for path in paths:
54
+ if path not in seen:
55
+ seen.add(path)
56
+ unique.append(path)
57
+ return unique
58
+
59
+
60
+ def edit_scope_warning(
61
+ root: Path,
62
+ hook_input: dict[str, Any],
63
+ *,
64
+ fallback_for_unbound: bool = False,
65
+ ) -> str:
66
+ """At most one line when the edited file is outside the task's
67
+ file-scope whitelist. Silence rules: no active in_progress/review
68
+ task, a subagent session, an in-scope path, an unreadable whitelist,
69
+ or no extractable path. `fallback_for_unbound` follows the calling
70
+ host's policy (zcode: true). Never raises — the edit has already
71
+ happened."""
72
+ try:
73
+ task_path, status, _source = _get_active_task_with_fallback(
74
+ root, hook_input, fallback_for_unbound
75
+ )
76
+ except Exception:
77
+ return ""
78
+ if not task_path or status not in STAGE_CONTRACT_STATES:
79
+ return ""
80
+ if _session_scope(root, hook_input) == "subagent":
81
+ return ""
82
+ paths = extract_edited_paths(hook_input.get("tool_input"))
83
+ if not paths:
84
+ return ""
85
+ try:
86
+ from services.fact_view import file_scope_whitelist, path_in_scope
87
+
88
+ whitelist = file_scope_whitelist(root, root / task_path)
89
+ except Exception:
90
+ return ""
91
+ for file_path in paths:
92
+ # Hosts pass absolute edit paths; whitelist entries are
93
+ # repo-relative. Same normalization as run_edit_checks took.
94
+ normalized = file_path.replace("\\", "/").strip()
95
+ try:
96
+ normalized = (
97
+ Path(normalized).resolve().relative_to(root.resolve()).as_posix()
98
+ )
99
+ except (ValueError, OSError):
100
+ pass
101
+ try:
102
+ if path_in_scope(whitelist, normalized).get("inScope"):
103
+ continue
104
+ except Exception:
105
+ return ""
106
+ while normalized.startswith("./"):
107
+ normalized = normalized[2:]
108
+ return (
109
+ f"⚠️ {normalized} is outside the task's declared scope. "
110
+ "If intended, declare it in the task's `implement.jsonl` (agent-mutable) "
111
+ "or revert the edit."
112
+ )
113
+ return ""
@@ -12,6 +12,10 @@ session file probe (session_start_event=None), as with codex.
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from adapters.host.edit_scope import edit_scope_warning
15
19
  from adapters.host.policy_base import HostPolicy
16
20
 
17
21
  OPENCODE_DIGEST_POLICY = (
@@ -20,6 +24,13 @@ OPENCODE_DIGEST_POLICY = (
20
24
  )
21
25
 
22
26
 
27
+ def post_tool_use(root: Path, hook_input: dict[str, Any]) -> tuple[str, int]:
28
+ """PostToolUse transport for the plugin's tool.execute.after hook: the
29
+ edit-scope advisory is bare text (emit_text), appended to the finished
30
+ tool output by the shim. Exit 0 — never blocks the edit."""
31
+ return edit_scope_warning(root, hook_input), 0
32
+
33
+
23
34
  POLICY = HostPolicy(
24
35
  host="opencode",
25
36
  digest_policy=OPENCODE_DIGEST_POLICY,
@@ -29,4 +40,5 @@ POLICY = HostPolicy(
29
40
  emit_not_initialized=True,
30
41
  # stdout is the injection itself: the shim forwards it verbatim.
31
42
  emit_text=True,
43
+ post_tool_use=post_tool_use,
32
44
  )
@@ -3,9 +3,7 @@
3
3
 
4
4
  Rendering stays in workflow_state_hook.py; this module owns only what Qoder
5
5
  changes. Qoder shares the JSON envelope shape (`hookSpecificOutput`) with
6
- claude-code and codex, but its `PostToolUse` is not a blockable event: exit 2
7
- there is ignored, so the edit-phase spec advisory must travel as
8
- `additionalContext` on exit 0 instead of stderr.
6
+ claude-code and codex.
9
7
 
10
8
  Host assets (hooks declaration, fixed subagents, commands, skills) ship as a
11
9
  machine-level Qoder plugin, so the hook reaches this entry through the plugin
@@ -17,8 +15,9 @@ from __future__ import annotations
17
15
  from pathlib import Path
18
16
  from typing import Any
19
17
 
18
+ from adapters.host.edit_scope import edit_scope_warning
20
19
  from adapters.host.policy_base import HostPolicy
21
- from adapters.host.workflow_state_hook import hook_state_summary, spec_edit_warning
20
+ from adapters.host.workflow_state_hook import hook_state_summary
22
21
 
23
22
  QODER_PREAMBLE = (
24
23
  (
@@ -34,7 +33,10 @@ def preamble(root: Path) -> tuple[str, ...]:
34
33
 
35
34
 
36
35
  def post_tool_use(root: Path, hook_input: dict[str, Any]) -> tuple[str, int]:
37
- return spec_edit_warning(root, hook_input, POLICY), 0
36
+ """PostToolUse transport: the edit-scope advisory rides the standard
37
+ additionalContext envelope, exit 0 (never a systemMessage — that gate
38
+ stays on the two injecting events)."""
39
+ return edit_scope_warning(root, hook_input), 0
38
40
 
39
41
 
40
42
  # The task directory name is user-chosen and unbounded, and the host renders
@@ -466,97 +466,6 @@ def _stage_contract_block(
466
466
  return None
467
467
 
468
468
 
469
- _APPLY_PATCH_FILE_RE = re.compile(
470
- r"^\*\*\* (?:Update|Add|Delete) File: (.+?)\s*$", re.MULTILINE
471
- )
472
-
473
-
474
- def _extract_edited_paths(tool_input: Any) -> list[str]:
475
- """Best-effort edited file paths from a PostToolUse tool_input payload.
476
-
477
- Hosts differ: claude-code and zcode pass file_path, while codex's
478
- apply_patch carries the paths inside the patch text. Returning every
479
- extractable path keeps a multi-file patch from silently skipping
480
- checks; deduplication keeps one edit from burning the throttle window
481
- on the same file twice."""
482
- if not isinstance(tool_input, dict):
483
- return []
484
- paths: list[str] = []
485
- for key in ("file_path", "filePath", "path"):
486
- value = tool_input.get(key)
487
- if isinstance(value, str) and value.strip():
488
- paths.append(value.strip())
489
- for key in ("input", "patch", "patchText"):
490
- text = tool_input.get(key)
491
- if isinstance(text, str):
492
- paths.extend(
493
- match.strip() for match in _APPLY_PATCH_FILE_RE.findall(text)
494
- )
495
- seen: set[str] = set()
496
- unique: list[str] = []
497
- for path in paths:
498
- if path not in seen:
499
- seen.add(path)
500
- unique.append(path)
501
- return unique
502
-
503
-
504
- def spec_edit_warning(
505
- root: Path,
506
- hook_input: dict[str, Any],
507
- policy: HostPolicy,
508
- ) -> str:
509
- """Editor-phase spec-check single-line warning (PostToolUse short path).
510
-
511
- Delegated subagents are the primary coders, so spec violations must
512
- reach them too: only an active in_progress/review task is required —
513
- session scope is not consulted here (scope warnings stay main-only in
514
- the zcode policy's edit_scope_warning). Hosts with
515
- fallback_for_unbound (zcode) follow another main session's binding for
516
- their never-bound hook sessions only while exactly one main-session
517
- binding exists (see _get_active_task_with_fallback); strict-identity
518
- hosts (claude-code/codex) require the calling session's own binding.
519
- no_task, planning, and completed stay silent. Empty string means
520
- silent. Never raises — the editor path must not break edits.
521
- """
522
- try:
523
- task_path, status, _source = _get_active_task_with_fallback(
524
- root, hook_input, policy.fallback_for_unbound
525
- )
526
- except Exception:
527
- return ""
528
- if not task_path or status not in STAGE_CONTRACT_STATES:
529
- return ""
530
- paths = _extract_edited_paths(hook_input.get("tool_input"))
531
- if not paths:
532
- return ""
533
- try:
534
- from services.spec_check import run_edit_checks
535
-
536
- for path in paths:
537
- warning = run_edit_checks(root, path)
538
- if warning:
539
- return warning
540
- return ""
541
- except Exception:
542
- return ""
543
-
544
-
545
- def spec_only_post_tool_use(
546
- root: Path,
547
- hook_input: dict[str, Any],
548
- policy: HostPolicy,
549
- ) -> tuple[str, int]:
550
- """claude-code / codex PostToolUse transport: spec-check advisory on
551
- stderr only (exit 2 surfaces it to the model); the edit itself has
552
- already happened. No additionalContext payload."""
553
- warning = spec_edit_warning(root, hook_input, policy)
554
- if warning:
555
- print(warning, file=sys.stderr)
556
- return "", 2
557
- return "", 0
558
-
559
-
560
469
  def _load_breadcrumbs(root: Path) -> dict[str, str]:
561
470
  path = (
562
471
  root
@@ -4,27 +4,20 @@
4
4
  Rendering stays in workflow_state_hook.py (single source shared with the
5
5
  other hosts); this module owns only what is zcode-specific — the digest
6
6
  policy wording, the newest-session fallback for unbound hook sessions, the
7
- rebind hints and essential-files check ported from the zcode JS hook, the
8
- merged scope+spec edit warning, and the PostToolUse transport contract the
9
- zcode shim relies on.
7
+ rebind hints and essential-files check ported from the zcode JS hook, and
8
+ the PostToolUse transport contract the zcode shim relies on.
10
9
  """
11
10
 
12
11
  from __future__ import annotations
13
12
 
14
13
  import json
15
14
  import re
16
- import sys
17
15
  from pathlib import Path
18
16
  from typing import Any
19
17
 
18
+ from adapters.host.edit_scope import edit_scope_warning
20
19
  from adapters.host.policy_base import HostPolicy
21
- from adapters.host.workflow_state_hook import (
22
- STAGE_CONTRACT_STATES,
23
- _get_active_task_with_fallback,
24
- _session_scope,
25
- build_hook_context,
26
- spec_edit_warning,
27
- )
20
+ from adapters.host.workflow_state_hook import build_hook_context
28
21
 
29
22
  # Wording frozen by context-injection.md: zcode drops the
30
23
  # registry warning and asks for the fingerprint every hook.
@@ -162,72 +155,12 @@ def essential_files_warning(root: Path) -> str:
162
155
  )
163
156
 
164
157
 
165
- def edit_scope_warning(root: Path, hook_input: dict[str, Any]) -> str:
166
- """Per-edit out-of-scope warning (zcode-only capability). Port of the
167
- zcode hook's editScopeWarning: at most one line when the edited file is
168
- outside the task's file-scope whitelist. Silence rules match the JS
169
- source, including the newest-session display fallback for sessions with
170
- no resolvable identity. Never raises."""
171
- try:
172
- task_path, status, _source = _get_active_task_with_fallback(
173
- root, hook_input, POLICY.fallback_for_unbound
174
- )
175
- except Exception:
176
- return ""
177
- if not task_path or status not in STAGE_CONTRACT_STATES:
178
- return ""
179
- if _session_scope(root, hook_input) == "subagent":
180
- return ""
181
- tool_input = hook_input.get("tool_input")
182
- file_path = (
183
- tool_input.get("file_path") if isinstance(tool_input, dict) else None
184
- )
185
- if not isinstance(file_path, str) or not file_path.strip():
186
- return ""
187
- # Hosts pass absolute edit paths; whitelist entries are
188
- # repo-relative. Same normalization as run_edit_checks.
189
- normalized = file_path.replace("\\", "/").strip()
190
- try:
191
- normalized = (
192
- Path(normalized).resolve().relative_to(root.resolve()).as_posix()
193
- )
194
- except (ValueError, OSError):
195
- pass
196
- try:
197
- from services.fact_view import file_scope_whitelist, path_in_scope
198
-
199
- whitelist = file_scope_whitelist(root, root / task_path)
200
- if path_in_scope(whitelist, normalized).get("inScope"):
201
- return ""
202
- except Exception:
203
- return ""
204
- while normalized.startswith("./"):
205
- normalized = normalized[2:]
206
- return (
207
- f"⚠️ {normalized} is outside the task's declared scope. "
208
- "If intended, add it with `task context add` (agent-mutable) "
209
- "or revert the edit."
210
- )
211
-
212
-
213
- def merged_edit_warning(root: Path, hook_input: dict[str, Any]) -> str:
214
- """zcode PostToolUse Edit/Write/MultiEdit context: the scope warning and
215
- the spec-check warning merged into one additionalContext payload, at
216
- most one line each, joined by a newline (port of the zcode hook's
217
- mergedEditWarning)."""
218
- scope_line = edit_scope_warning(root, hook_input)
219
- spec_line = spec_edit_warning(root, hook_input, POLICY)
220
- if scope_line and spec_line:
221
- return f"{scope_line}\n{spec_line}"
222
- return scope_line or spec_line
223
-
224
-
225
158
  def post_tool_use(root: Path, hook_input: dict[str, Any]) -> tuple[str, int]:
226
- """zcode PostToolUse transport: Edit/Write/MultiEdit get the merged
227
- edit warning; Bash events refresh the full state only for workflow
228
- lifecycle commands (the shim pre-filters the common case, this keeps
229
- the entry correct standalone). Edit storms must not multiply the
230
- injection payload."""
159
+ """zcode PostToolUse transport: Edit/Write/MultiEdit get the shared
160
+ scope warning (with zcode's newest-session fallback); Bash events
161
+ refresh the full state only for workflow lifecycle commands (the shim
162
+ pre-filters the common case, this keeps the entry correct standalone).
163
+ Edit storms must not multiply the injection payload."""
231
164
  tool_name = str(hook_input.get("tool_name") or "")
232
165
  tool_input = hook_input.get("tool_input")
233
166
  file_path = (
@@ -238,7 +171,12 @@ def post_tool_use(root: Path, hook_input: dict[str, Any]) -> tuple[str, int]:
238
171
  and isinstance(file_path, str)
239
172
  and file_path.strip()
240
173
  ):
241
- return merged_edit_warning(root, hook_input), 0
174
+ return (
175
+ edit_scope_warning(
176
+ root, hook_input, fallback_for_unbound=POLICY.fallback_for_unbound
177
+ ),
178
+ 0,
179
+ )
242
180
  if not is_lifecycle_bash(hook_input):
243
181
  return "", 0
244
182
  claim_after_lifecycle_bash(root, hook_input)