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 +8 -0
- 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/kimi-code/.kimi-plugin/plugin.json +1 -1
- package/presets/qoder/.qoder-plugin/plugin.json +1 -1
- package/presets/zcode/.zcode-plugin/plugin.json +1 -1
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/adapters/cli/subagent.py +111 -7
- package/template/.cowork-flow/scripts/runtime/session_state.py +19 -0
- package/template/.cowork-flow/scripts/services/workflow_runtime.py +140 -0
- package/template/.cowork-flow/spec/contracts/error-codes.md +3 -2
- package/template/.cowork-flow/spec/contracts/index.md +1 -1
- package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +25 -1
- package/template/skills/agent-dispatch/SKILL.md +2 -0
- package/template/skills/cowork-flow/SKILL.md +9 -0
- package/template/skills/runtime-health/scripts/doctor.py +57 -0
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 +1 @@
|
|
|
1
|
-
1.9.
|
|
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
|
|
4
|
-
|
|
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
|
|
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
|
-
-
|
|
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(
|