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.
- package/CHANGELOG.md +23 -1
- package/docs/architecture.md +1 -34
- package/docs/direction.md +1 -1
- package/docs/hosts.md +13 -9
- package/package.json +1 -1
- package/presets/claude-code/.claude-plugin/plugin.json +1 -1
- package/presets/codex/.codex-plugin/plugin.json +1 -1
- package/presets/dsh/plugins/workflow-state.js +31 -16
- package/presets/kimi-code/.kimi-plugin/plugin.json +1 -1
- package/presets/opencode/cowork-flow/plugin-core.js +42 -87
- package/presets/opencode/plugins/cowork-flow.js +7 -9
- package/presets/qoder/.qoder-plugin/plugin.json +1 -1
- package/presets/qoder/hooks/hooks.json +1 -1
- package/presets/zcode/.zcode-plugin/plugin.json +1 -1
- package/src/commands/host.js +23 -2
- package/src/commands/registry.js +4 -2
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/adapters/claude-code/adapter.yaml +1 -1
- package/template/.cowork-flow/adapters/codex/adapter.yaml +1 -1
- package/template/.cowork-flow/adapters/dsh/adapter.yaml +1 -1
- package/template/.cowork-flow/adapters/opencode/adapter.yaml +1 -1
- package/template/.cowork-flow/adapters/qoder/adapter.yaml +1 -1
- package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +0 -1
- package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +0 -7
- package/template/.cowork-flow/scripts/adapters/host/claude_code_policy.py +6 -4
- package/template/.cowork-flow/scripts/adapters/host/codex_policy.py +8 -6
- package/template/.cowork-flow/scripts/adapters/host/edit_scope.py +113 -0
- package/template/.cowork-flow/scripts/adapters/host/opencode_policy.py +12 -0
- package/template/.cowork-flow/scripts/adapters/host/qoder_policy.py +7 -5
- package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +0 -91
- package/template/.cowork-flow/scripts/adapters/host/zcode_policy.py +15 -77
- package/template/.cowork-flow/scripts/run.py +0 -2
- package/template/.cowork-flow/scripts/services/context_paths.py +2 -2
- package/template/.cowork-flow/scripts/services/fact_view.py +49 -21
- package/template/.cowork-flow/scripts/services/lifecycle_checks.py +1 -216
- package/template/.cowork-flow/scripts/services/lifecycle_policy.py +0 -5
- package/template/.cowork-flow/scripts/services/task_gate_profiles.py +1 -1
- package/template/.cowork-flow/scripts/services/task_lifecycle.py +14 -31
- package/template/.cowork-flow/spec/backend/error-handling.md +0 -14
- package/template/.cowork-flow/spec/contracts/context-injection.md +49 -22
- package/template/.cowork-flow/spec/contracts/decision-anchor.md +3 -3
- package/template/.cowork-flow/spec/contracts/error-codes.md +1 -3
- package/template/.cowork-flow/spec/contracts/evidence-completion.md +6 -8
- package/template/.cowork-flow/spec/contracts/index.md +0 -1
- package/template/.cowork-flow/spec/contracts/plan-binding.md +3 -2
- package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +6 -21
- package/template/.cowork-flow/spec/contracts/task-adopt.md +1 -1
- package/template/.cowork-flow/spec/runtime/contract-registry.json +0 -13
- package/template/.cowork-flow/spec/runtime/host-assets.json +10 -7
- package/template/.cowork-flow/spec/runtime/scope-rules.json +2 -2
- package/template/.opencode/cowork-flow/plugin-core.js +42 -87
- package/template/.opencode/plugins/cowork-flow.js +7 -9
- package/template/AGENTS.md +0 -1
- package/template/skills/agent-dispatch/SKILL.md +0 -15
- package/template/skills/runtime-health/scripts/doctor.py +0 -49
- package/template/skills/spec-sync/SKILL.md +0 -1
- package/template/skills/task-planning/SKILL.md +1 -1
- package/template/skills/task-review/SKILL.md +3 -3
- package/template/.cowork-flow/scripts/adapters/cli/spec_check.py +0 -178
- package/template/.cowork-flow/scripts/services/spec_check.py +0 -573
- 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:
|
|
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
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
8
|
-
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
the entry correct standalone).
|
|
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
|
|
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":
|
|
27
|
+
"budget": 3400,
|
|
28
28
|
"scopeLimit": 8,
|
|
29
29
|
"specLimit": 4,
|
|
30
|
-
"verifyLimit":
|
|
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 =
|
|
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 =
|
|
229
|
-
STAGE_CONTRACT_BUDGET =
|
|
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 (
|
|
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
|
|
261
|
-
1) drop removable rows (Specs/Verify) from the
|
|
262
|
-
entries one by one (min 1); 3)
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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(
|