cowork-flow 1.9.2 → 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 (61) hide show
  1. package/CHANGELOG.md +23 -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/task_lifecycle_commands.py +0 -1
  24. package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +0 -7
  25. package/template/.cowork-flow/scripts/adapters/host/claude_code_policy.py +6 -4
  26. package/template/.cowork-flow/scripts/adapters/host/codex_policy.py +8 -6
  27. package/template/.cowork-flow/scripts/adapters/host/edit_scope.py +113 -0
  28. package/template/.cowork-flow/scripts/adapters/host/opencode_policy.py +12 -0
  29. package/template/.cowork-flow/scripts/adapters/host/qoder_policy.py +7 -5
  30. package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +0 -91
  31. package/template/.cowork-flow/scripts/adapters/host/zcode_policy.py +15 -77
  32. package/template/.cowork-flow/scripts/run.py +0 -2
  33. package/template/.cowork-flow/scripts/services/context_paths.py +2 -2
  34. package/template/.cowork-flow/scripts/services/fact_view.py +49 -21
  35. package/template/.cowork-flow/scripts/services/lifecycle_checks.py +1 -216
  36. package/template/.cowork-flow/scripts/services/lifecycle_policy.py +0 -5
  37. package/template/.cowork-flow/scripts/services/task_gate_profiles.py +1 -1
  38. package/template/.cowork-flow/scripts/services/task_lifecycle.py +14 -31
  39. package/template/.cowork-flow/spec/backend/error-handling.md +0 -14
  40. package/template/.cowork-flow/spec/contracts/context-injection.md +49 -22
  41. package/template/.cowork-flow/spec/contracts/decision-anchor.md +3 -3
  42. package/template/.cowork-flow/spec/contracts/error-codes.md +1 -3
  43. package/template/.cowork-flow/spec/contracts/evidence-completion.md +6 -8
  44. package/template/.cowork-flow/spec/contracts/index.md +0 -1
  45. package/template/.cowork-flow/spec/contracts/plan-binding.md +3 -2
  46. package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +6 -21
  47. package/template/.cowork-flow/spec/contracts/task-adopt.md +1 -1
  48. package/template/.cowork-flow/spec/runtime/contract-registry.json +0 -13
  49. package/template/.cowork-flow/spec/runtime/host-assets.json +10 -7
  50. package/template/.cowork-flow/spec/runtime/scope-rules.json +2 -2
  51. package/template/.opencode/cowork-flow/plugin-core.js +42 -87
  52. package/template/.opencode/plugins/cowork-flow.js +7 -9
  53. package/template/AGENTS.md +0 -1
  54. package/template/skills/agent-dispatch/SKILL.md +0 -15
  55. package/template/skills/runtime-health/scripts/doctor.py +0 -49
  56. package/template/skills/spec-sync/SKILL.md +0 -1
  57. package/template/skills/task-planning/SKILL.md +1 -1
  58. package/template/skills/task-review/SKILL.md +3 -3
  59. package/template/.cowork-flow/scripts/adapters/cli/spec_check.py +0 -178
  60. package/template/.cowork-flow/scripts/services/spec_check.py +0 -573
  61. 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: subagent
19
19
  # Host assets ship as a machine-level plugin; their layout is owned by
@@ -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)
@@ -32,7 +32,6 @@ COMMAND_SCRIPTS = {
32
32
  "state": "adapters/cli/state.py",
33
33
  "mcp-state": "adapters/mcp/state_server.py",
34
34
  "subagent": "adapters/cli/subagent.py",
35
- "spec-check": "adapters/cli/spec_check.py",
36
35
  }
37
36
  RESERVED_COMMAND_NAMES = (*COMMAND_SCRIPTS, "python", "help", "-h", "--help")
38
37
 
@@ -51,7 +50,6 @@ Common commands:
51
50
  resume
52
51
  task
53
52
  state
54
- spec-check
55
53
  get-context
56
54
  get-developer
57
55
  init-developer
@@ -24,10 +24,10 @@ DEFAULT_SCOPE_RULES: dict = {
24
24
  "trailingSlashRejectedTypes": ["planned-file", "deleted-file"],
25
25
  },
26
26
  "stageContract": {
27
- "budget": 1200,
27
+ "budget": 3400,
28
28
  "scopeLimit": 8,
29
29
  "specLimit": 4,
30
- "verifyLimit": 3,
30
+ "verifyLimit": 10,
31
31
  },
32
32
  }
33
33
 
@@ -19,7 +19,7 @@ from typing import Any
19
19
 
20
20
  GOAL_TRUNCATE = 300
21
21
  VERIFY_COMMAND_TRUNCATE = 120
22
- VERIFY_COMMAND_MAX = 5
22
+ VERIFY_COMMAND_MAX = 10
23
23
  SCOPE_BOUNDARY_TRUNCATE = 160
24
24
  ANCHOR_SECTIONS = ("目标", "验收标准", "被拒方案", "验证命令", "范围边界")
25
25
  ACCEPTANCE_RE = re.compile(
@@ -225,12 +225,12 @@ def spec_entries_for_task(
225
225
 
226
226
  STAGE_CONTRACT_SCOPE_LIMIT = 8
227
227
  STAGE_CONTRACT_SPECS_LIMIT = 4
228
- STAGE_CONTRACT_VERIFY_LIMIT = 3
229
- STAGE_CONTRACT_BUDGET = 1200
228
+ STAGE_CONTRACT_VERIFY_LIMIT = 10
229
+ STAGE_CONTRACT_BUDGET = 3400
230
230
  GATES_TEXT = (
231
231
  "Gates: edits outside Scope are review blockers; CLAUDE.md and workflow "
232
232
  "files are protected; spec/ edits may be allowed by review policy; "
233
- "scope is agent-mutable (self-declared via task context add)"
233
+ "scope is agent-mutable (declare more in implement.jsonl)"
234
234
  )
235
235
  # Delegated subtasks render the parent scope as a read-only
236
236
  # reference: a child must not believe it can self-declare
@@ -257,11 +257,14 @@ def _fit_stage_contract(
257
257
  budget: int = STAGE_CONTRACT_BUDGET,
258
258
  ) -> list[str]:
259
259
  """Degrade an over-budget block without ever emitting a malformed one:
260
- the closing tag and the guard rows (Scope/Gates) always survive. Order:
261
- 1) drop removable rows (Specs/Verify) from the tail; 2) shrink Scope
262
- entries one by one (min 1); 3) last-resort codepoint cut that keeps the
263
- closing tag. Single renderer: the host shims transport this output
264
- unchanged, so the row-role rules and the drop order live here only."""
260
+ the opening tag, the closing tag and the guard rows (Scope/Gates) always
261
+ survive intact. Order: 1) drop removable rows (Specs/Verify) from the
262
+ tail; 2) shrink Scope entries one by one (min 1); 3) `_cut_scope_rows`
263
+ shortens the Scope rows' own text, and nothing else -- it returns None
264
+ when even that cannot keep the block usable, and the block is then
265
+ returned as is, over budget (guards and parseability outrank it).
266
+ Single renderer: the host shims transport this output unchanged, so the
267
+ row-role rules and the drop order live here only."""
265
268
  if len("\n".join(lines)) <= budget:
266
269
  return lines
267
270
  removable = [
@@ -289,18 +292,43 @@ def _fit_stage_contract(
289
292
  lines = candidate
290
293
  if len("\n".join(lines)) <= budget:
291
294
  return lines
292
- closing = lines[-1]
293
- # The final join inserts one newline between the cut body
294
- # and the closing tag; reserve it so the block stays
295
- # within budget byte-for-byte.
296
- room = budget - len(closing) - 1
297
- body = "\n".join(lines[:-1])
298
- if len(body) <= room:
299
- return lines
300
- head = body[:room].rstrip()
301
- if not head:
302
- return lines
303
- return [head, closing]
295
+ cut = _cut_scope_rows(lines, budget)
296
+ return lines if cut is None else cut
297
+
298
+
299
+ def _cut_scope_rows(lines: list[str], budget: int) -> list[str] | None:
300
+ """Last resort: shorten the text of every Scope row so the block fits.
301
+
302
+ Returns None -- the caller then returns the block whole and lets it
303
+ exceed the budget -- in three cases: there is no Scope row to cut at all
304
+ (a shape the renderer never emits), the fixed rows plus a minimal
305
+ `Scope: X` row do not fit, or the cut would shave a `Scope: ` label off a
306
+ row (`rstrip` landing inside the entry's leading whitespace). A body-wide
307
+ cut is never allowed: it would truncate the tag every consumer matches
308
+ on, and a label-less row counts as a dropped guard row. The floor is not
309
+ redundant with the label check -- a negative `room` slices from the row's
310
+ tail, so without it a long row would go through chopped and still over
311
+ budget. Guard rows and parseability outrank the budget.
312
+ """
313
+ fixed = [line for line in lines if not line.startswith("Scope:")]
314
+ scope_count = len(lines) - len(fixed)
315
+ if scope_count == 0:
316
+ return None
317
+ # Every Scope row is shortened to the same width, so the room left after
318
+ # the fixed rows and the separators is shared between them.
319
+ room = (budget - len("\n".join(fixed)) - scope_count) // scope_count
320
+ if room < len("Scope: ") + 1:
321
+ return None
322
+ cut = [
323
+ line[:room].rstrip() if line.startswith("Scope:") else line
324
+ for line in lines
325
+ ]
326
+ if any(
327
+ original.startswith("Scope:") and not shortened.startswith("Scope: ")
328
+ for original, shortened in zip(lines, cut)
329
+ ):
330
+ return None
331
+ return cut
304
332
 
305
333
 
306
334
  def _stage_contract_lines(