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.
- package/README.md +16 -1
- package/package.json +6 -3
- package/src/cli.js +6 -0
- package/src/commands/source-refresh.js +40 -0
- package/src/lib/copy-template.js +130 -1
- package/src/lib/host-assets.js +216 -71
- package/template/.codex/hooks.json +1 -1
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/adapters/cli/task.py +0 -2
- package/template/.cowork-flow/scripts/adapters/cli/task_archive_commands.py +12 -0
- package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +18 -133
- package/template/.cowork-flow/scripts/adapters/cli/task_navigation.py +82 -1
- package/template/.cowork-flow/scripts/adapters/host/host_manifest.py +98 -114
- package/template/.cowork-flow/scripts/infra/skill_manifest.py +23 -3
- package/template/.cowork-flow/scripts/infra/storage/operation_log.py +40 -0
- package/template/.cowork-flow/scripts/infra/storage/state_store.py +356 -1
- package/template/.cowork-flow/scripts/services/context_discovery.py +156 -0
- package/template/.cowork-flow/scripts/services/context_jsonl.py +119 -0
- package/template/.cowork-flow/scripts/services/context_paths.py +186 -0
- package/template/.cowork-flow/scripts/services/lifecycle_checks.py +23 -4
- package/template/.cowork-flow/scripts/services/lifecycle_policy.py +128 -0
- package/template/.cowork-flow/scripts/services/plan_binding.py +146 -0
- package/template/.cowork-flow/scripts/services/task_archive.py +100 -14
- package/template/.cowork-flow/scripts/services/task_context.py +49 -479
- package/template/.cowork-flow/scripts/services/task_creation.py +31 -27
- package/template/.cowork-flow/scripts/services/task_lifecycle.py +155 -18
- package/template/.cowork-flow/spec/contracts/error-output-as-data.md +8 -0
- package/template/.cowork-flow/spec/runtime/index.md +1 -1
- package/template/.cowork-flow/spec/schemas/host-assets.schema.json +32 -6
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/skills/batch-execution/scripts/batch_execution.py +111 -629
- package/template/skills/batch-execution/scripts/batch_recovery.py +94 -0
- package/template/skills/batch-execution/scripts/batch_state_machine.py +201 -0
- package/template/skills/batch-execution/scripts/batch_verification.py +326 -0
- package/template/skills/cowork-flow/SKILL.md +3 -0
- package/template/skills/party-mode/scripts/party_actions.py +95 -0
- package/template/skills/party-mode/scripts/party_mode_v2.py +129 -205
- package/template/skills/party-mode/scripts/party_reports.py +177 -0
- package/template/skills/party-mode/scripts/party_state_machine.py +189 -0
- package/template/skills/runtime-health/scripts/doctor.py +204 -15
- package/template/skills/task-review/scripts/review_check.py +135 -2
- package/template/skills/test-first/SKILL.md +18 -3
- 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 =
|
|
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=
|
|
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 =
|
|
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=
|
|
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 []
|