cowork-flow 1.8.0 → 1.8.1

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
@@ -5,6 +5,16 @@
5
5
  ### Added
6
6
 
7
7
 
8
+ ## [1.8.1] - 2026-10-08
9
+
10
+ ### Added
11
+
12
+ - **任务收养 `task next --run --adopt`(1.9.0 多执行者协作前置)**:为孤儿/滞留任务提供受支持的接管路径,替代手改 task.json meta。收养是独立动作(不与 review/complete/archive 链式同发):把 `in_progress`/`review` 状态、由另一执行者占有的任务重绑到收养者(`executor`),把评审基线**重置**到收养时 HEAD(原值记入 `meta.previousBaseline`),保留状态并写审计字段 `meta.previousExecutor` / `meta.previousBaseline` / `meta.adoptedAt`,结果 code `LIFECYCLE-EXECUTOR-ADOPTED`。误用 fail-closed(`LIFECYCLE-ADOPT-001`):planning/completed、无 executor、自我收养、process-fallback 身份、delegated 上下文、携带 create/plan 或批启动旗标全部拒绝且不改状态。start 路径"基线永不覆盖"不变量原样保留(既有测试零改动),收养是唯一经审计的显式重置路径;成功后 CLI 把会话绑定到该任务。契约见 `.cowork-flow/spec/contracts/task-adopt.md`。
13
+
14
+ ### Fixed
15
+
16
+ - **`--allow-missing-evidence` 恢复独立解析**(1.8.0 遗留缺陷):该旗标此前被误注册为 `--allow-unchecked` 的解析别名(同一 `add_argument`、dest 相同),造成两个后果——AC 证据豁免(`LIFECYCLE-AC-002` 的可豁免路径)从 CLI 不可达(`cmd_complete` 读取的属性 argparse 从不创建,恒为 False);且传入 `--allow-missing-evidence` 会静默获得 spec-check 未检查豁免(语义错误、方向危险:本意只豁免 AC 证据,却放开了缺失/超时的 spec-check 命令)。现在两个旗标各自独立解析,parser 级与命令贯通级回归测试锁定(既有测试手工构造 Namespace 绕过 parser,正是此缺陷漏网原因)。
17
+
8
18
  ## [1.8.0] - 2026-10-08
9
19
 
10
20
  ### Added
@@ -296,7 +306,8 @@
296
306
 
297
307
  此版本未保留本地条目正文,详情见 [GitHub Release](https://github.com/fishlikewater/cowork-flow/releases/tag/v0.0.47)。
298
308
 
299
- [Unreleased]: https://github.com/fishlikewater/cowork-flow/compare/v1.8.0...HEAD
309
+ [Unreleased]: https://github.com/fishlikewater/cowork-flow/compare/v1.8.1...HEAD
310
+ [1.8.1]: https://github.com/fishlikewater/cowork-flow/compare/v1.8.0...v1.8.1
300
311
  [1.8.0]: https://github.com/fishlikewater/cowork-flow/compare/v1.7.3...v1.8.0
301
312
  [1.7.3]: https://github.com/fishlikewater/cowork-flow/compare/v1.7.2...v1.7.3
302
313
  [1.7.2]: https://github.com/fishlikewater/cowork-flow/compare/v1.7.1...v1.7.2
package/docs/direction.md CHANGED
@@ -16,10 +16,11 @@
16
16
  | 本规划文档落库 | ✅ 完成 | 08-27-direction-doc | — |
17
17
  | 1.5→1.7 宿主广度 + 发布链(七宿主) | ✅ 完成 | CHANGELOG 1.5.0-1.7.2 | — |
18
18
  | 1.7.3 维护:ZCode shim .mjs + 项目根查找修复 | ✅ 完成 | 10-04-zcode-shim-mjs(d0716a9) | — |
19
- | 1.8.0 立项:证据化完成 | 🔄 立项完成,待启动 | 10-05-direction-1-8-0 / 10-05-evidence-completion | 在宿主会话启动主线任务 |
20
- | 1.8.0 主线实现:证据化完成 | ✅ 完成 | 10-05-evidence-completion(baa386d,CI test + windows-core 全绿) | 真实宿主冒烟后发布 1.8.0 |
21
- | 1.8.x 收尾:Skill 证据补课 | 🔄 进行中 | 10-06-skill-evidence-training | 合入后与冒烟共同构成 1.8.0 发布判据 |
22
- | 1.9.0 前置:task adopt 命令 | 📋 立项 | 10-06-task-adopt-command | 下一会话实现(agent-teams 就绪的地基) |
19
+ | 1.8.0 立项:证据化完成 | ✅ 完成 | 10-05-direction-1-8-0 / 10-05-evidence-completion | — |
20
+ | 1.8.0 主线实现:证据化完成 | ✅ 完成 | 10-05-evidence-completion(baa386d,CI test + windows-core 全绿);已随 1.8.0 发布(cfe1ac1) | — |
21
+ | 1.8.x 收尾:Skill 证据补课 + spec-check 采纳闭环 | ✅ 完成 | 10-06-skill-evidence-training(09937d0)、10-08 spec-check 闭环(08f9d9b) | — |
22
+ | 1.9.0 前置:task adopt 命令 | ✅ 完成 | 10-06-task-adopt-command(e6e5132):`--adopt` 重绑执行者 + 基线重置 + 审计;契约 task-adopt.md | 1.9.0 主线(多执行者协作)立项 |
23
+ | 1.8.1 补丁:`--allow-missing-evidence` 解析修复 + adopt | 🔄 本次发布 | 10-08-fix-evidence-flag-parser(dc594a4);tag v1.8.1 → CI 发布 npm | 确认 CI 全绿与 npm latest=1.8.1 |
23
24
 
24
25
  **当前结论**:方向路线图阶段 0-3 全部落地。项目当前形态:「多主机 Agent 协作的运行时上下文与协作事实层」——事实视图(CLI + MCP)、结构化注入、跨宿主一致性治理、执行者归属与证据位均已就绪并有测试锁定。后续演进见「不做什么」边界与观察期事项。
25
26
 
@@ -177,3 +178,4 @@
177
178
  - 2026-08-29:MCP 全局入口补充——npm CLI 新增 `cowork-flow mcp-state` 透传命令(cwd 向上定位项目运行时、stdio inherit、退出码透传),MCP 客户端全局注册一次即可服务所有项目,消除逐项目 `.mcp.json` 配置(9a1fa86)。
178
179
  - 2026-08-29:后续方向 P0-P3 执行完毕。**P0**:1.1.0 发布——dev→master 合并、CHANGELOG 1.1.0 段(含 1.0.0 载体错位注明)、tag v1.1.0 与 GitHub Release 创建;publish CI 首跑曝光 node:full 存量问题(dsh-hook 依赖 gitignored 运行时、Windows 无法 exec shebang 脚本、test runner IPC 崩溃)并全部修复——双平台门禁现已全绿。**发布确认:npm registry `cowork-flow@1.1.0` 已上线(dist-tag latest),AC-004 闭合,发版任务归档。** **P1**:`docs/mcp-client-setup.md`(codex/opencode/claude-code/zcode 真实注册格式)+ `test/mcp-client-matrix.test.js` 客户端等效启动验证。**P1'**:publish.yml 链路实测核对并写入 README 发布节。**P2**:flaky 组合复跑 5 轮未复现(记录为单次现象);doctor 会话卫生无残留。**P3**:观察期边界维持——MCP 写工具默认拒绝,AGNTCY 等协议收敛后按薄壳接入。- 2026-10-05:2026-10 修订落库(任务 10-05-direction-1-8-0)。1.7.3 维护完成(10-04-zcode-shim-mjs,d0716a9:ZCode shim 改 .mjs、findWorkflowRoot off-by-one 修复);1.8.0 主线定为**证据化完成**,主线任务 10-05-evidence-completion 立项(planning 就绪待启动);候选 B(agent-teams 就绪)列为 1.9.0 候选主线;文档英文化列为待决项,待维护者拍板。方向文档自本次起恢复版本控制(087ef72 untrack 后首次入库)。
179
180
  - 2026-10-06:10.5 增补落库(任务 10-06-direction-post-1-8-0)。1.8.0 主线实现完成(10-05-evidence-completion,baa386d,CI 双平台全绿);立项 10-06-skill-evidence-training(1.8.0 收尾)与 10-06-task-adopt-command(1.9.0 前置);1.8.0 发布判据 = CI + 真实宿主冒烟 + Skill 补课。
181
+ - 2026-10-08:1.8.0 已发布(cfe1ac1,tag v1.8.0,CI publish 全绿,npm latest=1.8.0)。发布后排查发现 `--allow-missing-evidence` 被误注册为 `--allow-unchecked` 解析别名的遗留缺陷(AC 证据豁免从 CLI 不可达 + 误授 spec-check 豁免),修复入库(10-08-fix-evidence-flag-parser,dc594a4,parser 级与命令贯通级回归测试锁定)。1.9.0 前置 task adopt 完成(10-06-task-adopt-command,e6e5132):`task next --run --adopt` 独立动作——重绑执行者、评审基线重置到收养时 HEAD(审计保留原值)、状态不变、误用 fail-closed(`LIFECYCLE-ADOPT-001`);契约 `spec/contracts/task-adopt.md`,含与 `--takeover` 的边界。本次发布 1.8.1 同时携带两项交付(CHANGELOG 分列 Added/Fixed);1.9.0 主线(多执行者协作就绪)待立项。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
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.8.0",
3
+ "version": "1.8.1",
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.8.0",
3
+ "version": "1.8.1",
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.8.0",
3
+ "version": "1.8.1",
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.8.0",
3
+ "version": "1.8.1",
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.8.0",
3
+ "version": "1.8.1",
4
4
  "description": "AI 协作工作流引擎:任务生命周期、子代理调度、门禁与状态注入。将 cowork-flow 的完整工作流带入 ZCode。",
5
5
  "author": {
6
6
  "name": "fishlikewater",
@@ -1 +1 @@
1
- 1.8.0
1
+ 1.8.1
@@ -38,6 +38,7 @@ from adapters.cli.task_context_commands import (
38
38
  from adapters.cli.task_create_command import cmd_create, ensure_tasks_dir
39
39
  from adapters.cli import task_navigation
40
40
  from adapters.cli.task_lifecycle_commands import (
41
+ cmd_adopt,
41
42
  cmd_complete,
42
43
  cmd_current,
43
44
  cmd_finish,
@@ -152,6 +153,7 @@ def cmd_next(args: argparse.Namespace) -> int:
152
153
  review=cmd_review,
153
154
  complete=cmd_complete,
154
155
  archive=cmd_archive,
156
+ adopt=cmd_adopt,
155
157
  ),
156
158
  )
157
159
  return task_navigation.cmd_next(args)
@@ -30,6 +30,7 @@ from runtime.session_state import (
30
30
  PROVENANCE_PROCESS_FALLBACK,
31
31
  clear_active_task,
32
32
  get_active_task,
33
+ set_active_task,
33
34
  )
34
35
 
35
36
 
@@ -284,6 +285,50 @@ def cmd_start(args: argparse.Namespace) -> int:
284
285
  return 0
285
286
 
286
287
 
288
+ def cmd_adopt(args: argparse.Namespace) -> int:
289
+ """Adopt an active task from another executor."""
290
+ repo_root = get_repo_root()
291
+ execution_context = execution_context_from_namespace(args)
292
+ task_input = getattr(args, "dir", None)
293
+ if not task_input:
294
+ print(
295
+ colored("Error: task directory or name required", Colors.RED),
296
+ file=sys.stderr,
297
+ )
298
+ return 1
299
+
300
+ full_path = _resolve_start_task(task_input, repo_root)
301
+ if full_path is None:
302
+ return 1
303
+
304
+ service = TaskLifecycleService(repo_root)
305
+ result = service.adopt(
306
+ full_path,
307
+ executor=getattr(args, "executor", None),
308
+ execution_context=execution_context,
309
+ )
310
+ if not result.ok:
311
+ if result.code == "LIFECYCLE-ADOPT-001":
312
+ print(
313
+ colored("Error: task adoption blocked", Colors.RED),
314
+ file=sys.stderr,
315
+ )
316
+ for blocker in result.blockers:
317
+ print(f" - {blocker}", file=sys.stderr)
318
+ return 1
319
+ if result.title:
320
+ return _report_lifecycle_preflight(result)
321
+ if result.repository_error is not None:
322
+ return _report_lifecycle_repository_error(result)
323
+ return 1
324
+
325
+ task_path = _display_task_path(repo_root, full_path)
326
+ set_active_task(repo_root, task_path)
327
+ print(colored(f"[OK] Task adopted: {task_path}", Colors.GREEN))
328
+ print(f"Next: ./.cowork-flow/run task next {task_path} --run")
329
+ return 0
330
+
331
+
287
332
  def cmd_review(args: argparse.Namespace) -> int:
288
333
  """Mark a task ready for check."""
289
334
  repo_root = get_repo_root()
@@ -18,6 +18,7 @@ from runtime.session_state import (
18
18
  get_active_task,
19
19
  )
20
20
  from services.plan_binding import PlanBindingError, bind_task_plan
21
+ from services.task_lifecycle import ADOPTABLE_STATUSES
21
22
 
22
23
 
23
24
  LifecycleCommand = Callable[[argparse.Namespace], int]
@@ -30,6 +31,7 @@ class NextActionHandlers:
30
31
  review: LifecycleCommand
31
32
  complete: LifecycleCommand
32
33
  archive: LifecycleCommand
34
+ adopt: LifecycleCommand
33
35
 
34
36
 
35
37
  def namespace_with(args: argparse.Namespace, **overrides) -> argparse.Namespace:
@@ -270,7 +272,62 @@ def _run_plan_bind_action(args: argparse.Namespace, task_path: str) -> int:
270
272
  return 0
271
273
 
272
274
 
275
+ def _run_adopt_action(
276
+ args: argparse.Namespace,
277
+ handlers: NextActionHandlers,
278
+ ) -> int:
279
+ """`--adopt` is its own action: it repairs ownership and the review
280
+ baseline without running any lifecycle transition."""
281
+ repo_root = get_repo_root()
282
+ if _create_input_names(args) or getattr(args, "from_plan", None):
283
+ print(
284
+ colored(
285
+ "Error: --adopt takes over an existing task; create/plan "
286
+ "inputs cannot run with it",
287
+ Colors.RED,
288
+ ),
289
+ file=sys.stderr,
290
+ )
291
+ return 1
292
+ if getattr(args, "auto", False) or getattr(args, "approved", False):
293
+ print(
294
+ colored(
295
+ "Error: --adopt does not run batch start; adopt first, then "
296
+ "re-run with --auto --approved",
297
+ Colors.RED,
298
+ ),
299
+ file=sys.stderr,
300
+ )
301
+ return 1
302
+ task_dir, task_path, _active_target = _next_target_for_run(args, repo_root)
303
+ if task_dir is None or task_path is None:
304
+ blockers = _fallback_binding_blockers(args, repo_root)
305
+ for blocker in blockers or ["no task target for --adopt; pass a task dir"]:
306
+ print(colored(f"Error: {blocker}", Colors.RED), file=sys.stderr)
307
+ return 1
308
+ if not task_dir.is_dir():
309
+ print(
310
+ colored(f"Error: task directory not found: {task_path}", Colors.RED),
311
+ file=sys.stderr,
312
+ )
313
+ return 1
314
+ status = task_navigation._status(repo_root, task_dir)
315
+ if status not in ADOPTABLE_STATUSES:
316
+ print(
317
+ colored(
318
+ "Error: --adopt only applies to an active task "
319
+ f"({'/'.join(ADOPTABLE_STATUSES)}); current status: {status}",
320
+ Colors.RED,
321
+ ),
322
+ file=sys.stderr,
323
+ )
324
+ return 1
325
+ return handlers.adopt(namespace_with(args, dir=task_path))
326
+
327
+
273
328
  def run_next_action(args: argparse.Namespace, handlers: NextActionHandlers) -> int:
329
+ if bool(getattr(args, "adopt", False)):
330
+ return _run_adopt_action(args, handlers)
274
331
  action, error_code = _validated_next_action(args)
275
332
  if action is None:
276
333
  return error_code
@@ -31,6 +31,8 @@ Action inputs for `next --run`:
31
31
  --parent <dir> Optional parent task directory for create_task
32
32
  --from-plan <path> Plan path for create_task or planning-phase rebind
33
33
  --auto --approved Optional batch start flags for start_task
34
+ --adopt Adopt an active task from another executor (rebind
35
+ executor + reset review baseline; status unchanged)
34
36
  --commit Optional archive auto-commit flag for archive_task
35
37
 
36
38
  Examples:
@@ -86,12 +88,18 @@ def _add_next_output_options(parser: argparse.ArgumentParser) -> None:
86
88
  )
87
89
  parser.add_argument(
88
90
  "--allow-unchecked",
89
- "--allow-missing-evidence",
90
91
  dest="allow_unchecked",
91
92
  action="store_true",
92
93
  help="With task completion: allow unchecked spec-check commands "
93
94
  "(missing/timeout) and record the exemption in task.json meta",
94
95
  )
96
+ parser.add_argument(
97
+ "--allow-missing-evidence",
98
+ dest="allow_missing_evidence",
99
+ action="store_true",
100
+ help="With task completion: allow checked acceptance criteria "
101
+ "without evidence and record the exemption in task.json meta",
102
+ )
95
103
  parser.add_argument(
96
104
  "--list",
97
105
  dest="list_tasks",
@@ -157,6 +165,12 @@ def _add_next_runtime_inputs(parser: argparse.ArgumentParser) -> None:
157
165
  action="store_true",
158
166
  help="Take the task over from its current executor on start_task",
159
167
  )
168
+ parser.add_argument(
169
+ "--adopt",
170
+ action="store_true",
171
+ help="Adopt an active task from another executor: rebind executor, "
172
+ "reset the review baseline to HEAD, keep status, record audit fields",
173
+ )
160
174
 
161
175
 
162
176
  def _add_next_command(subparsers: argparse._SubParsersAction) -> None:
@@ -60,6 +60,10 @@ COMPLETE_STAGE = LifecycleStage(
60
60
  records_completion_date=True,
61
61
  )
62
62
 
63
+ # Adoption repairs ownership on an unfinished task; finished or unstarted
64
+ # tasks have nothing to hand over.
65
+ ADOPTABLE_STATUSES = ("in_progress", "review")
66
+
63
67
 
64
68
  @dataclass(frozen=True)
65
69
  class LifecyclePreflightFailure:
@@ -139,6 +143,73 @@ class TaskLifecycleService:
139
143
  execution_context=execution_context,
140
144
  )
141
145
 
146
+ def adopt(
147
+ self,
148
+ task: str | Path,
149
+ *,
150
+ executor: str | None = None,
151
+ execution_context: object | None = None,
152
+ ) -> LifecycleResult:
153
+ """Take over an active task owned by another executor: rebind the
154
+ executor, reset the review baseline to HEAD, keep status, record
155
+ audit fields. Adoption repairs ownership; it is not a transition."""
156
+ task_dir = self.repository.resolve(task)
157
+ task_data_or_failure = self._load_transition_task(START_STAGE, task_dir)
158
+ if isinstance(task_data_or_failure, LifecycleResult):
159
+ return task_data_or_failure
160
+ task_data = task_data_or_failure
161
+
162
+ resolved, failure = self._adopt_gate(
163
+ START_STAGE,
164
+ task_dir,
165
+ task_data,
166
+ executor,
167
+ execution_context,
168
+ )
169
+ if failure is not None:
170
+ return failure
171
+ current_task, failure = self._takeover_snapshot(
172
+ START_STAGE, task_dir, task_data
173
+ )
174
+ if failure is not None:
175
+ return failure
176
+
177
+ current = str(task_data.get("executor")).strip()
178
+ meta = dict(task_data.get("meta") or {})
179
+ adoption = {
180
+ "previousExecutor": current,
181
+ "previousBaseline": meta.get("baselineCommit"),
182
+ "adoptedAt": datetime.now(timezone.utc).strftime(
183
+ "%Y-%m-%dT%H:%M:%SZ"
184
+ ),
185
+ # Reset, not preserve: the adopter's review window starts at the
186
+ # adoption HEAD. No HEAD (no git) degrades to status-only.
187
+ "baselineCommit": current_head(self.repo_root),
188
+ }
189
+ failure = self._write_takeover(
190
+ START_STAGE,
191
+ task_dir,
192
+ task_data,
193
+ resolved,
194
+ current_task,
195
+ adoption=adoption,
196
+ operation_tag="adopt",
197
+ )
198
+ if failure is not None:
199
+ return failure
200
+ return LifecycleResult(
201
+ ok=True,
202
+ code="LIFECYCLE-EXECUTOR-ADOPTED",
203
+ stage=START_STAGE,
204
+ task_dir=task_dir,
205
+ active_task_path=self._display_task_path(task_dir),
206
+ transition=LifecycleTransition(
207
+ previous_status=task_data.get("status"),
208
+ next_status=task_data.get("status"),
209
+ changed=False,
210
+ ),
211
+ )
212
+
142
213
  def review(
143
214
  self,
144
215
  task: str | Path,
@@ -717,6 +788,103 @@ class TaskLifecycleService:
717
788
  return None
718
789
  return key or None
719
790
 
791
+ def _adopt_gate(
792
+ self,
793
+ stage: LifecycleStage,
794
+ task_dir: Path,
795
+ task_data: dict,
796
+ executor: str | None,
797
+ execution_context: object | None,
798
+ ) -> tuple[str | None, LifecycleResult | None]:
799
+ """Fail-closed adoption gate: only an active task owned by another
800
+ executor can be adopted, and only by a real (non-shared) identity."""
801
+ if self._delegated_mutation_request(execution_context):
802
+ return None, self._failure(
803
+ stage,
804
+ task_dir,
805
+ "TASK-EXECUTION-001",
806
+ title="Delegated execution cannot mutate task lifecycle",
807
+ blockers=(
808
+ "delegated execution context cannot mutate main-session "
809
+ "task lifecycle state",
810
+ ),
811
+ )
812
+ status = task_data.get("status")
813
+ if status not in ADOPTABLE_STATUSES:
814
+ return None, self._failure(
815
+ stage,
816
+ task_dir,
817
+ "LIFECYCLE-ADOPT-001",
818
+ title="task is not active",
819
+ blockers=(
820
+ "--adopt only applies to an active task "
821
+ f"({'/'.join(ADOPTABLE_STATUSES)}); current status: "
822
+ f"{status!r}",
823
+ ),
824
+ )
825
+ current = task_data.get("executor")
826
+ current = (
827
+ current.strip()
828
+ if isinstance(current, str) and current.strip()
829
+ else None
830
+ )
831
+ if current is None:
832
+ return None, self._failure(
833
+ stage,
834
+ task_dir,
835
+ "LIFECYCLE-ADOPT-001",
836
+ title="task has no executor",
837
+ blockers=("task has no recorded executor; nothing to adopt",),
838
+ )
839
+ explicit = executor.strip() if executor and executor.strip() else None
840
+ if explicit:
841
+ resolved = explicit
842
+ else:
843
+ try:
844
+ from runtime.session_state import (
845
+ PROVENANCE_PROCESS_FALLBACK,
846
+ resolve_context_key_with_provenance,
847
+ )
848
+
849
+ resolved, provenance = resolve_context_key_with_provenance()
850
+ except Exception:
851
+ resolved, provenance = None, None
852
+ if resolved and provenance == PROVENANCE_PROCESS_FALLBACK:
853
+ return None, self._failure(
854
+ stage,
855
+ task_dir,
856
+ "LIFECYCLE-ADOPT-001",
857
+ title="adoption requires an explicit identity",
858
+ blockers=(
859
+ f"process-fallback identity '{resolved}' cannot adopt "
860
+ "a task; set COWORK_FLOW_CONTEXT_ID or pass "
861
+ "--executor <label>",
862
+ ),
863
+ )
864
+ if not resolved:
865
+ return None, self._failure(
866
+ stage,
867
+ task_dir,
868
+ "LIFECYCLE-ADOPT-001",
869
+ title="no executor identity resolved",
870
+ blockers=(
871
+ "no executor identity resolved; set "
872
+ "COWORK_FLOW_CONTEXT_ID or pass --executor <label>",
873
+ ),
874
+ )
875
+ if resolved == current:
876
+ return None, self._failure(
877
+ stage,
878
+ task_dir,
879
+ "LIFECYCLE-ADOPT-001",
880
+ title=f"task is already owned by '{current}'",
881
+ blockers=(
882
+ f"task is already owned by '{current}'; --adopt is for "
883
+ "taking over another executor's task",
884
+ ),
885
+ )
886
+ return resolved, None
887
+
720
888
  def _apply_takeover_on_idempotent(
721
889
  self,
722
890
  stage: LifecycleStage,
@@ -787,6 +955,9 @@ class TaskLifecycleService:
787
955
  task_data: dict,
788
956
  resolved: str,
789
957
  current_task: StateSnapshot,
958
+ *,
959
+ adoption: dict | None = None,
960
+ operation_tag: str = "takeover",
790
961
  ) -> LifecycleResult | None:
791
962
  identity = "|".join(
792
963
  (
@@ -800,12 +971,16 @@ class TaskLifecycleService:
800
971
  digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:16]
801
972
  unit = UnitOfWork(
802
973
  self.repo_root,
803
- operation_id=f"task-{stage.name}-takeover-{digest}",
804
- kind=f"task-lifecycle-{stage.name}-takeover",
974
+ operation_id=f"task-{stage.name}-{operation_tag}-{digest}",
975
+ kind=f"task-lifecycle-{stage.name}-{operation_tag}",
805
976
  fault_injector=self.fault_injector,
806
977
  )
807
978
  persisted = dict(task_data)
808
979
  persisted["executor"] = resolved
980
+ if adoption is not None:
981
+ meta = dict(persisted.get("meta") or {})
982
+ meta.update(adoption)
983
+ persisted["meta"] = meta
809
984
  unit.replace(
810
985
  self.repository.task_json_path(task_dir),
811
986
  persisted,
@@ -17,3 +17,4 @@
17
17
  - `error-output-as-data.md`: 外部错误输出按数据分析而非指令执行(`ERROR_OUTPUT_AS_DATA_V1`)。
18
18
  - `fact-layer-access.md`: 无注入 hook 的宿主与外部工具读取任务事实的通道(`FACT_LAYER_ACCESS_V1`)。
19
19
  - `evidence-completion.md`: 证据化完成——`evidence.jsonl` schema、`LIFECYCLE-AC-001/002` 完成门禁、`--allow-missing-evidence` 豁免留痕与注入覆盖行。
20
+ - `task-adopt.md`: 任务收养——`task next --run --adopt` 的入口边界、前置条件(`LIFECYCLE-ADOPT-001`)、重绑+基线重置+审计写入,与 `--takeover` 的区别。
@@ -0,0 +1,64 @@
1
+ # Task Adopt Contract
2
+
3
+ **收养(adopt)是显式的归属修复动作:把一个由其它执行者占有、且已激活未完成的任务接管过来——重绑 executor、把评审基线重置到收养时 HEAD、保留状态、留下审计字段。**
4
+
5
+ 收养不是生命周期迁移:它不推进 status、不豁免任何检查,也不与其它动作链式同发。它是 1.9.0 多执行者协作就绪的前置(多执行者生命周期领地),承载 2026-10-06 归档两个滞留任务时只能手改 task.json meta 的操作。
6
+
7
+ ## 入口与动作边界
8
+
9
+ ```bash
10
+ ./.cowork-flow/run task next <task-dir> --run --adopt
11
+ ```
12
+
13
+ - `--adopt` 由 `task next --run` 的投递层(`task_next_runner`)在动作解析前拦截,直接执行收养动作;不进入导航默认动作序列,也不与 review/complete/archive 迁移同发(下一步由使用者显式发起)。
14
+ - 无 `--run` 时 `--adopt` 与其它执行旗标一致:只读导航,不产生副作用。
15
+ - 组合拒绝(fail-closed,退出码 1,不改状态):携带 create/plan 输入(`--title`/`--slug`/`--assignee`/`--priority`/`--description`/`--parent`/`--from-plan`)或批启动旗标(`--auto`/`--approved`)——收养与创建/批启动语义不同,需分两步。
16
+
17
+ ## 前置条件(`LIFECYCLE-ADOPT-001`)
18
+
19
+ | 事实 | 要求 |
20
+ |---|---|
21
+ | 任务状态 | `in_progress` 或 `review`(已激活未完成;planning/completed/stale 拒绝) |
22
+ | 任务 executor | 已记录且非空(无归属的任务没有可收养对象) |
23
+ | 收养者身份 | 可解析且 ≠ 当前 executor;显式 `--executor <label>` 优先,否则取会话身份 |
24
+ | 身份来源 | process-fallback(共享进程标签)拒绝收养——归属必须来自真实会话身份或显式 label |
25
+ | 执行上下文 | delegated(worker/subagent)拒绝收养(`TASK-EXECUTION-001`,与其它生命周期变更一致) |
26
+
27
+ 拒绝时不写 task.json、不改状态、不动会话绑定;blockers 说明具体原因。
28
+
29
+ ## 收养写入(单次 UnitOfWork)
30
+
31
+ | 字段 | 写入 |
32
+ |---|---|
33
+ | `executor` | 收养者身份 |
34
+ | `meta.previousExecutor` | 原 executor(审计:原执行者) |
35
+ | `meta.previousBaseline` | 原 `meta.baselineCommit`(无则 `null`) |
36
+ | `meta.adoptedAt` | UTC 时间戳 `YYYY-MM-DDTHH:MM:SSZ` |
37
+ | `meta.baselineCommit` | **重置**为收养时 HEAD;无 git/无 HEAD 时写 `null`(降级 status-only) |
38
+ | `status` | 不变 |
39
+ | `_state.revision` / `_state.operation_id` | 修订递增,operation_id 含 `adopt` 标记(审计可追溯) |
40
+
41
+ 结果 code `LIFECYCLE-EXECUTOR-ADOPTED`;`transition.changed=false`(不推进状态的事实位);`check_result` 为 `null`(不豁免检查)。
42
+
43
+ ## 基线与"永不覆盖"不变量的关系
44
+
45
+ - start 路径的写一次逻辑不变:**重复 start 永不覆盖已有 `meta.baselineCommit`**,既有测试零改动锁定该不变量。
46
+ - 收养是唯一经审计的显式重置路径:重置前把原值记入 `meta.previousBaseline`。
47
+ - 重置后的评审窗口 = 收养时未提交改动 + 收养之后的提交。收养前**已提交**的改动不进入本任务窗口(它们在 git 历史中,需由提交记录单独追溯)——这是收养的已知边界。
48
+ - 收养后基线再次回到"写一次"语义:后续 start 不会再次滑动。
49
+
50
+ ## 会话绑定
51
+
52
+ 收养成功后 CLI 把收养者会话绑定到该任务(与 `task next <dir>` 改绑、`task create` 创建后绑定同型),使后续 `task next <dir> --run` 可省略目录继续 review/complete/archive 链。
53
+
54
+ ## 与 `--takeover` 的区别
55
+
56
+ | | `--takeover` | `--adopt` |
57
+ |---|---|---|
58
+ | 适用动作 | start_task(含幂等重跑) | 独立动作(本契约) |
59
+ | 状态要求 | 任意(只在 start 激活点生效) | `in_progress` / `review` |
60
+ | 基线 | 不动 | 重置到 HEAD(审计保留原值) |
61
+ | 审计字段 | 无 | `previousExecutor` / `previousBaseline` / `adoptedAt` |
62
+ | 结果 code | `LIFECYCLE-EXECUTOR-TAKEN-OVER` | `LIFECYCLE-EXECUTOR-ADOPTED` |
63
+
64
+ `--takeover` 语义保持原样;`--adopt` 是超集动作但不改写 takeover 路径(不变量与既有测试零改动)。