cowork-flow 0.0.49 → 0.0.51

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.
Files changed (28) hide show
  1. package/README.md +23 -3
  2. package/package.json +2 -1
  3. package/presets/dsh/agent.cordis.yml +206 -0
  4. package/presets/dsh/plugins/workflow-state.js +248 -0
  5. package/presets/dsh/preset.yml +2 -0
  6. package/src/cli.js +6 -0
  7. package/src/commands/install-dsh-preset.js +72 -0
  8. package/template/.cowork-flow/.version +1 -1
  9. package/template/.cowork-flow/adapters/dsh/adapter.yaml +30 -0
  10. package/template/.cowork-flow/scripts/adapters/cli/batch_mode.py +7 -0
  11. package/template/.cowork-flow/scripts/adapters/cli/task_navigation.py +20 -2
  12. package/template/.cowork-flow/scripts/adapters/cli/task_next_runner.py +35 -3
  13. package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +6 -3
  14. package/template/.cowork-flow/scripts/infra/paths.py +9 -1
  15. package/template/.cowork-flow/scripts/infra/process.py +32 -0
  16. package/template/.cowork-flow/scripts/run.py +2 -8
  17. package/template/.cowork-flow/scripts/runtime/session_state.py +31 -3
  18. package/template/.cowork-flow/scripts/services/plan_binding.py +21 -0
  19. package/template/.cowork-flow/scripts/services/task_archive.py +45 -0
  20. package/template/.cowork-flow/scripts/services/task_creation.py +2 -5
  21. package/template/.cowork-flow/scripts/services/workflow_runtime.py +2 -11
  22. package/template/.cowork-flow/spec/contracts/plan-binding.md +17 -0
  23. package/template/.cowork-flow/spec/runtime/host-assets.json +45 -0
  24. package/template/.cowork-flow/spec/schemas/host-assets.schema.json +2 -1
  25. package/template/.dsh/README.md +20 -0
  26. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  27. package/template/skills/brainstorming/manifest.json +1 -1
  28. package/template/skills/task-review/SKILL.md +4 -3
@@ -8,6 +8,7 @@ import subprocess
8
8
  import sys
9
9
  from pathlib import Path
10
10
 
11
+ from infra.process import runtime_pythonpath_env
11
12
  from infra.skill_manifest import SkillManifestError, skill_command_scripts
12
13
 
13
14
 
@@ -40,6 +41,11 @@ def _run_batch_action(
40
41
  *args: str,
41
42
  ) -> int:
42
43
  script = _batch_action_script(repo_root)
44
+ # The skill script imports `services.*` / `infra.*` from the runtime.
45
+ # Inject the runtime scripts dir via PYTHONPATH before spawning (shared
46
+ # bootstrap with run.py) so the child resolves the same runtime this
47
+ # adapter came from instead of relying on the ambient path.
48
+ env = runtime_pythonpath_env()
43
49
  try:
44
50
  completed = subprocess.run(
45
51
  [sys.executable, str(script), *args],
@@ -48,6 +54,7 @@ def _run_batch_action(
48
54
  text=True,
49
55
  encoding="utf-8",
50
56
  errors="replace",
57
+ env=env,
51
58
  check=False,
52
59
  )
53
60
  except OSError as error:
@@ -47,9 +47,22 @@ def _default_action_command(
47
47
  return " ".join(parts)
48
48
 
49
49
 
50
- def _render_command(action_id: str, task_path: str | None, template: object) -> str | None:
50
+ def _render_command(
51
+ action_id: str,
52
+ task_path: str | None,
53
+ template: object,
54
+ blockers: object,
55
+ ) -> str | None:
51
56
  if isinstance(template, str) and template.strip():
52
57
  return template.replace("<task-dir>", task_path or "<task-dir>")
58
+ if action_id == "edit_planning_artifacts":
59
+ items = [str(item) for item in (blockers or [])]
60
+ if items and all(item.startswith("planFile") for item in items):
61
+ return (
62
+ f"./.cowork-flow/run task next {task_path or '<task-dir>'}"
63
+ " --run --from-plan <plan-file>"
64
+ )
65
+ return None
53
66
  if action_id in {"start_task", "implement_change"}:
54
67
  return _default_action_command(task_path)
55
68
  return None
@@ -66,7 +79,12 @@ def _render_adapter_commands(payload: dict[str, object], task_path: str | None)
66
79
  if not isinstance(action, dict):
67
80
  return payload
68
81
  action_id = str(action.get("id"))
69
- command = _render_command(action_id, task_path, action.get("command"))
82
+ command = _render_command(
83
+ action_id,
84
+ task_path,
85
+ action.get("command"),
86
+ action.get("blockers"),
87
+ )
70
88
  diagnostics = _render_diagnostics(task_path, action.get("diagnosticsCommand"))
71
89
  action["command"] = command
72
90
  action["diagnosticsCommand"] = diagnostics
@@ -13,6 +13,7 @@ from adapters.cli import task_navigation
13
13
  from adapters.cli.task_support import Colors, colored, resolve_task_dir
14
14
  from infra.paths import get_repo_root
15
15
  from runtime.session_state import get_active_task
16
+ from services.plan_binding import PlanBindingError, bind_task_plan
16
17
 
17
18
 
18
19
  LifecycleCommand = Callable[[argparse.Namespace], int]
@@ -115,7 +116,6 @@ def _create_input_names(args: argparse.Namespace) -> list[str]:
115
116
  "assignee",
116
117
  "description",
117
118
  "parent",
118
- "from_plan",
119
119
  )
120
120
  return [name for name in names if getattr(args, name, None)]
121
121
 
@@ -131,6 +131,7 @@ def _validated_next_action(args: argparse.Namespace) -> tuple[dict[str, object]
131
131
  return None, 1
132
132
  create_inputs = _create_input_names(args)
133
133
  action_id = action.get("id")
134
+ from_plan = getattr(args, "from_plan", None)
134
135
  if create_inputs and action_id != "create_task":
135
136
  print(
136
137
  colored(
@@ -140,11 +141,25 @@ def _validated_next_action(args: argparse.Namespace) -> tuple[dict[str, object]
140
141
  file=sys.stderr,
141
142
  )
142
143
  print(
143
- "Hint: archive or finish the current task first, then rerun "
144
- "`task next --run --title ... --slug ... --assignee <name>`.",
144
+ "Hint: those flags only create a new task. To continue the "
145
+ "current task, run:",
145
146
  file=sys.stderr,
146
147
  )
148
+ print(" ./.cowork-flow/run task next <task-dir> --run", file=sys.stderr)
147
149
  return None, 1
150
+ if from_plan and action_id not in {"create_task", "edit_planning_artifacts"}:
151
+ print(
152
+ colored(
153
+ "Error: --from-plan binds a plan to a planning task or "
154
+ "creates a new task, but the next action is "
155
+ f"{action_id}",
156
+ Colors.RED,
157
+ ),
158
+ file=sys.stderr,
159
+ )
160
+ return None, 1
161
+ if action_id == "edit_planning_artifacts" and from_plan:
162
+ return action, 0
148
163
  if payload.get("blockers") or action.get("blockers"):
149
164
  print(colored("Error: next action is blocked", Colors.RED), file=sys.stderr)
150
165
  for blocker in payload.get("blockers", []) or action.get("blockers", []):
@@ -213,6 +228,8 @@ def _dispatch_next_lifecycle_action(
213
228
  approved=bool(getattr(args, "approved", False)),
214
229
  )
215
230
  )
231
+ if action_id == "edit_planning_artifacts":
232
+ return _run_plan_bind_action(args, task_path)
216
233
  print(
217
234
  colored(f"Error: unsupported next action: {action_id}", Colors.RED),
218
235
  file=sys.stderr,
@@ -220,6 +237,21 @@ def _dispatch_next_lifecycle_action(
220
237
  return 1
221
238
 
222
239
 
240
+ def _run_plan_bind_action(args: argparse.Namespace, task_path: str) -> int:
241
+ repo_root = get_repo_root()
242
+ try:
243
+ bind_task_plan(repo_root, repo_root / task_path, args.from_plan)
244
+ except PlanBindingError as error:
245
+ print(
246
+ colored(f"Error: {error.detail}: {error.path}", Colors.RED),
247
+ file=sys.stderr,
248
+ )
249
+ return 1
250
+ print(colored(f"[OK] Plan bound to: {task_path}", Colors.GREEN))
251
+ print(f"Next: ./.cowork-flow/run task next {task_path} --run")
252
+ return 0
253
+
254
+
223
255
  def run_next_action(args: argparse.Namespace, handlers: NextActionHandlers) -> int:
224
256
  action, error_code = _validated_next_action(args)
225
257
  if action is None:
@@ -29,7 +29,7 @@ Action inputs for `next --run`:
29
29
  --priority <P0-P3> Optional task priority for create_task (default: P2)
30
30
  --description <text> Optional task description for create_task
31
31
  --parent <dir> Optional parent task directory for create_task
32
- --from-plan <path> Optional plan path for create_task
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
34
  --commit Optional archive auto-commit flag for archive_task
35
35
 
@@ -40,7 +40,7 @@ Examples:
40
40
  ./.cowork-flow/run task next .cowork-flow/tasks/07-25-demo --validate
41
41
  ./.cowork-flow/run task next .cowork-flow/tasks/07-25-demo
42
42
  ./.cowork-flow/run task next .cowork-flow/tasks/07-25-demo --run
43
- ./.cowork-flow/run task next --run --title "Add login feature" --slug add-login
43
+ ./.cowork-flow/run task next --run --title "Add login feature" --slug add-login --from-plan .cowork-flow/plans/YYYY-MM-DD-add-login.md
44
44
  """
45
45
 
46
46
 
@@ -117,7 +117,10 @@ def _add_next_create_inputs(parser: argparse.ArgumentParser) -> None:
117
117
  "--from-plan",
118
118
  "-f",
119
119
  dest="from_plan",
120
- help="Plan file for create_task decision-anchor skeleton",
120
+ help=(
121
+ "Plan file to bind to the task: at creation, or as a "
122
+ "planning-phase rebind (repo-relative .cowork-flow/plans path)"
123
+ ),
121
124
  )
122
125
 
123
126
 
@@ -31,6 +31,7 @@ DIR_SCRIPTS = "scripts"
31
31
  FILE_DEVELOPER = ".developer"
32
32
  FILE_TASK_JSON = "task.json"
33
33
  TASK_DATE_PREFIX_PATTERN = re.compile(r"^\d{2}-\d{2}-")
34
+ FULL_DATE_PREFIX_PATTERN = re.compile(r"^\d{4}-(\d{2}-\d{2}-)")
34
35
 
35
36
 
36
37
  # =============================================================================
@@ -146,7 +147,14 @@ def generate_task_date_prefix() -> str:
146
147
 
147
148
 
148
149
  def ensure_task_date_prefix(slug: str) -> str:
149
- """Return slug with one MM-DD prefix."""
150
+ """Return slug with exactly one MM-DD prefix.
151
+
152
+ A full YYYY-MM-DD- prefix is normalized to MM-DD-; a bare slug
153
+ gets today's MM-DD prefix.
154
+ """
155
+ full = FULL_DATE_PREFIX_PATTERN.match(slug)
156
+ if full:
157
+ return full.group(1) + slug[full.end():]
150
158
  if TASK_DATE_PREFIX_PATTERN.match(slug):
151
159
  return slug
152
160
  return f"{generate_task_date_prefix()}-{slug}"
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env python3
2
+ """Shared subprocess bootstrap for runtime-owned Python child processes."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ from pathlib import Path
8
+
9
+ RUNTIME_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
10
+
11
+
12
+ def runtime_pythonpath_env(
13
+ scripts_dir: Path = RUNTIME_SCRIPTS_DIR,
14
+ *,
15
+ extra: dict[str, str] | None = None,
16
+ ) -> dict[str, str]:
17
+ """Return a copy of os.environ with `scripts_dir` first on PYTHONPATH.
18
+
19
+ The runtime scripts directory is prepended so a child Python process can
20
+ import `services.*` / `infra.*` from the same runtime this code came from,
21
+ while any caller-provided PYTHONPATH entries are preserved after it.
22
+ """
23
+ env = os.environ.copy()
24
+ existing = env.get("PYTHONPATH")
25
+ env["PYTHONPATH"] = (
26
+ str(scripts_dir)
27
+ if not existing
28
+ else f"{scripts_dir}{os.pathsep}{existing}"
29
+ )
30
+ if extra:
31
+ env.update(extra)
32
+ return env
@@ -4,7 +4,6 @@
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
- import os
8
7
  import subprocess
9
8
  import sys
10
9
  from pathlib import Path
@@ -15,6 +14,7 @@ from adapters.cli.execution_context_args import (
15
14
  parse_public_execution_context_args,
16
15
  )
17
16
  from runtime.execution_context import ExecutionContextError
17
+ from infra.process import runtime_pythonpath_env
18
18
  from infra.skill_manifest import SkillManifestError, skill_command_scripts
19
19
 
20
20
  configure_cli_encoding()
@@ -81,13 +81,7 @@ def resolve_project_python_script(command: str) -> Path | None:
81
81
  def run_python(args: list[str], *, pythonpath: Path | None = None) -> int:
82
82
  env = None
83
83
  if pythonpath is not None:
84
- env = os.environ.copy()
85
- existing = env.get("PYTHONPATH")
86
- env["PYTHONPATH"] = (
87
- str(pythonpath)
88
- if not existing
89
- else f"{pythonpath}{os.pathsep}{existing}"
90
- )
84
+ env = runtime_pythonpath_env(pythonpath)
91
85
  completed = subprocess.run([sys.executable, *args], check=False, env=env)
92
86
  return int(completed.returncode)
93
87
 
@@ -54,7 +54,15 @@ def _first_prompt_value(values: Mapping[str, object] | None) -> str | None:
54
54
 
55
55
 
56
56
  def resolve_context_key(values: Mapping[str, object] | None = None) -> str | None:
57
- return _resolve_env_context_key() or _resolve_input_context_key(values)
57
+ context_key = _resolve_env_context_key() or _resolve_input_context_key(values)
58
+ if context_key:
59
+ return context_key
60
+ # zcode 主会话(Bash CLI)无任何 host session id:用进程标签兜底,
61
+ # 保证 task start / session 解析一次成功,不再回落到其它 host。
62
+ process_label = os.environ.get("ZCODE_PROCESS_LABEL")
63
+ if process_label and process_label.strip():
64
+ return _prefixed_context_key("zcode", process_label)
65
+ return None
58
66
 
59
67
 
60
68
  def _prefixed_context_key(prefix: str, raw: str | None) -> str | None:
@@ -90,6 +98,22 @@ def _resolve_input_context_key(values: Mapping[str, object] | None) -> str | Non
90
98
  if explicit:
91
99
  return _sanitize(explicit)
92
100
 
101
+ # 真实 zcode hook env 带 ZCODE_SESSION_ID:input 的 sessionId/session_id
102
+ # 归 zcode 前缀,避免被通用 key 误标成 opencode/codex;无 session 键直接短路。
103
+ if os.environ.get("ZCODE_SESSION_ID"):
104
+ zcode_session = _first_input_value(
105
+ values,
106
+ (
107
+ "ZCODE_SESSION_ID",
108
+ "zcode_session_id",
109
+ "sessionId",
110
+ "session_id",
111
+ ),
112
+ )
113
+ if zcode_session:
114
+ return _prefixed_context_key("zcode", zcode_session)
115
+ return None
116
+
93
117
  for prefix, names in (
94
118
  ("zcode", ("ZCODE_SESSION_ID", "zcode_session_id")),
95
119
  ("opencode", ("OPENCODE_SESSION_ID", "opencode_session_id", "sessionID", "sessionId")),
@@ -146,13 +170,17 @@ def _write_json(path: Path, data: dict) -> None:
146
170
  os.replace(tmp_path, path)
147
171
 
148
172
 
149
- def _platform_from_context_key(context_key: str) -> str:
173
+ def platform_from_context_key(context_key: str) -> str:
174
+ if context_key.startswith("zcode_"):
175
+ return "zcode"
150
176
  if context_key.startswith("codex_"):
151
177
  return "codex"
152
178
  if context_key.startswith("opencode_"):
153
179
  return "opencode"
154
180
  if context_key.startswith("claude_"):
155
181
  return "claude-code"
182
+ if context_key.startswith("dsh_"):
183
+ return "dsh"
156
184
  return "manual"
157
185
 
158
186
 
@@ -243,7 +271,7 @@ def build_active_task_session(
243
271
  data = {
244
272
  FIELD_ACTIVE_TASK_PATH: normalized,
245
273
  FIELD_SCOPE: SCOPE_MAIN,
246
- "platform": _platform_from_context_key(context_key),
274
+ "platform": platform_from_context_key(context_key),
247
275
  "last_seen_at": _now(),
248
276
  }
249
277
  return (
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
 
10
10
  from infra.files import read_text_utf8
11
11
  from infra.paths import DIR_WORKFLOW
12
+ from services.task_repository import TaskRepository
12
13
 
13
14
 
14
15
  PLAN_DIRECTORY = f"{DIR_WORKFLOW}/plans/"
@@ -121,6 +122,26 @@ def bind_plan_file(
121
122
  return BoundPlanFile(normalized=normalized, path=plan_path)
122
123
 
123
124
 
125
+ def bind_task_plan(
126
+ repo_root: Path,
127
+ task_dir: Path,
128
+ plan_file: str | Path,
129
+ ) -> dict:
130
+ """Validate and bind one plan file to a task's meta, preserving other meta keys."""
131
+ bound = bind_plan_file(
132
+ repo_root,
133
+ plan_file,
134
+ require_exists=True,
135
+ require_non_empty=True,
136
+ )
137
+ repository = TaskRepository(repo_root)
138
+ task_data = repository.load(task_dir)
139
+ meta = task_data.get("meta")
140
+ meta = dict(meta) if isinstance(meta, dict) else {}
141
+ meta["planFile"] = bound.normalized
142
+ return repository.save(task_dir, {"meta": meta})
143
+
144
+
124
145
  def plan_file_blockers(repo_root: Path, task_data: dict) -> list[str]:
125
146
  """Return lifecycle readiness blockers for task.json meta.planFile."""
126
147
  meta = task_data.get("meta")
@@ -19,6 +19,7 @@ from services.task_utils import find_task_by_name
19
19
 
20
20
  from kernel.task_state import DONE_STATUSES # noqa: F401
21
21
  ArchiveFinalizer = Callable[[], bool]
22
+ PLAN_SNAPSHOT_NAME = "plan.md"
22
23
 
23
24
 
24
25
  @dataclass(frozen=True)
@@ -177,6 +178,7 @@ class TaskArchiveService:
177
178
  )
178
179
  self._apply_relationship_updates(relationship_updates)
179
180
  self._normalize_context_paths(task_dir, destination)
181
+ self._snapshot_plan_file(destination, task_data)
180
182
  if finalize is not None and not finalize():
181
183
  raise TaskArchiveError(
182
184
  "TASK-ARCHIVE-FINALIZE-001",
@@ -268,6 +270,47 @@ class TaskArchiveService:
268
270
  f"failed to snapshot task context: {error}",
269
271
  ) from error
270
272
 
273
+ @staticmethod
274
+ def _discard_plan_snapshot(
275
+ destination: Path,
276
+ issues: list[RollbackIssue],
277
+ ) -> None:
278
+ plan_snapshot = destination / PLAN_SNAPSHOT_NAME
279
+ if not plan_snapshot.exists():
280
+ return
281
+ try:
282
+ plan_snapshot.unlink()
283
+ except OSError as error:
284
+ issues.append(
285
+ RollbackIssue(
286
+ "plan_snapshot_remove",
287
+ plan_snapshot,
288
+ str(error),
289
+ )
290
+ )
291
+
292
+ def _snapshot_plan_file(
293
+ self,
294
+ destination: Path,
295
+ task_data: dict,
296
+ ) -> None:
297
+ meta = task_data.get("meta")
298
+ plan_file = meta.get("planFile") if isinstance(meta, dict) else None
299
+ if not isinstance(plan_file, str) or not plan_file.strip():
300
+ return
301
+ plan_path = self.repo_root / plan_file.strip()
302
+ if not plan_path.is_file():
303
+ return
304
+ try:
305
+ snapshot = plan_path.read_bytes()
306
+ (destination / PLAN_SNAPSHOT_NAME).write_bytes(snapshot)
307
+ except OSError as error:
308
+ raise TaskArchiveError(
309
+ "TASK-ARCHIVE-PLAN-001",
310
+ plan_path,
311
+ f"failed to snapshot plan file: {error}",
312
+ ) from error
313
+
271
314
  def _normalize_context_paths(
272
315
  self,
273
316
  source: Path,
@@ -382,6 +425,8 @@ class TaskArchiveService:
382
425
  )
383
426
  )
384
427
 
428
+ self._discard_plan_snapshot(destination, issues)
429
+
385
430
  directory_restore_attempted = False
386
431
  if destination.is_dir() and not source.exists():
387
432
  directory_restore_attempted = True
@@ -3,7 +3,6 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
- import re
7
6
  from dataclasses import dataclass
8
7
  from datetime import datetime
9
8
  from pathlib import Path
@@ -91,8 +90,8 @@ class TaskCreationService:
91
90
  "%Y-%m-%d"
92
91
  )
93
92
  task_data = {
94
- "id": request.slug,
95
- "name": request.slug,
93
+ "id": task_name,
94
+ "name": task_name,
96
95
  "title": request.title,
97
96
  "description": request.description or "",
98
97
  "status": "planning",
@@ -151,8 +150,6 @@ class TaskCreationService:
151
150
 
152
151
  @staticmethod
153
152
  def _task_name(request: TaskCreationRequest) -> str:
154
- if re.match(r"^\d{2}-\d{2}-", request.slug):
155
- return request.slug
156
153
  if request.date_prefix:
157
154
  return f"{request.date_prefix}-{request.slug}"
158
155
  return ensure_task_date_prefix(request.slug)
@@ -20,6 +20,7 @@ from runtime.session_state import (
20
20
  FIELD_SCOPE,
21
21
  SCOPE_SUBAGENT,
22
22
  logical_subagent_context_key,
23
+ platform_from_context_key,
23
24
  resolve_context_key,
24
25
  resolve_host_context_key,
25
26
  runtime_context_path,
@@ -193,7 +194,7 @@ class RuntimeContextService:
193
194
  "schema_version": 2,
194
195
  FIELD_SCOPE: SCOPE_SUBAGENT,
195
196
  FIELD_RUNTIME_CONTEXT_ID: runtime_context_id,
196
- "platform": self._platform_from_context_key(host_context_key),
197
+ "platform": platform_from_context_key(host_context_key),
197
198
  "status": "bound",
198
199
  "last_seen_at": now,
199
200
  }
@@ -347,16 +348,6 @@ class RuntimeContextService:
347
348
  ).hexdigest()[:16]
348
349
  return f"runtime-{kind}-{digest}"
349
350
 
350
- @staticmethod
351
- def _platform_from_context_key(context_key: str) -> str:
352
- if context_key.startswith("codex_"):
353
- return "codex"
354
- if context_key.startswith("opencode_"):
355
- return "opencode"
356
- if context_key.startswith("claude_"):
357
- return "claude-code"
358
- return "manual"
359
-
360
351
  @staticmethod
361
352
  def _now() -> str:
362
353
  return (
@@ -23,6 +23,16 @@
23
23
  }
24
24
  ```
25
25
 
26
+ ## 规划期绑定
27
+
28
+ - 未在创建时绑定的 planning task 可执行
29
+ `./.cowork-flow/run task next <task-dir> --run --from-plan <path>` 补绑计划。
30
+ - 校验与创建阶段一致:路径存在、位于仓库内、`.cowork-flow/plans/` 下且非空。
31
+ - 绑定只写 `task.json.meta.planFile`(保留 meta 其它键),不改任务状态;
32
+ 绑定后重新运行 `task next <task-dir> --run` 继续。
33
+ - 非 planning 任务携带 `--from-plan` 被拒绝(next action 不是
34
+ `edit_planning_artifacts` 时 runner 报错)。
35
+
26
36
  ## 启动实施前
27
37
 
28
38
  Normal / High-risk task 从 `planning` 进入 `in_progress` 前必须满足:
@@ -39,6 +49,13 @@ Tiny task 可以没有 planFile,但仍必须满足现有 anchor 与 implement
39
49
 
40
50
  当 active task 绑定了 `meta.planFile`,恢复清单必须提示先读取当前计划,再读取 `decision-anchor.md` 和 JSONL context。恢复输出只列路径,不展开计划正文。
41
51
 
52
+ ## 归档阶段
53
+
54
+ - 归档任务时,runtime 把 `meta.planFile` 指向的计划文件按字节复制为归档任务目录下的 `plan.md` 快照,使归档记录自包含。
55
+ - 快照是复制不是移动:`.cowork-flow/plans/` 原件与 `meta.planFile` 指针保持不变。
56
+ - `meta.planFile` 缺失、为空或指向不存在的文件时跳过快照,归档照常完成。
57
+ - 快照写入失败按既有归档事务回滚(TASK-ARCHIVE-PLAN-001);回滚时删除已写入的快照。
58
+
42
59
  ## v1 非目标
43
60
 
44
61
  - 不解析计划 step 状态。
@@ -73,6 +73,20 @@
73
73
  "status": "unsupported",
74
74
  "fallback": "inline_or_manual"
75
75
  }
76
+ },
77
+ "dsh": {
78
+ "task_action": {
79
+ "status": "native"
80
+ },
81
+ "subagent_dispatch": {
82
+ "status": "native"
83
+ },
84
+ "file_write": {
85
+ "status": "native"
86
+ },
87
+ "party_board_action": {
88
+ "status": "experimental"
89
+ }
76
90
  }
77
91
  }
78
92
  },
@@ -186,6 +200,37 @@
186
200
  "target": ".claude/hooks/inject-workflow-state.py"
187
201
  }
188
202
  ]
203
+ },
204
+ {
205
+ "id": "dsh",
206
+ "displayName": "DeepSeek Harness",
207
+ "aliases": [
208
+ "dsh"
209
+ ],
210
+ "detectAny": [
211
+ ".dsh"
212
+ ],
213
+ "assetPrefixes": [
214
+ ".dsh/",
215
+ ".cowork-flow/adapters/dsh/"
216
+ ],
217
+ "assetFiles": [],
218
+ "skillTarget": ".agents/skills",
219
+ "adapterPath": ".cowork-flow/adapters/dsh/adapter.yaml",
220
+ "capabilities": {
221
+ "dispatchSubagent": "native",
222
+ "freshChildContext": "native",
223
+ "sendFollowup": "native",
224
+ "waitChild": "native",
225
+ "listChildren": "native",
226
+ "cancelChild": "native",
227
+ "stateInjection": "external",
228
+ "backgroundChild": "native",
229
+ "runtimeContextDispatch": "native",
230
+ "runtimeContextBinding": "shim",
231
+ "runtimeContextCleanup": "shim"
232
+ },
233
+ "commandTargets": []
189
234
  }
190
235
  ],
191
236
  "excludedPrefixes": [
@@ -183,7 +183,8 @@
183
183
  "codex",
184
184
  "claude-code",
185
185
  "opencode",
186
- "zcode"
186
+ "zcode",
187
+ "dsh"
187
188
  ]
188
189
  }
189
190
  }
@@ -0,0 +1,20 @@
1
+ # DSH Host Adapter 标记
2
+
3
+ cowork-flow 用本目录识别项目已接入 DeepSeek Harness(DSH),`sync` 据此自动识别并刷新 DSH 资产(`AGENTS.md`、`.agents/skills/`、`.cowork-flow/adapters/dsh/`)。
4
+
5
+ DSH 侧无需其它配置:`AGENTS.md` 作为工作区指令、`.agents/skills/` 作为技能目录被自动发现。
6
+
7
+ ## DSH 子代理派发与绑定(实测 2026-08-14)
8
+
9
+ 主会话派发正式固定代理(cowork-implement / cowork-check / cowork-research)的实测路径:
10
+
11
+ 1. 主会话创建 runtime context(DSH 无 hook,会话命令需显式设置 `COWORK_FLOW_CONTEXT_ID`):
12
+ `./.cowork-flow/run subagent init --title <t> --role cowork-implement --execution-task-dir <task-dir> --host dsh --adapter dsh`
13
+ 输出 JSON 含 `cowork_runtime_context_id` 与 `cowork_host_context_key`。
14
+ 2. 用 DSH `subagent` 工具派发子代理,prompt 携带上述两个字段。
15
+ 3. 子代理首步执行 `./.cowork-flow/run subagent bind <runtime_context_id> <host_context_key>`;结束后执行 `./.cowork-flow/run subagent close <runtime_context_id>`。
16
+
17
+ 实测结论:bind → status → 读取绑定任务目录 → close 全链路在 DSH 子代理上可用;DSH 子代理具备 shell 工具,绑定状态文件语义正确(close 后 session 文件按设计清理)。
18
+
19
+ 已知局限:bind 生成的 host-session 文件的 `platform` 字段记为 `manual`(运行时 `_platform_from_context_key` 无 `dsh_` 前缀映射);权威平台字段以 `.cowork-flow/.runtime/subagents/<id>.json` 的 `host: dsh` 为准。映射修复另开任务。
20
+
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "0.0.49",
3
+ "version": "0.0.51",
4
4
  "description": "AI 协作工作流引擎:任务生命周期、子代理调度、门禁与状态注入。将 cowork-flow 的完整工作流带入 ZCode。",
5
5
  "author": {
6
6
  "name": "fisklikewater"
@@ -6,7 +6,7 @@
6
6
  "id": "create_task",
7
7
  "label": "create a planned task",
8
8
  "mutatesState": true,
9
- "command": "./.cowork-flow/run task next --run --title <title> --slug <task-name> --assignee <name>"
9
+ "command": "./.cowork-flow/run task next --run --title <title> --slug <task-name> --assignee <name> --from-plan .cowork-flow/plans/YYYY-MM-DD-<task-name>.md"
10
10
  }
11
11
  ]
12
12
  }