cowork-flow 1.9.1 → 1.9.2

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
@@ -4,6 +4,13 @@
4
4
 
5
5
  ### Added
6
6
 
7
+ ## [1.9.2] - 2026-10-14
8
+
9
+ ### Added
10
+
11
+ - **子代理切片机检与派发工具(派发默认化 M2)**:把"并行切片互不踩"从计划措辞变成运行时事实。① **切片所有权机检**:`subagent init --slice <name> --owns <path>...` 把所有权声明写进 `assignment`;同一任务内,若新 implement 上下文声明的 `owned_paths` 与任一未关闭 implement 上下文的声明相等或互为目录前缀(`\`/`/` 与尾斜杠归一,绝对与相对 `--execution-task-dir` 按同一把钥匙比较),初始化 fail-closed 报 `SUBAGENT-SLICE-001` 且被拒上下文不落盘;关闭(close)即释放路径,未声明 `--owns` 的既有调用不受任何影响。② **派发 prompt 渲染**:`subagent prompt <runtime_context_id>` 输出可直接粘贴的子代理提示——transport 两行原文 + "先读 assignment"指引 + goal/expected_output/切片,保持宿主无关(不写宿主专有协议串)。③ **敞口盘点**:`subagent list [--task <dir>] [--open]` 列上下文 id / status / agent_type / 任务 / slice 与 owns 摘要。④ **卫生提示**:doctor 对"completed 任务仍有未关闭子代理上下文"新增 warning `TASK-HYGIENE-COMPLETED-WITH-OPEN-SUBAGENTS`(只提示不门禁,exit 语义不变;hint 指向 `subagent list --task`)。契约同步:`subagent-dispatch.md` 新增 Implement Slices 段、`error-codes.md` 登记 `SUBAGENT-SLICE-001`、`agent-dispatch` 技能补 owned_paths 编辑边界。
12
+ - **派发等待语义转为 converge(异步派发默认化)**:`subagent-dispatch.md` 的 Return Acceptance 首条从"用 wait 原语等待"改为汇合点语义——只要求收敛到子代理结果(wait 原语或宿主完成信号),前台/后台由宿主决定,不再暗示同步阻塞;路由技能 `cowork-flow` 的 Delegation 段新增三条:长任务优先后台派发(完成信号即汇合点、acceptance 在汇合后);等待期主会话可做安全区工作(只读调查、运行时目录产物、独立 worktree);汇合前不进子代理的场地(不改 tracked 树与本任务 anchor/计划、不做当前树 git 写与生命周期转换、不新开任务)。
13
+
7
14
  ## [1.9.1] - 2026-10-14
8
15
 
9
16
  ### Fixed
@@ -338,6 +345,7 @@
338
345
  此版本未保留本地条目正文,详情见 [GitHub Release](https://github.com/fishlikewater/cowork-flow/releases/tag/v0.0.47)。
339
346
 
340
347
  [Unreleased]: https://github.com/fishlikewater/cowork-flow/compare/v1.9.1...HEAD
348
+ [1.9.2]: https://github.com/fishlikewater/cowork-flow/compare/v1.9.1...v1.9.2
341
349
  [1.9.1]: https://github.com/fishlikewater/cowork-flow/compare/v1.9.0...v1.9.1
342
350
  [1.9.0]: https://github.com/fishlikewater/cowork-flow/compare/v1.8.1...v1.9.0
343
351
  [1.8.1]: https://github.com/fishlikewater/cowork-flow/compare/v1.8.0...v1.8.1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "1.9.1",
3
+ "version": "1.9.2",
4
4
  "description": "CLI for installing and maintaining cowork-flow collaboration templates.",
5
5
  "keywords": [
6
6
  "ai-agent",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "1.9.1",
3
+ "version": "1.9.2",
4
4
  "description": "AI 协作工作流引擎:任务生命周期、子代理调度、门禁与状态注入。将 cowork-flow 的完整工作流带入 Claude Code。",
5
5
  "displayName": "Cowork Flow",
6
6
  "author": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "1.9.1",
3
+ "version": "1.9.2",
4
4
  "description": "AI 协作工作流引擎:任务生命周期、子代理调度、门禁与状态注入。将 cowork-flow 的完整工作流带入 Codex。",
5
5
  "author": {
6
6
  "name": "fishlikewater",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "1.9.1",
3
+ "version": "1.9.2",
4
4
  "description": "AI 协作工作流引擎:任务生命周期、子代理调度、门禁与状态注入。将 cowork-flow 的完整工作流带入 Kimi Code。",
5
5
  "author": {
6
6
  "name": "fishlikewater",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "1.9.1",
3
+ "version": "1.9.2",
4
4
  "description": "AI 协作工作流引擎:任务生命周期、子代理调度、门禁与状态注入。将 cowork-flow 的完整工作流带入 Qoder。",
5
5
  "displayName": "Cowork Flow",
6
6
  "author": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "1.9.1",
3
+ "version": "1.9.2",
4
4
  "description": "AI 协作工作流引擎:任务生命周期、子代理调度、门禁与状态注入。将 cowork-flow 的完整工作流带入 ZCode。",
5
5
  "author": {
6
6
  "name": "fishlikewater",
@@ -1 +1 @@
1
- 1.9.1
1
+ 1.9.2
@@ -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)
@@ -207,6 +207,25 @@ def _read_json(path: Path) -> dict:
207
207
  return data if isinstance(data, dict) else {}
208
208
 
209
209
 
210
+ def iter_subagent_contexts(repo_root: Path) -> list[dict]:
211
+ """Every readable subagent runtime context, ordered by context id.
212
+
213
+ The id falls back to the file name when the record omits it, so a
214
+ record still lists under the id its callers already know.
215
+ """
216
+ directory = subagent_contexts_dir(repo_root)
217
+ if not directory.is_dir():
218
+ return []
219
+ contexts: list[dict] = []
220
+ for path in sorted(directory.glob("*.json")):
221
+ data = _read_json(path)
222
+ if not data:
223
+ continue
224
+ data.setdefault("runtime_context_id", path.stem)
225
+ contexts.append(data)
226
+ return contexts
227
+
228
+
210
229
  def _write_json(
211
230
  path: Path,
212
231
  data: dict,
@@ -20,6 +20,7 @@ from runtime.session_state import (
20
20
  FIELD_RUNTIME_CONTEXT_ID,
21
21
  FIELD_SCOPE,
22
22
  SCOPE_SUBAGENT,
23
+ iter_subagent_contexts,
23
24
  logical_subagent_context_key,
24
25
  platform_from_context_key,
25
26
  resolve_context_key,
@@ -38,6 +39,38 @@ class RuntimeContextError(RuntimeError):
38
39
  super().__init__(f"{code}: {detail}")
39
40
 
40
41
 
42
+ def normalize_relative_path(path: str) -> str:
43
+ """Repository-relative form used to compare ownership claims."""
44
+ normalized = path.strip().replace("\\", "/")
45
+ while normalized.startswith("./"):
46
+ normalized = normalized[2:]
47
+ return normalized.strip("/")
48
+
49
+
50
+ def slice_paths_overlap(left: str, right: str) -> bool:
51
+ """True when two owned paths are equal or one contains the other."""
52
+ a = normalize_relative_path(left)
53
+ b = normalize_relative_path(right)
54
+ if not a or not b:
55
+ return False
56
+ return a == b or a.startswith(b + "/") or b.startswith(a + "/")
57
+
58
+
59
+ def owned_paths_overlap(
60
+ left: tuple[str, ...] | list[str],
61
+ right: tuple[str, ...] | list[str],
62
+ ) -> list[tuple[str, str]]:
63
+ """Every overlapping (left, right) pair of owned-path claims."""
64
+ overlaps: list[tuple[str, str]] = []
65
+ for a in left:
66
+ if not isinstance(a, str):
67
+ continue
68
+ for b in right:
69
+ if isinstance(b, str) and slice_paths_overlap(a, b):
70
+ overlaps.append((a, b))
71
+ return overlaps
72
+
73
+
41
74
  @dataclass(frozen=True)
42
75
  class RuntimeContextInitResult:
43
76
  """Files created for one runtime-context initialization."""
@@ -105,6 +138,17 @@ class RuntimeContextService:
105
138
  logical_context_key=logical_context_key,
106
139
  )
107
140
 
141
+ conflicts = self.slice_conflicts(runtime_context_id, context)
142
+ if conflicts:
143
+ raise RuntimeContextError(
144
+ "SUBAGENT-SLICE-001",
145
+ "owned_paths overlap an open implement context of this task: "
146
+ + "; ".join(
147
+ f"{other_id} owns {other_path}, overlaps {claimed_path}"
148
+ for other_id, claimed_path, other_path in conflicts
149
+ ),
150
+ )
151
+
108
152
  unit = self._unit(
109
153
  self._operation_id("init", runtime_context_id),
110
154
  "runtime-context-init",
@@ -120,6 +164,102 @@ class RuntimeContextService:
120
164
  logical_context_key=logical_context_key,
121
165
  )
122
166
 
167
+ def slice_conflicts(
168
+ self,
169
+ runtime_context_id: str,
170
+ context: dict,
171
+ ) -> list[tuple[str, str, str]]:
172
+ """(other_id, claimed_path, conflicting_path) for open sibling slices.
173
+
174
+ Only implement contexts that declare `owned_paths` take part: an
175
+ init without an ownership claim can neither conflict nor be
176
+ conflicted with, and a closed context has released its paths.
177
+ """
178
+ if context.get("agent_type") != "cowork-implement":
179
+ return []
180
+ claimed = self._owned_paths(context)
181
+ if not claimed:
182
+ return []
183
+ task_dir = self._normalized_task_dir(str(context.get("task_dir") or ""))
184
+ if not task_dir:
185
+ return []
186
+ conflicts: list[tuple[str, str, str]] = []
187
+ for other in iter_subagent_contexts(self.repo_root):
188
+ other_id = str(other.get("runtime_context_id") or "")
189
+ if not other_id or other_id == runtime_context_id:
190
+ continue
191
+ if other.get("agent_type") != "cowork-implement":
192
+ continue
193
+ if other.get("status") == "closed":
194
+ continue
195
+ if self._normalized_task_dir(str(other.get("task_dir") or "")) != task_dir:
196
+ continue
197
+ for claimed_path, other_path in owned_paths_overlap(
198
+ claimed,
199
+ self._owned_paths(other),
200
+ ):
201
+ conflicts.append((other_id, claimed_path, other_path))
202
+ return conflicts
203
+
204
+ @staticmethod
205
+ def _task_dir_is_absolute(value: str) -> bool:
206
+ """Decide on the raw value, before any slash stripping.
207
+
208
+ Stripping first would turn a slash-rooted POSIX path into a
209
+ relative-looking one and silently disable the same-task comparison.
210
+ """
211
+ raw = value.strip().replace("\\", "/")
212
+ if raw.startswith("/"):
213
+ return True
214
+ return Path(raw).is_absolute()
215
+
216
+ def _normalized_task_dir(self, value: str) -> str:
217
+ """One task, one key: `--execution-task-dir` may arrive absolute."""
218
+ raw = value.strip().replace("\\", "/")
219
+ if self._task_dir_is_absolute(raw):
220
+ try:
221
+ return normalize_relative_path(
222
+ str(
223
+ Path(raw)
224
+ .resolve()
225
+ .relative_to(self.repo_root.resolve())
226
+ )
227
+ )
228
+ except ValueError:
229
+ return normalize_relative_path(raw)
230
+ return normalize_relative_path(raw)
231
+
232
+ @staticmethod
233
+ def _owned_paths(context: dict) -> list[str]:
234
+ assignment = context.get("assignment")
235
+ owned = (
236
+ assignment.get("owned_paths")
237
+ if isinstance(assignment, dict)
238
+ else None
239
+ )
240
+ if not isinstance(owned, list):
241
+ return []
242
+ return [path for path in owned if isinstance(path, str) and path.strip()]
243
+
244
+ def list_contexts(
245
+ self,
246
+ *,
247
+ task_dir: str | None = None,
248
+ open_only: bool = False,
249
+ ) -> list[dict]:
250
+ """Subagent runtime contexts for inventory views (read-only)."""
251
+ wanted = self._normalized_task_dir(task_dir) if task_dir else None
252
+ contexts: list[dict] = []
253
+ for context in iter_subagent_contexts(self.repo_root):
254
+ if open_only and context.get("status") == "closed":
255
+ continue
256
+ if wanted is not None and self._normalized_task_dir(
257
+ str(context.get("task_dir") or "")
258
+ ) != wanted:
259
+ continue
260
+ contexts.append(context)
261
+ return contexts
262
+
123
263
  def update(
124
264
  self,
125
265
  runtime_context_id: str,
@@ -1,7 +1,7 @@
1
1
  # Runtime Error Codes
2
2
 
3
- Every `LIFECYCLE-*` error code the runtime can put in a result's `code` field, with
4
- the condition that produces it. A code is a stable contract: consumers match on the
3
+ Every runtime error code that can reach a result's `code` field, with the
4
+ condition that produces it. A code is a stable contract: consumers match on the
5
5
  code, never on the message text.
6
6
 
7
7
  Codes are defined in `scripts/services/*.py` and surface in CLI output, `--json`
@@ -28,6 +28,7 @@ results, and the closeout blockers.
28
28
  | `LIFECYCLE-TASK-001` | The task directory passed to a lifecycle command does not exist. | `services/task_lifecycle.py:529` |
29
29
  | `LIFECYCLE-TRANSITION-001` | The task's current status cannot transition to the target status of the stage being run. | `services/task_lifecycle.py:641` |
30
30
  | `LIFECYCLE-UOW-001` | The `UnitOfWork` write fails: task metadata cannot be re-read before commit/takeover, changed under it (optimistic-concurrency conflict), or `unit.commit()` raises. | `services/task_lifecycle.py:942` (also `:949`, `:998`, `:1051`, `:1059`, `:1095`) |
31
+ | `SUBAGENT-SLICE-001` | A `cowork-implement` runtime context is initialized with `owned_paths` that equal, or nest with, the `owned_paths` of an open (not yet closed) implement context of the same task; nothing is written for the rejected context. | `services/workflow_runtime.py:144` |
31
32
 
32
33
  ## Non-error `LIFECYCLE-*` codes
33
34
 
@@ -20,4 +20,4 @@ Current contracts:
20
20
  - `evidence-completion.md`: evidence-based completion — the `evidence.jsonl` schema, the `LIFECYCLE-AC-001/002/003/004` completion gate, `--allow-missing-evidence` exemption traces, and the injected coverage row.
21
21
  - `task-adopt.md`: task adoption — the entry boundary of `task next --run --adopt`, its preconditions (`LIFECYCLE-ADOPT-001`), the rebind + baseline reset + audit write, and how it differs from `--takeover`.
22
22
  - `task-board.md`: the shared board and dependency edges — the list/MCP board fields (executor / ownerSessionActive / evidenceCoverage / blocked), `--depends-on` declaration and validation, the start dependency gate (`LIFECYCLE-DEPENDENCY-001`), and the adopt handover receipt.
23
- - `error-codes.md`: the registry of every runtime `LIFECYCLE-*` error code with the condition that produces it: `LIFECYCLE-AC-001/002/003/004`, `LIFECYCLE-ADOPT-001`, `LIFECYCLE-CHECK-001`, `LIFECYCLE-CONTEXT-001`, `LIFECYCLE-DEPENDENCY-001`, `LIFECYCLE-EXECUTOR-001`, `LIFECYCLE-RECOVERY-001`, `LIFECYCLE-REVIEW-001/002/003/004`, `LIFECYCLE-TASK-001`, `LIFECYCLE-TRANSITION-001`, `LIFECYCLE-UOW-001`.
23
+ - `error-codes.md`: the registry of every runtime error code with the condition that produces it: `LIFECYCLE-AC-001/002/003/004`, `LIFECYCLE-ADOPT-001`, `LIFECYCLE-CHECK-001`, `LIFECYCLE-CONTEXT-001`, `LIFECYCLE-DEPENDENCY-001`, `LIFECYCLE-EXECUTOR-001`, `LIFECYCLE-RECOVERY-001`, `LIFECYCLE-REVIEW-001/002/003/004`, `LIFECYCLE-TASK-001`, `LIFECYCLE-TRANSITION-001`, `LIFECYCLE-UOW-001`, `SUBAGENT-SLICE-001`.
@@ -38,6 +38,28 @@ verifies that support. If `subagent init` emits a suggested host context key,
38
38
  the parent may use that key unless the host adapter has a stronger stable child
39
39
  session key.
40
40
 
41
+ ## Implement Slices
42
+
43
+ An implement context may declare the ownership slice it works on inside
44
+ `assignment`: `slice` (a name) and `owned_paths` (repository-relative paths),
45
+ recorded by `subagent init --slice <name> --owns <path>...`. The runtime then
46
+ machine-checks slice cleanliness instead of leaving it to prose:
47
+
48
+ - Initializing an implement context whose `owned_paths` equal, or nest with,
49
+ those of an open (not yet closed) implement context of the same task fails
50
+ closed with `SUBAGENT-SLICE-001`; nothing is written for the rejected
51
+ context.
52
+ - A context that declares no `owned_paths` claims nothing, so it neither
53
+ conflicts with nor blocks anyone — existing plain `init` calls keep working
54
+ unchanged.
55
+ - Closing a context releases its paths; a finished slice no longer blocks a
56
+ re-run of the same slice.
57
+
58
+ `subagent list [--task <task-dir>] [--open]` inventories contexts with their
59
+ status and ownership summary, and `subagent prompt <runtime_context_id>`
60
+ renders the transport lines plus the assignment pointers as a paste-ready
61
+ dispatch prompt.
62
+
41
63
  ## Binding Gate
42
64
 
43
65
  The child hook or plugin may resolve `cowork_runtime_context_id` early and bind
@@ -105,7 +127,9 @@ blocks completion regardless.
105
127
 
106
128
  ## Return Acceptance And Closeout
107
129
 
108
- - Wait for the child result with the adapter wait primitive.
130
+ - Converge on the child result before acceptance — via the wait primitive or
131
+ the host's completion signal; the dispatch form (foreground or background)
132
+ is host-determined.
109
133
  - Confirm no stray running children with the adapter list primitive.
110
134
  - Verify the child report by checking files, commands, and results; do not
111
135
  trust completed text alone.
@@ -25,6 +25,8 @@ Use this Skill only inside fixed subagents such as `cowork-implement`,
25
25
  - Act as a leaf executor: do not spawn, wait for, list, or close other agents.
26
26
  - Do not start, finish, archive, resume, commit, or push workflow state.
27
27
  - Keep edits and reads inside the assigned runtime context and task scope.
28
+ - When the assignment names `owned_paths`, keep every edit inside them; report
29
+ a needed edit outside the slice instead of making it.
28
30
  - Treat runtime gates as CLI adapters backed by kernel services; this Skill may
29
31
  explain when to run or report them, but does not enforce them.
30
32
 
@@ -56,6 +56,15 @@ Dispatch follows the plan's execution strategy:
56
56
  requires — do not wait for the blocker to tell you.
57
57
  - Research-heavy work: dispatch two or three `cowork-research` children for
58
58
  independent questions instead of widening the main context.
59
+ - Long-running children: prefer background dispatch when the host supports it;
60
+ the completion signal is the convergence point and acceptance runs after it.
61
+ Use foreground dispatch for short work or hosts without a background mode.
62
+ - While a child runs, the main session keeps working on things that never
63
+ touch the child's ground: read-only investigation, runtime-directory work
64
+ (evidence, board, plan drafts, follow-up task drafts), separate worktrees.
65
+ - Until convergence, stay off the child's ground: no edits to the tracked
66
+ worktree or this task's anchor/plan, no current-tree git writes or lifecycle
67
+ transitions, and do not start other tasks.
59
68
 
60
69
  Public Skills carry workflow guidance. Runtime Gates carry hard enforcement.
61
70
  Do not recreate an internal protocol layer or another process authority.
@@ -233,6 +233,46 @@ def _session_task_paths(repo_root: Path) -> set[str]:
233
233
  return active
234
234
 
235
235
 
236
+ def _normalized_task_path(value: str, repo_root: Path) -> str:
237
+ """One task, one key: recorded task dirs may be absolute.
238
+
239
+ Absoluteness is decided on the raw value before any slash stripping, so
240
+ a slash-rooted POSIX path is not mistaken for a relative one.
241
+ """
242
+ raw = value.strip().replace("\\", "/")
243
+ if raw.startswith("/") or Path(raw).is_absolute():
244
+ try:
245
+ return (
246
+ Path(raw).resolve().relative_to(repo_root.resolve()).as_posix()
247
+ )
248
+ except ValueError:
249
+ pass
250
+ normalized = raw
251
+ while normalized.startswith("./"):
252
+ normalized = normalized[2:]
253
+ return normalized.strip("/")
254
+
255
+
256
+ def _open_subagent_contexts_by_task(repo_root: Path) -> dict[str, list[str]]:
257
+ """Open subagent runtime contexts, keyed by their task directory."""
258
+ directory = repo_root / ".cowork-flow" / ".runtime" / "subagents"
259
+ open_by_task: dict[str, list[str]] = {}
260
+ if not directory.is_dir():
261
+ return open_by_task
262
+ for path in sorted(directory.glob("*.json")):
263
+ data = _task_metadata(path)
264
+ if data.get("status") == "closed":
265
+ continue
266
+ task_path = data.get("task_dir")
267
+ if not isinstance(task_path, str) or not task_path.strip():
268
+ continue
269
+ context_id = str(data.get("runtime_context_id") or path.stem)
270
+ open_by_task.setdefault(
271
+ _normalized_task_path(task_path, repo_root), []
272
+ ).append(context_id)
273
+ return open_by_task
274
+
275
+
236
276
  def _task_hygiene_issue(
237
277
  *,
238
278
  kind: str,
@@ -268,6 +308,7 @@ def check_task_hygiene(repo_root: Path) -> list[dict[str, str]]:
268
308
  if not tasks_dir.is_dir():
269
309
  return []
270
310
  bound_tasks = _session_task_paths(repo_root)
311
+ open_subagents = _open_subagent_contexts_by_task(repo_root)
271
312
  issues: list[dict[str, str]] = []
272
313
  for task_dir in sorted(tasks_dir.iterdir()):
273
314
  if not task_dir.is_dir() or task_dir.name == "archive":
@@ -301,6 +342,22 @@ def check_task_hygiene(repo_root: Path) -> list[dict[str, str]]:
301
342
  hint=f"./.cowork-flow/run task next {task} --run --intent archive",
302
343
  )
303
344
  )
345
+ open_ids = open_subagents.get(
346
+ _normalized_task_path(task, repo_root)
347
+ )
348
+ if open_ids:
349
+ issues.append(
350
+ _task_hygiene_issue(
351
+ kind="completed_with_open_subagents",
352
+ task=task,
353
+ status=status,
354
+ message=(
355
+ "completed task still has open subagent context(s): "
356
+ + ", ".join(open_ids)
357
+ ),
358
+ hint=f"./.cowork-flow/run subagent list --task {task}",
359
+ )
360
+ )
304
361
  if status in {"in_progress", "review"} and task not in bound_tasks:
305
362
  issues.append(
306
363
  _task_hygiene_issue(