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 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,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "CLI for installing and maintaining cowork-flow collaboration templates.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1 +1 @@
1
- 1.3.0
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, status: str, source: str
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 body."""
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 the newest main session for their
426
- never-bound hook sessions; strict-identity hosts (claude-code/codex)
427
- require the calling session's own binding. no_task, planning, and
428
- completed stay silent. Empty string means silent. Never raises — the
429
- editor path must not break edits.
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 the
609
- newest valid main session's binding. Two sources qualify: no session
610
- identity at all (missing-context), and a session id that resolves but
611
- was never bound (empty-session) — the zcode hook always carries the
612
- conversation's own session id, while task activation happens in the
613
- Bash CLI under a separate explicit identity, so the hook session file
614
- never exists. Gated by the host policy's fallback_for_unbound flag;
615
- display/warning-only — the CLI lifecycle commands keep their strict
616
- identity semantics. Multi-window setups may cross-read another window's
617
- binding; accepted for advisory output."""
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
- fallback = _newest_session_task(root)
626
- if fallback is None:
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 _newest_session_task(root: Path) -> str | None:
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 None
674
+ return []
644
675
  sessions = sessions_dir(root)
645
676
  if not sessions.is_dir():
646
- return None
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 None
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
- return task_path
672
- return None
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
- def _tool_task_state(root: Path, arguments: dict) -> dict:
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 = get_active_task(root).task_path
129
+ target, reason = _implicit_active_task(root)
110
130
  if not target:
111
- return {"schemaVersion": 1, "task": None, "reason": "no-active-task"}
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 = get_active_task(root).task_path
161
+ target, reason = _implicit_active_task(root)
136
162
  if not target:
137
- raise ValueError("no-active-task")
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. Attribute values are XML-escaped
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "AI 协作工作流引擎:任务生命周期、子代理调度、门禁与状态注入。将 cowork-flow 的完整工作流带入 ZCode。",
5
5
  "author": {
6
6
  "name": "fisklikewater"