cowork-flow 0.0.48 → 0.0.49

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 (43) hide show
  1. package/README.md +16 -1
  2. package/package.json +6 -3
  3. package/src/cli.js +6 -0
  4. package/src/commands/source-refresh.js +40 -0
  5. package/src/lib/copy-template.js +130 -1
  6. package/src/lib/host-assets.js +216 -71
  7. package/template/.codex/hooks.json +1 -1
  8. package/template/.cowork-flow/.version +1 -1
  9. package/template/.cowork-flow/scripts/adapters/cli/task.py +0 -2
  10. package/template/.cowork-flow/scripts/adapters/cli/task_archive_commands.py +12 -0
  11. package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +18 -133
  12. package/template/.cowork-flow/scripts/adapters/cli/task_navigation.py +82 -1
  13. package/template/.cowork-flow/scripts/adapters/host/host_manifest.py +98 -114
  14. package/template/.cowork-flow/scripts/infra/skill_manifest.py +23 -3
  15. package/template/.cowork-flow/scripts/infra/storage/operation_log.py +40 -0
  16. package/template/.cowork-flow/scripts/infra/storage/state_store.py +356 -1
  17. package/template/.cowork-flow/scripts/services/context_discovery.py +156 -0
  18. package/template/.cowork-flow/scripts/services/context_jsonl.py +119 -0
  19. package/template/.cowork-flow/scripts/services/context_paths.py +186 -0
  20. package/template/.cowork-flow/scripts/services/lifecycle_checks.py +23 -4
  21. package/template/.cowork-flow/scripts/services/lifecycle_policy.py +128 -0
  22. package/template/.cowork-flow/scripts/services/plan_binding.py +146 -0
  23. package/template/.cowork-flow/scripts/services/task_archive.py +100 -14
  24. package/template/.cowork-flow/scripts/services/task_context.py +49 -479
  25. package/template/.cowork-flow/scripts/services/task_creation.py +31 -27
  26. package/template/.cowork-flow/scripts/services/task_lifecycle.py +155 -18
  27. package/template/.cowork-flow/spec/contracts/error-output-as-data.md +8 -0
  28. package/template/.cowork-flow/spec/runtime/index.md +1 -1
  29. package/template/.cowork-flow/spec/schemas/host-assets.schema.json +32 -6
  30. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  31. package/template/skills/batch-execution/scripts/batch_execution.py +111 -629
  32. package/template/skills/batch-execution/scripts/batch_recovery.py +94 -0
  33. package/template/skills/batch-execution/scripts/batch_state_machine.py +201 -0
  34. package/template/skills/batch-execution/scripts/batch_verification.py +326 -0
  35. package/template/skills/cowork-flow/SKILL.md +3 -0
  36. package/template/skills/party-mode/scripts/party_actions.py +95 -0
  37. package/template/skills/party-mode/scripts/party_mode_v2.py +129 -205
  38. package/template/skills/party-mode/scripts/party_reports.py +177 -0
  39. package/template/skills/party-mode/scripts/party_state_machine.py +189 -0
  40. package/template/skills/runtime-health/scripts/doctor.py +204 -15
  41. package/template/skills/task-review/scripts/review_check.py +135 -2
  42. package/template/skills/test-first/SKILL.md +18 -3
  43. package/template/skills/test-first/manifest.json +8 -4
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env python3
2
+ """Task context repository-relative path policy."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from pathlib import Path
8
+
9
+
10
+ CONTEXT_ENTRY_TYPES = frozenset(("file", "directory", "planned-file", "deleted-file"))
11
+
12
+
13
+ class TaskContextError(RuntimeError):
14
+ """Raised when a context mutation cannot be performed."""
15
+
16
+ def __init__(self, code: str, path: Path, detail: str) -> None:
17
+ self.code = code
18
+ self.path = path
19
+ self.detail = detail
20
+ super().__init__(f"{code}: {detail}: {path}")
21
+
22
+
23
+ def normalize_context_path(
24
+ repo_root: Path,
25
+ path: str,
26
+ entry_type: str,
27
+ ) -> tuple[str, Path]:
28
+ """Return a canonical repo-relative context path and its absolute target."""
29
+ candidate = Path(repo_root) / str(path)
30
+ _validate_context_entry_type(candidate, entry_type)
31
+ normalized = _normalized_context_path(path, entry_type)
32
+ segments = normalized.split("/")
33
+ _validate_context_path(candidate, normalized, segments, path, entry_type)
34
+ repo_root, full_path = _resolved_context_target(repo_root, segments)
35
+ return _typed_context_path(normalized, full_path, repo_root, entry_type)
36
+
37
+
38
+ def _validate_context_entry_type(candidate: Path, entry_type: str) -> None:
39
+ if entry_type not in CONTEXT_ENTRY_TYPES:
40
+ raise TaskContextError(
41
+ "TASK-CONTEXT-TYPE-001",
42
+ candidate,
43
+ f"unsupported context entry type: {entry_type}",
44
+ )
45
+
46
+
47
+ def _normalized_context_path(path: str, entry_type: str) -> str:
48
+ normalized = str(path).replace("\\", "/")
49
+ while normalized.startswith("./"):
50
+ normalized = normalized[2:]
51
+ if entry_type == "directory" and normalized.endswith("/"):
52
+ normalized = normalized[:-1]
53
+ return normalized
54
+
55
+
56
+ def _validate_context_path(
57
+ candidate: Path,
58
+ normalized: str,
59
+ segments: list[str],
60
+ path: str,
61
+ entry_type: str,
62
+ ) -> None:
63
+ if not _is_valid_context_path(normalized, segments, path, entry_type):
64
+ raise TaskContextError(
65
+ "TASK-CONTEXT-PATH-002",
66
+ candidate,
67
+ "context path must be a canonical repository-relative path",
68
+ )
69
+
70
+
71
+ def _is_valid_context_path(
72
+ normalized: str,
73
+ segments: list[str],
74
+ path: str,
75
+ entry_type: str,
76
+ ) -> bool:
77
+ return not (
78
+ not normalized
79
+ or normalized.startswith("/")
80
+ or re.match(r"^[A-Za-z]:", normalized) is not None
81
+ or any(segment in ("", ".", "..") for segment in segments)
82
+ or any(character in normalized for character in "*?[]")
83
+ or (entry_type in {"planned-file", "deleted-file"} and str(path).endswith(("/", "\\")))
84
+ )
85
+
86
+
87
+ def _resolved_context_target(
88
+ repo_root: Path,
89
+ segments: list[str],
90
+ ) -> tuple[Path, Path]:
91
+ resolved_root = Path(repo_root).resolve()
92
+ return resolved_root, resolved_root.joinpath(*segments).resolve(strict=False)
93
+
94
+
95
+ def _typed_context_path(
96
+ normalized: str,
97
+ full_path: Path,
98
+ repo_root: Path,
99
+ entry_type: str,
100
+ ) -> tuple[str, Path]:
101
+ try:
102
+ full_path.relative_to(repo_root)
103
+ except ValueError as error:
104
+ raise TaskContextError(
105
+ "TASK-CONTEXT-PATH-002",
106
+ full_path,
107
+ "context path resolves outside the repository",
108
+ ) from error
109
+
110
+ if entry_type == "directory":
111
+ normalized = f"{normalized}/"
112
+ return normalized, full_path
113
+
114
+
115
+ def prepare_context_entry(
116
+ repo_root: Path,
117
+ path: str,
118
+ reason: str,
119
+ requested_type: str | None,
120
+ ) -> tuple[str, str, dict]:
121
+ """Build one validated context entry without writing it."""
122
+ entry_type = requested_type
123
+ if entry_type is None:
124
+ candidate = Path(repo_root) / path
125
+ entry_type = "directory" if candidate.is_dir() else "file"
126
+
127
+ normalized_path, full_path = normalize_context_path(
128
+ repo_root,
129
+ path,
130
+ entry_type,
131
+ )
132
+ valid_target = {
133
+ "directory": full_path.is_dir(),
134
+ "planned-file": not full_path.is_dir(),
135
+ "deleted-file": not full_path.is_dir(),
136
+ "file": full_path.is_file(),
137
+ }[entry_type]
138
+ if not valid_target:
139
+ raise TaskContextError(
140
+ "TASK-CONTEXT-PATH-001",
141
+ full_path,
142
+ f"context {entry_type} path does not exist or has the wrong type",
143
+ )
144
+
145
+ entry = {"file": normalized_path, "reason": reason}
146
+ if entry_type != "file":
147
+ entry["type"] = entry_type
148
+ return entry_type, normalized_path, entry
149
+
150
+
151
+ def normalize_context_file_scope_entry(
152
+ repo_root: Path,
153
+ entry: dict,
154
+ ) -> tuple[str | None, str | None]:
155
+ """Return the file-scope path allowed by one context entry.
156
+
157
+ Directory entries are valid context, but they do not authorize arbitrary
158
+ changed files for lifecycle review.
159
+ """
160
+ entry_type = entry.get("type", "file")
161
+ file_path = entry.get("file")
162
+ if not isinstance(file_path, str) or not file_path:
163
+ return None, "missing file path"
164
+ if entry_type == "directory":
165
+ return _normalize_context_file_scope_path(repo_root, file_path, entry_type)
166
+ if entry_type not in ("file", "planned-file", "deleted-file"):
167
+ return None, f"unsupported type {entry_type!r}"
168
+ return _normalize_context_file_scope_path(repo_root, file_path, entry_type)
169
+
170
+
171
+ def _normalize_context_file_scope_path(
172
+ repo_root: Path,
173
+ file_path: str,
174
+ entry_type: str,
175
+ ) -> tuple[str | None, str | None]:
176
+ try:
177
+ normalized, _full_path = normalize_context_path(
178
+ repo_root,
179
+ file_path,
180
+ entry_type,
181
+ )
182
+ except TaskContextError:
183
+ return None, f"non-canonical path {file_path!r}"
184
+ if entry_type == "directory":
185
+ return None, None
186
+ return normalized, None
@@ -13,6 +13,7 @@ from dataclasses import dataclass
13
13
  from pathlib import Path
14
14
 
15
15
  from infra.git_snapshot import collect_changed_paths
16
+ from services.lifecycle_policy import LifecycleExecutionPolicy
16
17
  from services.task_context import (
17
18
  normalize_context_file_scope_entry,
18
19
  read_context_jsonl_entries,
@@ -71,7 +72,8 @@ class LifecycleCheckRunner:
71
72
  self,
72
73
  task_dir: Path,
73
74
  *,
74
- allow_spec_file_modifications: bool = False,
75
+ allow_spec_file_modifications: bool | None = None,
76
+ execution_policy: LifecycleExecutionPolicy | None = None,
75
77
  ) -> LifecycleCheckResult:
76
78
  return LifecycleCheckResult(
77
79
  stage="review",
@@ -79,7 +81,10 @@ class LifecycleCheckRunner:
79
81
  _review_completion_issues(
80
82
  self.repo_root,
81
83
  task_dir,
82
- allow_spec_file_modifications=allow_spec_file_modifications,
84
+ allow_spec_file_modifications=_policy_allows_spec_changes(
85
+ execution_policy,
86
+ allow_spec_file_modifications,
87
+ ),
83
88
  ),
84
89
  ),
85
90
  )
@@ -88,7 +93,8 @@ class LifecycleCheckRunner:
88
93
  self,
89
94
  task_dir: Path,
90
95
  *,
91
- allow_spec_file_modifications: bool = False,
96
+ allow_spec_file_modifications: bool | None = None,
97
+ execution_policy: LifecycleExecutionPolicy | None = None,
92
98
  ) -> LifecycleCheckResult:
93
99
  return LifecycleCheckResult(
94
100
  stage="complete",
@@ -96,12 +102,25 @@ class LifecycleCheckRunner:
96
102
  _review_completion_issues(
97
103
  self.repo_root,
98
104
  task_dir,
99
- allow_spec_file_modifications=allow_spec_file_modifications,
105
+ allow_spec_file_modifications=_policy_allows_spec_changes(
106
+ execution_policy,
107
+ allow_spec_file_modifications,
108
+ ),
100
109
  ),
101
110
  ),
102
111
  )
103
112
 
104
113
 
114
+ def _policy_allows_spec_changes(
115
+ execution_policy: LifecycleExecutionPolicy | None,
116
+ allow_spec_file_modifications: bool | None,
117
+ ) -> bool:
118
+ if execution_policy is not None:
119
+ return execution_policy.allow_spec_file_modifications
120
+ return bool(allow_spec_file_modifications)
121
+
122
+
123
+
105
124
  def _review_completion_issues(
106
125
  repo_root: Path,
107
126
  task_dir: Path,
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env python3
2
+ """Pure lifecycle policy facts shared by services and delivery adapters."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from runtime.execution_context import ExecutionContext
10
+ from runtime.session_state import is_main_session
11
+ from services.task_context import TaskContextService
12
+
13
+
14
+ PLANNED_FILE_HINT = (
15
+ "If this is a planned new file, add "
16
+ '"type": "planned-file"; if this is an already-deleted file in scope, add '
17
+ '"type": "deleted-file" before retrying.'
18
+ )
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class LifecyclePolicyFailure:
23
+ """Structured lifecycle policy blocker without terminal rendering."""
24
+
25
+ code: str
26
+ title: str
27
+ blockers: tuple[str, ...]
28
+ hint: str = ""
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class LifecycleExecutionPolicy:
33
+ """Execution scope facts used by lifecycle checks."""
34
+
35
+ execution_scope: str
36
+ allow_spec_file_modifications: bool
37
+
38
+
39
+ def start_readiness_failure(
40
+ repo_root: Path,
41
+ task_dir: Path,
42
+ ) -> LifecyclePolicyFailure | None:
43
+ """Return the first start readiness failure, or None when start is ready."""
44
+ repo_root = Path(repo_root)
45
+ task_dir = Path(task_dir)
46
+ context_service = TaskContextService(repo_root)
47
+
48
+ blockers = tuple(context_service.start_blockers(task_dir))
49
+ if blockers:
50
+ return LifecyclePolicyFailure(
51
+ code="TASK-START-001",
52
+ title="Task is not ready to start yet",
53
+ blockers=blockers,
54
+ hint=(
55
+ "write decision-anchor.md and required context JSONL files, "
56
+ "then retry `task next <dir> --run`"
57
+ ),
58
+ )
59
+
60
+ validation_issues = tuple(context_service.validation_issue_summaries(task_dir))
61
+ if validation_issues:
62
+ context_service.ensure_task_artifact_placeholders(task_dir)
63
+ validation_issues = tuple(context_service.validation_issue_summaries(task_dir))
64
+ if validation_issues:
65
+ return LifecyclePolicyFailure(
66
+ code="TASK-CONTEXT-001",
67
+ title="Task context validation failed",
68
+ blockers=validation_issues,
69
+ hint=(
70
+ "run task next <dir> --validate and fix the "
71
+ f"reported issues. {PLANNED_FILE_HINT}"
72
+ ),
73
+ )
74
+
75
+ readiness_blockers = _task_readiness_blockers(repo_root, task_dir)
76
+ if readiness_blockers:
77
+ return LifecyclePolicyFailure(
78
+ code="TASK-READINESS-001",
79
+ title="Task readiness failed",
80
+ blockers=readiness_blockers,
81
+ hint=(
82
+ "run task next <dir> and complete the "
83
+ "reported readiness artifacts"
84
+ ),
85
+ )
86
+ return None
87
+
88
+
89
+ def resolve_execution_policy(
90
+ repo_root: Path,
91
+ execution_context: ExecutionContext | None = None,
92
+ *,
93
+ allow_spec_file_modifications: bool | None = None,
94
+ ) -> LifecycleExecutionPolicy:
95
+ """Resolve lifecycle scope capability from execution facts."""
96
+ if execution_context is None:
97
+ if allow_spec_file_modifications is not None:
98
+ return LifecycleExecutionPolicy(
99
+ execution_scope="explicit",
100
+ allow_spec_file_modifications=allow_spec_file_modifications,
101
+ )
102
+ execution_context = ExecutionContext()
103
+
104
+ if allow_spec_file_modifications is not None:
105
+ allowed = allow_spec_file_modifications
106
+ elif execution_context.is_worker or execution_context.is_subagent:
107
+ allowed = False
108
+ else:
109
+ allowed = is_main_session(Path(repo_root))
110
+ return LifecycleExecutionPolicy(
111
+ execution_scope=execution_context.mode,
112
+ allow_spec_file_modifications=allowed,
113
+ )
114
+
115
+
116
+ def _task_readiness_blockers(repo_root: Path, task_dir: Path) -> tuple[str, ...]:
117
+ try:
118
+ from services.readiness import task_readiness_blockers
119
+ except Exception:
120
+ return ()
121
+ try:
122
+ blockers = task_readiness_blockers(repo_root, task_dir)
123
+ except Exception:
124
+ return (
125
+ "readiness check failed; run task next <dir> --validate and inspect "
126
+ "task readiness artifacts",
127
+ )
128
+ return tuple(str(blocker) for blocker in blockers if str(blocker).strip())
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env python3
2
+ """Shared planFile binding policy for task lifecycle readiness and creation."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from infra.files import read_text_utf8
11
+ from infra.paths import DIR_WORKFLOW
12
+
13
+
14
+ PLAN_DIRECTORY = f"{DIR_WORKFLOW}/plans/"
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class BoundPlanFile:
19
+ normalized: str
20
+ path: Path
21
+
22
+
23
+ class PlanBindingError(RuntimeError):
24
+ """Raised when a planFile path violates the shared binding policy."""
25
+
26
+ def __init__(
27
+ self,
28
+ code: str,
29
+ path: Path,
30
+ detail: str,
31
+ normalized: str | None = None,
32
+ ) -> None:
33
+ self.code = code
34
+ self.path = path
35
+ self.detail = detail
36
+ self.normalized = normalized
37
+ super().__init__(f"{code}: {detail}: {path}")
38
+
39
+
40
+ def normalize_plan_file(plan_file: str | Path) -> str | None:
41
+ """Return a canonical repo-relative .cowork-flow/plans path."""
42
+ normalized = normalize_repo_relative_path(plan_file)
43
+ if normalized is None or not normalized.startswith(PLAN_DIRECTORY):
44
+ return None
45
+ return normalized
46
+
47
+
48
+ def normalize_repo_relative_path(path: str | Path) -> str | None:
49
+ normalized = str(path).replace("\\", "/").strip()
50
+ while normalized.startswith("./"):
51
+ normalized = normalized[2:]
52
+ segments = normalized.split("/")
53
+ if (
54
+ not normalized
55
+ or normalized.startswith("/")
56
+ or re.match(r"^[A-Za-z]:", normalized) is not None
57
+ or any(segment in ("", ".", "..") for segment in segments)
58
+ ):
59
+ return None
60
+ return normalized
61
+
62
+
63
+ def bind_plan_file(
64
+ repo_root: Path,
65
+ plan_file: str | Path,
66
+ *,
67
+ allow_absolute: bool = False,
68
+ require_exists: bool = True,
69
+ require_non_empty: bool = False,
70
+ ) -> BoundPlanFile:
71
+ """Resolve one planFile through the shared canonical path policy."""
72
+ repo_root = Path(repo_root)
73
+ raw_path = Path(plan_file)
74
+ if raw_path.is_absolute():
75
+ if not allow_absolute:
76
+ raise PlanBindingError(
77
+ "invalid_path",
78
+ raw_path,
79
+ "plan file must be a repository-relative path",
80
+ )
81
+ try:
82
+ normalized = raw_path.resolve().relative_to(repo_root.resolve()).as_posix()
83
+ except ValueError as error:
84
+ raise PlanBindingError(
85
+ "outside_repository",
86
+ raw_path,
87
+ "plan file must be inside the repository",
88
+ ) from error
89
+ else:
90
+ normalized = normalize_repo_relative_path(plan_file)
91
+ if normalized is None:
92
+ raise PlanBindingError(
93
+ "invalid_path",
94
+ repo_root / raw_path,
95
+ "plan file must be a canonical repository-relative path",
96
+ )
97
+
98
+ if not normalized.startswith(PLAN_DIRECTORY):
99
+ raise PlanBindingError(
100
+ "wrong_location",
101
+ repo_root / normalized,
102
+ f"plan file must live under {DIR_WORKFLOW}/plans",
103
+ normalized,
104
+ )
105
+
106
+ plan_path = repo_root / normalized
107
+ if require_exists and not plan_path.is_file():
108
+ raise PlanBindingError(
109
+ "missing",
110
+ plan_path,
111
+ "plan file does not exist",
112
+ normalized,
113
+ )
114
+ if require_non_empty and not read_text_utf8(plan_path).strip():
115
+ raise PlanBindingError(
116
+ "empty",
117
+ plan_path,
118
+ "plan file is empty",
119
+ normalized,
120
+ )
121
+ return BoundPlanFile(normalized=normalized, path=plan_path)
122
+
123
+
124
+ def plan_file_blockers(repo_root: Path, task_data: dict) -> list[str]:
125
+ """Return lifecycle readiness blockers for task.json meta.planFile."""
126
+ meta = task_data.get("meta")
127
+ plan_file = meta.get("planFile") if isinstance(meta, dict) else None
128
+ if not isinstance(plan_file, str) or not plan_file.strip():
129
+ return ["planFile is required before implementation starts"]
130
+ try:
131
+ bind_plan_file(
132
+ repo_root,
133
+ plan_file,
134
+ allow_absolute=False,
135
+ require_exists=True,
136
+ require_non_empty=True,
137
+ )
138
+ except PlanBindingError as error:
139
+ if error.code in {"invalid_path", "outside_repository", "wrong_location"}:
140
+ return ["planFile must be a repo-relative .cowork-flow/plans path"]
141
+ if error.code == "missing":
142
+ return [f"planFile does not exist: {error.normalized}"]
143
+ if error.code == "empty":
144
+ return [f"planFile is empty: {error.normalized}"]
145
+ return [error.detail]
146
+ return []