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.
- package/CHANGELOG.md +31 -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/subagent.py +111 -7
- 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/runtime/session_state.py +19 -0
- 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/scripts/services/workflow_runtime.py +140 -0
- 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 +4 -5
- package/template/.cowork-flow/spec/contracts/evidence-completion.md +6 -8
- package/template/.cowork-flow/spec/contracts/index.md +1 -2
- package/template/.cowork-flow/spec/contracts/plan-binding.md +3 -2
- package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +31 -22
- 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 +2 -15
- package/template/skills/cowork-flow/SKILL.md +9 -0
- package/template/skills/runtime-health/scripts/doctor.py +57 -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
|
|
@@ -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
|
|
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)
|