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.
- package/README.md +23 -3
- package/package.json +2 -1
- package/presets/dsh/agent.cordis.yml +206 -0
- package/presets/dsh/plugins/workflow-state.js +248 -0
- package/presets/dsh/preset.yml +2 -0
- package/src/cli.js +6 -0
- package/src/commands/install-dsh-preset.js +72 -0
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/adapters/dsh/adapter.yaml +30 -0
- package/template/.cowork-flow/scripts/adapters/cli/batch_mode.py +7 -0
- package/template/.cowork-flow/scripts/adapters/cli/task_navigation.py +20 -2
- package/template/.cowork-flow/scripts/adapters/cli/task_next_runner.py +35 -3
- package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +6 -3
- package/template/.cowork-flow/scripts/infra/paths.py +9 -1
- package/template/.cowork-flow/scripts/infra/process.py +32 -0
- package/template/.cowork-flow/scripts/run.py +2 -8
- package/template/.cowork-flow/scripts/runtime/session_state.py +31 -3
- package/template/.cowork-flow/scripts/services/plan_binding.py +21 -0
- package/template/.cowork-flow/scripts/services/task_archive.py +45 -0
- package/template/.cowork-flow/scripts/services/task_creation.py +2 -5
- package/template/.cowork-flow/scripts/services/workflow_runtime.py +2 -11
- package/template/.cowork-flow/spec/contracts/plan-binding.md +17 -0
- package/template/.cowork-flow/spec/runtime/host-assets.json +45 -0
- package/template/.cowork-flow/spec/schemas/host-assets.schema.json +2 -1
- package/template/.dsh/README.md +20 -0
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/skills/brainstorming/manifest.json +1 -1
- 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(
|
|
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(
|
|
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:
|
|
144
|
-
"
|
|
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>
|
|
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=
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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":
|
|
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":
|
|
95
|
-
"name":
|
|
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":
|
|
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": [
|
|
@@ -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
|
+
|
|
@@ -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
|
}
|