cowork-flow 1.3.0 → 1.4.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 +12 -0
- package/package.json +1 -1
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +58 -26
- package/template/.cowork-flow/scripts/adapters/host/zcode_policy.py +55 -0
- package/template/.cowork-flow/scripts/adapters/mcp/state_server.py +32 -6
- package/template/.cowork-flow/scripts/runtime/session_state.py +35 -0
- package/template/.cowork-flow/spec/contracts/context-injection.md +16 -3
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.0 - 2026-09-14
|
|
4
|
+
|
|
5
|
+
### Hook 会话身份绑定(fix(runtime))
|
|
6
|
+
|
|
7
|
+
实弹暴露的多窗口交叉读取问题:zcode hook 会话携带对话自身的 session id,而任务激活发生在 Bash CLI 的显式身份下,导致未绑定的 hook 会话回退到 newest-session 时可能**读到另一个窗口的任务**。本版把绑定与回退语义一起收紧:
|
|
8
|
+
|
|
9
|
+
- **激活即认领**:PostToolUse 在激活类命令(`task start` / `task next <dir> --run` / `resume <dir>`)之后,把 hook 会话自己的 `zcode_<sessionId>` 绑定认领下来(`session_state.claim_active_task`);查询类命令与不可信身份(process-fallback/缺失)永不认领。
|
|
10
|
+
- **回退语义收紧**:`fallback_for_unbound` 仅在**恰好存在一个** main-session 绑定时跟随最新的那个;存在多个绑定时,未绑定 hook 会话渲染 no_task + rebind 提示,绝不交叉读取其他窗口。
|
|
11
|
+
- **会话身份显式化**:workflow-state 头部新增 `session=<contextKey>` 属性,模型可将其作为 `COWORK_FLOW_CONTEXT_ID` 传给后续 CLI 调用,打通 hook 会话与 CLI 身份。
|
|
12
|
+
- **MCP 拒绝隐式冒认**:`task_state` / `task_scope` / `task_list` 在不可信身份下拒绝隐式解析(`identity-untrusted`),不再冒认 process-fallback 任务;CLI `FALLBACK_BINDING_BLOCKER` 语义不变(回归护栏锁定)。
|
|
13
|
+
- 契约文档 context-injection.md 同步记录 session 属性与回退语义;新增 pytest 396 行覆盖(active_task_runtime / inject_entry / mcp_state_server / workflow_state_hook)。
|
|
14
|
+
|
|
3
15
|
## 1.3.0 - 2026-09-09
|
|
4
16
|
|
|
5
17
|
### 跨宿主适配通用化(注入单源化 + MCP 重定位)
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.4.0
|
|
@@ -239,7 +239,7 @@ def build_hook_context(
|
|
|
239
239
|
if contract_block:
|
|
240
240
|
blocks.append(contract_block)
|
|
241
241
|
blocks.append(
|
|
242
|
-
f"<workflow-state{_workflow_state_attrs(task_path, status, source)}>"
|
|
242
|
+
f"<workflow-state{_workflow_state_attrs(task_path, status, source, session=_resolve_session_identity(root, hook_input))}>"
|
|
243
243
|
f"\n{body}\n</workflow-state>"
|
|
244
244
|
)
|
|
245
245
|
context = "\n\n".join(blocks)
|
|
@@ -279,14 +279,38 @@ def _xml_attr(value: Any) -> str:
|
|
|
279
279
|
return xml_attr(value)
|
|
280
280
|
|
|
281
281
|
|
|
282
|
+
def _resolve_session_identity(
|
|
283
|
+
root: Path, hook_input: dict[str, Any]
|
|
284
|
+
) -> str | None:
|
|
285
|
+
"""The caller's resolved context key for the session attribute
|
|
286
|
+
(context-injection.md, stage 1). None when no identity is resolvable —
|
|
287
|
+
the attribute is omitted rather than guessed."""
|
|
288
|
+
_load_common(root)
|
|
289
|
+
try:
|
|
290
|
+
from runtime.session_state import resolve_context_key
|
|
291
|
+
except Exception:
|
|
292
|
+
return None
|
|
293
|
+
try:
|
|
294
|
+
return resolve_context_key(hook_input)
|
|
295
|
+
except Exception:
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
|
|
282
299
|
def _workflow_state_attrs(
|
|
283
|
-
task_path: str | None,
|
|
300
|
+
task_path: str | None,
|
|
301
|
+
status: str,
|
|
302
|
+
source: str,
|
|
303
|
+
session: str | None = None,
|
|
284
304
|
) -> str:
|
|
285
305
|
"""Structured fact header (context-injection.md, stage 1): the machine
|
|
286
|
-
picks task/status/source off the attributes; humans read the
|
|
306
|
+
picks task/status/source/session off the attributes; humans read the
|
|
307
|
+
body. session is appended last and only when a session identity was
|
|
308
|
+
resolvable from the hook input or environment."""
|
|
287
309
|
attrs = [f'status="{_xml_attr(status)}"', f'source="{_xml_attr(source)}"']
|
|
288
310
|
if task_path:
|
|
289
311
|
attrs.insert(0, f'task="{_xml_attr(task_path)}"')
|
|
312
|
+
if session:
|
|
313
|
+
attrs.append(f'session="{_xml_attr(session)}"')
|
|
290
314
|
return "".join(f" {attr}" for attr in attrs)
|
|
291
315
|
|
|
292
316
|
|
|
@@ -422,11 +446,12 @@ def spec_edit_warning(
|
|
|
422
446
|
reach them too: only an active in_progress/review task is required —
|
|
423
447
|
session scope is not consulted here (scope warnings stay main-only in
|
|
424
448
|
the zcode policy's edit_scope_warning). Hosts with
|
|
425
|
-
fallback_for_unbound (zcode) follow
|
|
426
|
-
never-bound hook sessions
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
449
|
+
fallback_for_unbound (zcode) follow another main session's binding for
|
|
450
|
+
their never-bound hook sessions only while exactly one main-session
|
|
451
|
+
binding exists (see _get_active_task_with_fallback); strict-identity
|
|
452
|
+
hosts (claude-code/codex) require the calling session's own binding.
|
|
453
|
+
no_task, planning, and completed stay silent. Empty string means
|
|
454
|
+
silent. Never raises — the editor path must not break edits.
|
|
430
455
|
"""
|
|
431
456
|
try:
|
|
432
457
|
task_path, status, _source = _get_active_task_with_fallback(
|
|
@@ -605,16 +630,18 @@ def _get_active_task_with_fallback(
|
|
|
605
630
|
hook_input: dict[str, Any],
|
|
606
631
|
fallback_for_unbound: bool,
|
|
607
632
|
) -> tuple[str | None, str, str]:
|
|
608
|
-
"""zcode parity: a hook session that carries no binding follows
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
633
|
+
"""zcode parity: a hook session that carries no binding follows another
|
|
634
|
+
main session's binding — but only when exactly one main-session binding
|
|
635
|
+
exists. Two sources qualify for the fallback: no session identity at all
|
|
636
|
+
(missing-context), and a session id that resolves but was never bound
|
|
637
|
+
(empty-session) — the zcode hook always carries the conversation's own
|
|
638
|
+
session id, while task activation happens in the Bash CLI under a
|
|
639
|
+
separate explicit identity, so the hook session file may not exist yet.
|
|
640
|
+
Gated by the host policy's fallback_for_unbound flag; display/warning-only
|
|
641
|
+
— the CLI lifecycle commands keep their strict identity semantics. With
|
|
642
|
+
two or more main-session bindings the newest one belongs to some other
|
|
643
|
+
window, so the fallback refuses and the caller renders no_task plus the
|
|
644
|
+
rebind hints instead."""
|
|
618
645
|
task_path, status, source = _get_active_task(root, hook_input)
|
|
619
646
|
if (
|
|
620
647
|
not fallback_for_unbound
|
|
@@ -622,9 +649,10 @@ def _get_active_task_with_fallback(
|
|
|
622
649
|
or source not in {"missing-context", "empty-session"}
|
|
623
650
|
):
|
|
624
651
|
return task_path, status, source
|
|
625
|
-
|
|
626
|
-
if
|
|
652
|
+
candidates = _main_session_task_candidates(root)
|
|
653
|
+
if len(candidates) != 1:
|
|
627
654
|
return task_path, status, source
|
|
655
|
+
fallback = candidates[0]
|
|
628
656
|
task_json = root / fallback / "task.json"
|
|
629
657
|
try:
|
|
630
658
|
data = json.loads(task_json.read_text(encoding="utf-8"))
|
|
@@ -636,19 +664,22 @@ def _get_active_task_with_fallback(
|
|
|
636
664
|
return fallback, status.strip(), "session-fallback"
|
|
637
665
|
|
|
638
666
|
|
|
639
|
-
def
|
|
667
|
+
def _main_session_task_candidates(root: Path) -> list[str]:
|
|
668
|
+
"""All main-scope session bindings whose task directory is still valid,
|
|
669
|
+
newest first. Subagent scopes never qualify: delegated sessions follow
|
|
670
|
+
their runtime context, not another session's file."""
|
|
640
671
|
try:
|
|
641
672
|
from runtime.session_state import sessions_dir
|
|
642
673
|
except Exception:
|
|
643
|
-
return
|
|
674
|
+
return []
|
|
644
675
|
sessions = sessions_dir(root)
|
|
645
676
|
if not sessions.is_dir():
|
|
646
|
-
return
|
|
677
|
+
return []
|
|
647
678
|
entries: list[tuple[str, str]] = []
|
|
648
679
|
try:
|
|
649
680
|
paths = sorted(sessions.glob("*.json"))
|
|
650
681
|
except OSError:
|
|
651
|
-
return
|
|
682
|
+
return []
|
|
652
683
|
for path in paths:
|
|
653
684
|
try:
|
|
654
685
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
@@ -665,11 +696,12 @@ def _newest_session_task(root: Path) -> str | None:
|
|
|
665
696
|
(str(data.get("last_seen_at") or ""), task_path.strip())
|
|
666
697
|
)
|
|
667
698
|
entries.sort(reverse=True)
|
|
699
|
+
candidates: list[str] = []
|
|
668
700
|
for _seen_at, task_path in entries:
|
|
669
701
|
task_dir = root / task_path
|
|
670
702
|
if task_dir.is_dir() and (task_dir / "task.json").is_file():
|
|
671
|
-
|
|
672
|
-
return
|
|
703
|
+
candidates.append(task_path)
|
|
704
|
+
return candidates
|
|
673
705
|
|
|
674
706
|
|
|
675
707
|
def _session_scope(root: Path, hook_input: dict[str, Any]) -> str:
|
|
@@ -55,6 +55,60 @@ def is_lifecycle_bash(hook_input: dict[str, Any]) -> bool:
|
|
|
55
55
|
)
|
|
56
56
|
|
|
57
57
|
|
|
58
|
+
# Activation intent inside a lifecycle Bash command: `task next <dir> --run`,
|
|
59
|
+
# `task start <dir>`, `resume <dir>` (the run prefix is optional). Query
|
|
60
|
+
# forms (`task next --json`, `task next --run` without a dir) match nothing
|
|
61
|
+
# claimable — the captured token must be a task dir whose task.json exists.
|
|
62
|
+
ACTIVATION_COMMAND_RE = re.compile(
|
|
63
|
+
r"\btask\s+next\s+(?P<next>\S+)\s+--run\b"
|
|
64
|
+
r"|\btask\s+start\s+(?P<start>\S+?)(?=\s|$)"
|
|
65
|
+
r"|(?<![\w./-])resume\s+(?P<resume>\S+?)(?=\s|$)"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _resolvable_task_path(root: Path, token: str) -> str | None:
|
|
70
|
+
candidates = [token]
|
|
71
|
+
if not token.startswith(".cowork-flow/"):
|
|
72
|
+
candidates.append(f".cowork-flow/tasks/{token}")
|
|
73
|
+
for candidate in candidates:
|
|
74
|
+
normalized = candidate.replace("\\", "/").strip("/")
|
|
75
|
+
if (root / normalized / "task.json").is_file():
|
|
76
|
+
return normalized
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def claim_after_lifecycle_bash(root: Path, hook_input: dict[str, Any]) -> None:
|
|
81
|
+
"""Bind the activated task to this hook session's own identity.
|
|
82
|
+
|
|
83
|
+
The lifecycle Bash CLI resolves a process-fallback identity, so the
|
|
84
|
+
binding it writes is shared across windows; PostToolUse re-binds with the
|
|
85
|
+
hook input's session id via session_state.claim_active_task (explicit /
|
|
86
|
+
host_session provenance only). Commands without an activation intent or
|
|
87
|
+
with an unresolvable task dir claim nothing, and claim failure stays
|
|
88
|
+
silent — the refresh path below still renders the state."""
|
|
89
|
+
tool_input = hook_input.get("tool_input")
|
|
90
|
+
command = (
|
|
91
|
+
tool_input.get("command") if isinstance(tool_input, dict) else None
|
|
92
|
+
)
|
|
93
|
+
if not isinstance(command, str):
|
|
94
|
+
return
|
|
95
|
+
match = ACTIVATION_COMMAND_RE.search(command.replace("\\", "/"))
|
|
96
|
+
if match is None:
|
|
97
|
+
return
|
|
98
|
+
token = next(value for value in match.groupdict().values() if value)
|
|
99
|
+
if token.startswith("-"):
|
|
100
|
+
return
|
|
101
|
+
task_path = _resolvable_task_path(root, token)
|
|
102
|
+
if task_path is None:
|
|
103
|
+
return
|
|
104
|
+
try:
|
|
105
|
+
from runtime.session_state import claim_active_task
|
|
106
|
+
|
|
107
|
+
claim_active_task(root, task_path, hook_input)
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
|
|
58
112
|
def zcode_session_alias(data: dict[str, Any]) -> dict[str, Any]:
|
|
59
113
|
# Ported from the zcode hook's resolveSessionKey candidate order
|
|
60
114
|
# (env wins inside session_state; input aliases only fill the gap).
|
|
@@ -202,6 +256,7 @@ def post_tool_use(root: Path, hook_input: dict[str, Any]) -> tuple[str, int]:
|
|
|
202
256
|
return merged_edit_warning(root, hook_input), 0
|
|
203
257
|
if not is_lifecycle_bash(hook_input):
|
|
204
258
|
return "", 0
|
|
259
|
+
claim_after_lifecycle_bash(root, hook_input)
|
|
205
260
|
from adapters.host.workflow_state_hook import build_hook_context
|
|
206
261
|
|
|
207
262
|
context = build_hook_context(
|
|
@@ -99,16 +99,36 @@ TOOLS = [
|
|
|
99
99
|
]
|
|
100
100
|
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
TRUSTED_PROVENANCE = ("explicit", "host_session")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _implicit_active_task(root: Path) -> tuple[str | None, str]:
|
|
106
|
+
"""Implicit resolution (no explicit `task` argument): the caller's own
|
|
107
|
+
session-bound active task. A process-fallback identity is shared across
|
|
108
|
+
every window on the host and a missing identity is no identity at all —
|
|
109
|
+
neither may impersonate "the current task", so both return no task with
|
|
110
|
+
an identity-untrusted reason. Returns (task_path, no_task_reason)."""
|
|
103
111
|
from runtime.session_state import get_active_task
|
|
112
|
+
|
|
113
|
+
active = get_active_task(root)
|
|
114
|
+
if active.provenance not in TRUSTED_PROVENANCE:
|
|
115
|
+
return None, (
|
|
116
|
+
f"no-active-task (identity-untrusted: {active.provenance})"
|
|
117
|
+
)
|
|
118
|
+
if not active.task_path:
|
|
119
|
+
return None, "no-active-task"
|
|
120
|
+
return active.task_path, "no-active-task"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _tool_task_state(root: Path, arguments: dict) -> dict:
|
|
104
124
|
from services.fact_view import build_fact_view
|
|
105
125
|
from services.task_repository import TaskRepository, TaskRepositoryError
|
|
106
126
|
|
|
107
127
|
target = arguments.get("task")
|
|
108
128
|
if not target:
|
|
109
|
-
target =
|
|
129
|
+
target, reason = _implicit_active_task(root)
|
|
110
130
|
if not target:
|
|
111
|
-
return {"schemaVersion": 1, "task": None, "reason":
|
|
131
|
+
return {"schemaVersion": 1, "task": None, "reason": reason}
|
|
112
132
|
try:
|
|
113
133
|
task_dir = TaskRepository(root).resolve(target)
|
|
114
134
|
except TaskRepositoryError as error:
|
|
@@ -124,17 +144,23 @@ def _tool_task_list(root: Path, _arguments: dict) -> dict:
|
|
|
124
144
|
records, error = _list_task_records(root, mine=False, status=None)
|
|
125
145
|
if error:
|
|
126
146
|
return {"tasks": [], "count": 0, "error": error}
|
|
147
|
+
# records[].active is a per-session fact: only a trusted caller identity
|
|
148
|
+
# whose bound path matches the record may carry it.
|
|
149
|
+
trusted_active = _implicit_active_task(root)[0]
|
|
150
|
+
for record in records:
|
|
151
|
+
record["active"] = (
|
|
152
|
+
bool(trusted_active) and record.get("path") == trusted_active
|
|
153
|
+
)
|
|
127
154
|
return {"tasks": records, "count": len(records)}
|
|
128
155
|
|
|
129
156
|
|
|
130
157
|
def _resolve_task_dir(root: Path, target: str | None) -> Path:
|
|
131
|
-
from runtime.session_state import get_active_task
|
|
132
158
|
from services.task_repository import TaskRepository, TaskRepositoryError
|
|
133
159
|
|
|
134
160
|
if not target:
|
|
135
|
-
target =
|
|
161
|
+
target, reason = _implicit_active_task(root)
|
|
136
162
|
if not target:
|
|
137
|
-
raise ValueError(
|
|
163
|
+
raise ValueError(reason)
|
|
138
164
|
try:
|
|
139
165
|
task_dir = TaskRepository(root).resolve(target)
|
|
140
166
|
except TaskRepositoryError as error:
|
|
@@ -330,6 +330,41 @@ def set_active_task(repo_root: Path, task_path: str) -> ActiveTask | None:
|
|
|
330
330
|
return active
|
|
331
331
|
|
|
332
332
|
|
|
333
|
+
def claim_active_task(
|
|
334
|
+
repo_root: Path,
|
|
335
|
+
task_path: str,
|
|
336
|
+
values: Mapping[str, object] | None = None,
|
|
337
|
+
) -> ActiveTask | None:
|
|
338
|
+
"""Bind the task to the caller's own resolved session identity.
|
|
339
|
+
|
|
340
|
+
Used by host PostToolUse hooks after a lifecycle Bash activation: the CLI
|
|
341
|
+
process often only carries a shared process-fallback identity, so the hook
|
|
342
|
+
re-binds with the session id parsed from the hook input. Only explicit /
|
|
343
|
+
host_session provenance may claim — process_fallback and missing
|
|
344
|
+
identities are shared or absent, and a main binding written with them
|
|
345
|
+
would hijack the binding for every other window. Claim failures return
|
|
346
|
+
None and write nothing.
|
|
347
|
+
"""
|
|
348
|
+
context_key, provenance = resolve_context_key_with_provenance(values)
|
|
349
|
+
if not context_key or provenance not in (
|
|
350
|
+
PROVENANCE_EXPLICIT,
|
|
351
|
+
PROVENANCE_HOST_SESSION,
|
|
352
|
+
):
|
|
353
|
+
return None
|
|
354
|
+
normalized = task_path.replace("\\", "/")
|
|
355
|
+
target = repo_root / normalized
|
|
356
|
+
if not target.is_dir():
|
|
357
|
+
return None
|
|
358
|
+
data: dict[str, object] = {
|
|
359
|
+
FIELD_ACTIVE_TASK_PATH: normalized,
|
|
360
|
+
FIELD_SCOPE: SCOPE_MAIN,
|
|
361
|
+
"platform": platform_from_context_key(context_key),
|
|
362
|
+
"last_seen_at": _now(),
|
|
363
|
+
}
|
|
364
|
+
_write_json(_session_path(repo_root, context_key), data)
|
|
365
|
+
return ActiveTask(normalized, context_key, "session", provenance=provenance)
|
|
366
|
+
|
|
367
|
+
|
|
333
368
|
def get_active_task(repo_root: Path, values: Mapping[str, object] | None = None) -> ActiveTask:
|
|
334
369
|
context_key, provenance = resolve_context_key_with_provenance(values)
|
|
335
370
|
if not context_key:
|
|
@@ -85,8 +85,13 @@ The `<workflow-state>` open tag carries the machine-readable fact header as
|
|
|
85
85
|
XML attributes; the body keeps the human-readable breadcrumb prose.
|
|
86
86
|
|
|
87
87
|
- Attributes: `status` and `source` are always present; `task` (repo-relative
|
|
88
|
-
task path) appears whenever a task is bound
|
|
89
|
-
|
|
88
|
+
task path) appears whenever a task is bound; `session` (the caller's
|
|
89
|
+
resolved context key) appears whenever a session identity is resolvable
|
|
90
|
+
from the hook input or environment and is omitted otherwise (hosts that
|
|
91
|
+
cannot resolve an identity never guess one — the attribute is the machine
|
|
92
|
+
handle for re-binding with `COWORK_FLOW_CONTEXT_ID`). Attribute values are
|
|
93
|
+
XML-escaped (`& < > "`). The opencode JS mirror does not emit `session`
|
|
94
|
+
(delegated-only transport; documented behavioral difference, not drift).
|
|
90
95
|
- The legacy `Task:` / `Status:` / `Source:` label lines are gone — machines
|
|
91
96
|
parse the attributes, humans read the body. There is no bare `Scope:`
|
|
92
97
|
line in the body: the stage-contract block owns the scope declaration
|
|
@@ -94,11 +99,19 @@ XML attributes; the body keeps the human-readable breadcrumb prose.
|
|
|
94
99
|
as a read-only `[read-only]` reference of the parent task).
|
|
95
100
|
|
|
96
101
|
```text
|
|
97
|
-
<workflow-state task=".cowork-flow/tasks/08-28-demo" status="in_progress" source="runtime-session">
|
|
102
|
+
<workflow-state task=".cowork-flow/tasks/08-28-demo" status="in_progress" source="runtime-session" session="zcode_probe">
|
|
98
103
|
活动任务正在执行。...
|
|
99
104
|
</workflow-state>
|
|
100
105
|
```
|
|
101
106
|
|
|
107
|
+
- Unbound-session fallback (zcode only, display/warning-only): a hook
|
|
108
|
+
session with no binding follows another main session's binding only while
|
|
109
|
+
**exactly one** main-session binding exists (`source="session-fallback"`).
|
|
110
|
+
With two or more main-session bindings the newest one belongs to some
|
|
111
|
+
other window, so the fallback refuses: the injection renders
|
|
112
|
+
`status="no_task"` plus the rebind hints list. The CLI lifecycle commands
|
|
113
|
+
keep their strict identity semantics regardless (FALLBACK_BINDING_BLOCKER).
|
|
114
|
+
|
|
102
115
|
## Decision-anchor injection (stage 1)
|
|
103
116
|
|
|
104
117
|
For tasks whose status is `planning`, `in_progress`, or `review` (a delegated
|