cowork-flow 0.0.38 → 0.0.40
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/package.json +1 -1
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/application/task_archive.py +148 -21
- package/template/.cowork-flow/scripts/application/task_context.py +134 -25
- package/template/.cowork-flow/scripts/application/task_lifecycle.py +3 -1
- package/template/.cowork-flow/scripts/commands/add_session.py +137 -124
- package/template/.cowork-flow/scripts/commands/task.py +172 -110
- package/template/.cowork-flow/scripts/commands/task_archive_commands.py +135 -49
- package/template/.cowork-flow/scripts/commands/task_context_commands.py +18 -1
- package/template/.cowork-flow/scripts/commands/task_create_command.py +51 -24
- package/template/.cowork-flow/scripts/commands/task_navigation.py +145 -81
- package/template/.cowork-flow/scripts/commands/task_parser.py +23 -0
- package/template/.cowork-flow/scripts/common/core/paths.py +4 -1
- package/template/.cowork-flow/scripts/common/gates/gates.py +114 -108
- package/template/.cowork-flow/scripts/common/gates/validate_coding_standards.py +104 -62
- package/template/.cowork-flow/scripts/common/gates/validate_implementation.py +50 -28
- package/template/.cowork-flow/scripts/common/git/git_snapshot.py +14 -0
- package/template/.cowork-flow/scripts/common/task/active_task.py +36 -74
- package/template/.cowork-flow/spec/references/definition-of-done.md +1 -1
- package/template/.cowork-flow/workflow.md +6 -2
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/.zcode/scaffold/.cowork-flow/workflow.md +6 -2
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.0.
|
|
1
|
+
0.0.40
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Callable
|
|
6
8
|
from dataclasses import dataclass
|
|
7
9
|
from datetime import datetime
|
|
8
10
|
from pathlib import Path
|
|
@@ -15,6 +17,7 @@ from common.task.task_utils import find_task_by_name
|
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
DONE_STATUSES = ("completed", "done")
|
|
20
|
+
ArchiveFinalizer = Callable[[], bool]
|
|
18
21
|
|
|
19
22
|
|
|
20
23
|
class TaskArchiveError(RuntimeError):
|
|
@@ -53,7 +56,38 @@ class TaskArchiveService:
|
|
|
53
56
|
task: str | Path,
|
|
54
57
|
*,
|
|
55
58
|
archived_at: str | None = None,
|
|
59
|
+
finalize: ArchiveFinalizer | None = None,
|
|
56
60
|
) -> TaskArchiveResult:
|
|
61
|
+
task_dir, task_data = self._load_completed_task(task)
|
|
62
|
+
archive_date = archived_at or datetime.now().strftime("%Y-%m-%d")
|
|
63
|
+
destination = self._archive_destination(task_dir, archive_date)
|
|
64
|
+
relationship_updates = self._relationship_updates(
|
|
65
|
+
task_dir.name,
|
|
66
|
+
task_data,
|
|
67
|
+
)
|
|
68
|
+
context_snapshots = self._context_snapshots(task_dir)
|
|
69
|
+
self._move_task(task_dir, destination)
|
|
70
|
+
self._apply_archive_state(
|
|
71
|
+
task_dir,
|
|
72
|
+
destination,
|
|
73
|
+
task_data,
|
|
74
|
+
relationship_updates,
|
|
75
|
+
context_snapshots,
|
|
76
|
+
archive_date,
|
|
77
|
+
finalize,
|
|
78
|
+
)
|
|
79
|
+
clear_task_from_sessions(
|
|
80
|
+
self.repo_root,
|
|
81
|
+
task_dir.relative_to(self.repo_root).as_posix(),
|
|
82
|
+
)
|
|
83
|
+
return TaskArchiveResult(
|
|
84
|
+
source=task_dir,
|
|
85
|
+
destination=destination,
|
|
86
|
+
task_name=task_dir.name,
|
|
87
|
+
archived_at=archive_date,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def _load_completed_task(self, task: str | Path) -> tuple[Path, dict]:
|
|
57
91
|
task_dir = self.repository.resolve(task)
|
|
58
92
|
if not task_dir.is_dir():
|
|
59
93
|
raise TaskArchiveError(
|
|
@@ -78,19 +112,18 @@ class TaskArchiveService:
|
|
|
78
112
|
task_dir,
|
|
79
113
|
f"task status is {status}",
|
|
80
114
|
)
|
|
115
|
+
return task_dir, task_data
|
|
81
116
|
|
|
82
|
-
|
|
83
|
-
|
|
117
|
+
def _archive_destination(self, task_dir: Path, archive_date: str) -> Path:
|
|
118
|
+
return (
|
|
84
119
|
self.tasks_dir
|
|
85
120
|
/ DIR_ARCHIVE
|
|
86
121
|
/ archive_date[:7]
|
|
87
122
|
/ task_dir.name
|
|
88
123
|
)
|
|
89
|
-
relationship_updates = self._relationship_updates(
|
|
90
|
-
task_dir.name,
|
|
91
|
-
task_data,
|
|
92
|
-
)
|
|
93
124
|
|
|
125
|
+
@staticmethod
|
|
126
|
+
def _move_task(task_dir: Path, destination: Path) -> None:
|
|
94
127
|
move_result = archive_directory_resumable(task_dir, destination)
|
|
95
128
|
if not move_result.ok:
|
|
96
129
|
raise TaskArchiveError(
|
|
@@ -99,6 +132,16 @@ class TaskArchiveService:
|
|
|
99
132
|
move_result.message,
|
|
100
133
|
)
|
|
101
134
|
|
|
135
|
+
def _apply_archive_state(
|
|
136
|
+
self,
|
|
137
|
+
task_dir: Path,
|
|
138
|
+
destination: Path,
|
|
139
|
+
task_data: dict,
|
|
140
|
+
relationship_updates: list[tuple[Path, dict, dict]],
|
|
141
|
+
context_snapshots: dict[str, bytes],
|
|
142
|
+
archive_date: str,
|
|
143
|
+
finalize: ArchiveFinalizer | None,
|
|
144
|
+
) -> None:
|
|
102
145
|
try:
|
|
103
146
|
self.repository.save(
|
|
104
147
|
destination,
|
|
@@ -108,32 +151,35 @@ class TaskArchiveService:
|
|
|
108
151
|
},
|
|
109
152
|
)
|
|
110
153
|
self._apply_relationship_updates(relationship_updates)
|
|
111
|
-
|
|
154
|
+
self._normalize_context_paths(task_dir, destination)
|
|
155
|
+
if finalize is not None and not finalize():
|
|
156
|
+
raise TaskArchiveError(
|
|
157
|
+
"TASK-ARCHIVE-FINALIZE-001",
|
|
158
|
+
destination,
|
|
159
|
+
"linked change archive failed",
|
|
160
|
+
)
|
|
161
|
+
except Exception as error:
|
|
112
162
|
self._rollback(
|
|
113
163
|
task_dir,
|
|
114
164
|
destination,
|
|
115
165
|
task_data,
|
|
116
166
|
relationship_updates,
|
|
167
|
+
context_snapshots,
|
|
117
168
|
)
|
|
118
169
|
if isinstance(error, TaskArchiveError):
|
|
119
170
|
raise
|
|
171
|
+
if isinstance(error, TaskRepositoryError):
|
|
172
|
+
raise TaskArchiveError(
|
|
173
|
+
"TASK-ARCHIVE-WRITE-001",
|
|
174
|
+
error.path,
|
|
175
|
+
error.detail,
|
|
176
|
+
) from error
|
|
120
177
|
raise TaskArchiveError(
|
|
121
|
-
"TASK-ARCHIVE-
|
|
122
|
-
|
|
123
|
-
error
|
|
178
|
+
"TASK-ARCHIVE-FINALIZE-001",
|
|
179
|
+
destination,
|
|
180
|
+
f"archive finalizer failed: {error}",
|
|
124
181
|
) from error
|
|
125
182
|
|
|
126
|
-
clear_task_from_sessions(
|
|
127
|
-
self.repo_root,
|
|
128
|
-
task_dir.relative_to(self.repo_root).as_posix(),
|
|
129
|
-
)
|
|
130
|
-
return TaskArchiveResult(
|
|
131
|
-
source=task_dir,
|
|
132
|
-
destination=destination,
|
|
133
|
-
task_name=task_dir.name,
|
|
134
|
-
archived_at=archive_date,
|
|
135
|
-
)
|
|
136
|
-
|
|
137
183
|
def _relationship_updates(
|
|
138
184
|
self,
|
|
139
185
|
task_name: str,
|
|
@@ -177,6 +223,81 @@ class TaskArchiveService:
|
|
|
177
223
|
)
|
|
178
224
|
return updates
|
|
179
225
|
|
|
226
|
+
@staticmethod
|
|
227
|
+
def _context_snapshots(task_dir: Path) -> dict[str, bytes]:
|
|
228
|
+
try:
|
|
229
|
+
return {
|
|
230
|
+
path.name: path.read_bytes()
|
|
231
|
+
for path in task_dir.glob("*.jsonl")
|
|
232
|
+
if path.is_file()
|
|
233
|
+
}
|
|
234
|
+
except OSError as error:
|
|
235
|
+
raise TaskArchiveError(
|
|
236
|
+
"TASK-ARCHIVE-CONTEXT-001",
|
|
237
|
+
task_dir,
|
|
238
|
+
f"failed to snapshot task context: {error}",
|
|
239
|
+
) from error
|
|
240
|
+
|
|
241
|
+
def _normalize_context_paths(
|
|
242
|
+
self,
|
|
243
|
+
source: Path,
|
|
244
|
+
destination: Path,
|
|
245
|
+
) -> None:
|
|
246
|
+
source_path = source.relative_to(self.repo_root).as_posix()
|
|
247
|
+
destination_path = destination.relative_to(self.repo_root).as_posix()
|
|
248
|
+
for context_file in sorted(destination.glob("*.jsonl")):
|
|
249
|
+
self._normalize_context_file(
|
|
250
|
+
context_file,
|
|
251
|
+
source_path,
|
|
252
|
+
destination_path,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
@staticmethod
|
|
256
|
+
def _normalize_context_file(
|
|
257
|
+
context_file: Path,
|
|
258
|
+
source_path: str,
|
|
259
|
+
destination_path: str,
|
|
260
|
+
) -> None:
|
|
261
|
+
original = context_file.read_text(encoding="utf-8")
|
|
262
|
+
rendered: list[str] = []
|
|
263
|
+
changed = False
|
|
264
|
+
for line in original.splitlines(keepends=True):
|
|
265
|
+
content = line.rstrip("\r\n")
|
|
266
|
+
ending = line[len(content):]
|
|
267
|
+
try:
|
|
268
|
+
entry = json.loads(content)
|
|
269
|
+
except json.JSONDecodeError:
|
|
270
|
+
rendered.append(line)
|
|
271
|
+
continue
|
|
272
|
+
file_path = entry.get("file") if isinstance(entry, dict) else None
|
|
273
|
+
normalized = TaskArchiveService._archived_context_path(
|
|
274
|
+
file_path,
|
|
275
|
+
source_path,
|
|
276
|
+
destination_path,
|
|
277
|
+
)
|
|
278
|
+
if normalized == file_path:
|
|
279
|
+
rendered.append(line)
|
|
280
|
+
continue
|
|
281
|
+
entry["file"] = normalized
|
|
282
|
+
rendered.append(
|
|
283
|
+
json.dumps(entry, ensure_ascii=False) + ending
|
|
284
|
+
)
|
|
285
|
+
changed = True
|
|
286
|
+
if changed:
|
|
287
|
+
context_file.write_text("".join(rendered), encoding="utf-8")
|
|
288
|
+
|
|
289
|
+
@staticmethod
|
|
290
|
+
def _archived_context_path(
|
|
291
|
+
file_path: object,
|
|
292
|
+
source_path: str,
|
|
293
|
+
destination_path: str,
|
|
294
|
+
) -> object:
|
|
295
|
+
if file_path == source_path:
|
|
296
|
+
return destination_path
|
|
297
|
+
if isinstance(file_path, str) and file_path.startswith(f"{source_path}/"):
|
|
298
|
+
return destination_path + file_path[len(source_path):]
|
|
299
|
+
return file_path
|
|
300
|
+
|
|
180
301
|
def _apply_relationship_updates(
|
|
181
302
|
self,
|
|
182
303
|
updates: list[tuple[Path, dict, dict]],
|
|
@@ -204,6 +325,7 @@ class TaskArchiveService:
|
|
|
204
325
|
destination: Path,
|
|
205
326
|
task_data: dict,
|
|
206
327
|
relationship_updates: list[tuple[Path, dict, dict]],
|
|
328
|
+
context_snapshots: dict[str, bytes],
|
|
207
329
|
) -> None:
|
|
208
330
|
for task_dir, original, _ in relationship_updates:
|
|
209
331
|
try:
|
|
@@ -214,6 +336,11 @@ class TaskArchiveService:
|
|
|
214
336
|
if destination.is_dir() and not source.exists():
|
|
215
337
|
archive_directory_resumable(destination, source)
|
|
216
338
|
if source.is_dir():
|
|
339
|
+
for name, content in context_snapshots.items():
|
|
340
|
+
try:
|
|
341
|
+
(source / name).write_bytes(content)
|
|
342
|
+
except OSError:
|
|
343
|
+
pass
|
|
217
344
|
try:
|
|
218
345
|
self.repository.replace(source, task_data)
|
|
219
346
|
except TaskRepositoryError:
|
|
@@ -21,6 +21,11 @@ from common.core.paths import (
|
|
|
21
21
|
|
|
22
22
|
CONTEXT_JSONL_FILES = ("implement.jsonl", "check.jsonl", "debug.jsonl")
|
|
23
23
|
CONTEXT_ENTRY_TYPES = frozenset(("file", "directory", "planned-file"))
|
|
24
|
+
PLANNED_FILE_HINT = (
|
|
25
|
+
"If this is a planned new file, run "
|
|
26
|
+
"./.cowork-flow/run task add-planned-file <dir> <jsonl> <path> "
|
|
27
|
+
'"reason" or add "type": "planned-file" to the JSONL entry.'
|
|
28
|
+
)
|
|
24
29
|
|
|
25
30
|
|
|
26
31
|
class TaskContextError(RuntimeError):
|
|
@@ -40,6 +45,15 @@ def normalize_context_path(
|
|
|
40
45
|
) -> tuple[str, Path]:
|
|
41
46
|
"""Return a canonical repo-relative context path and its absolute target."""
|
|
42
47
|
candidate = Path(repo_root) / str(path)
|
|
48
|
+
_validate_context_entry_type(candidate, entry_type)
|
|
49
|
+
normalized = _normalized_context_path(path, entry_type)
|
|
50
|
+
segments = normalized.split("/")
|
|
51
|
+
_validate_context_path(candidate, normalized, segments, path, entry_type)
|
|
52
|
+
repo_root, full_path = _resolved_context_target(repo_root, segments)
|
|
53
|
+
return _typed_context_path(normalized, full_path, repo_root, entry_type)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _validate_context_entry_type(candidate: Path, entry_type: str) -> None:
|
|
43
57
|
if entry_type not in CONTEXT_ENTRY_TYPES:
|
|
44
58
|
raise TaskContextError(
|
|
45
59
|
"TASK-CONTEXT-TYPE-001",
|
|
@@ -47,14 +61,38 @@ def normalize_context_path(
|
|
|
47
61
|
f"unsupported context entry type: {entry_type}",
|
|
48
62
|
)
|
|
49
63
|
|
|
64
|
+
|
|
65
|
+
def _normalized_context_path(path: str, entry_type: str) -> str:
|
|
50
66
|
normalized = str(path).replace("\\", "/")
|
|
51
67
|
while normalized.startswith("./"):
|
|
52
68
|
normalized = normalized[2:]
|
|
53
69
|
if entry_type == "directory" and normalized.endswith("/"):
|
|
54
70
|
normalized = normalized[:-1]
|
|
71
|
+
return normalized
|
|
55
72
|
|
|
56
|
-
|
|
57
|
-
|
|
73
|
+
|
|
74
|
+
def _validate_context_path(
|
|
75
|
+
candidate: Path,
|
|
76
|
+
normalized: str,
|
|
77
|
+
segments: list[str],
|
|
78
|
+
path: str,
|
|
79
|
+
entry_type: str,
|
|
80
|
+
) -> None:
|
|
81
|
+
if not _is_valid_context_path(normalized, segments, path, entry_type):
|
|
82
|
+
raise TaskContextError(
|
|
83
|
+
"TASK-CONTEXT-PATH-002",
|
|
84
|
+
candidate,
|
|
85
|
+
"context path must be a canonical repository-relative path",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _is_valid_context_path(
|
|
90
|
+
normalized: str,
|
|
91
|
+
segments: list[str],
|
|
92
|
+
path: str,
|
|
93
|
+
entry_type: str,
|
|
94
|
+
) -> bool:
|
|
95
|
+
return not (
|
|
58
96
|
not normalized
|
|
59
97
|
or normalized.startswith("/")
|
|
60
98
|
or re.match(r"^[A-Za-z]:", normalized) is not None
|
|
@@ -62,15 +100,22 @@ def normalize_context_path(
|
|
|
62
100
|
or any(character in normalized for character in "*?[]")
|
|
63
101
|
or (entry_type == "planned-file" and str(path).endswith(("/", "\\")))
|
|
64
102
|
)
|
|
65
|
-
if invalid_path:
|
|
66
|
-
raise TaskContextError(
|
|
67
|
-
"TASK-CONTEXT-PATH-002",
|
|
68
|
-
candidate,
|
|
69
|
-
"context path must be a canonical repository-relative path",
|
|
70
|
-
)
|
|
71
103
|
|
|
72
|
-
|
|
73
|
-
|
|
104
|
+
|
|
105
|
+
def _resolved_context_target(
|
|
106
|
+
repo_root: Path,
|
|
107
|
+
segments: list[str],
|
|
108
|
+
) -> tuple[Path, Path]:
|
|
109
|
+
resolved_root = Path(repo_root).resolve()
|
|
110
|
+
return resolved_root, resolved_root.joinpath(*segments).resolve(strict=False)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _typed_context_path(
|
|
114
|
+
normalized: str,
|
|
115
|
+
full_path: Path,
|
|
116
|
+
repo_root: Path,
|
|
117
|
+
entry_type: str,
|
|
118
|
+
) -> tuple[str, Path]:
|
|
74
119
|
try:
|
|
75
120
|
full_path.relative_to(repo_root)
|
|
76
121
|
except ValueError as error:
|
|
@@ -150,31 +195,43 @@ def validate_context_entry(
|
|
|
150
195
|
)
|
|
151
196
|
|
|
152
197
|
entry_type = data.get("type", "file")
|
|
198
|
+
normalized = str(data["file"])
|
|
153
199
|
try:
|
|
154
200
|
normalized_path, full_path = normalize_context_path(
|
|
155
201
|
repo_root,
|
|
156
|
-
|
|
202
|
+
normalized,
|
|
157
203
|
entry_type,
|
|
158
204
|
)
|
|
159
205
|
except TaskContextError as error:
|
|
160
|
-
code = (
|
|
161
|
-
"invalid_entry_type"
|
|
162
|
-
if error.code == "TASK-CONTEXT-TYPE-001"
|
|
163
|
-
else "invalid_path"
|
|
164
|
-
)
|
|
206
|
+
code = _context_error_issue_code(error)
|
|
165
207
|
return _context_issue(context_file, line, code, error.detail)
|
|
166
208
|
|
|
167
209
|
if is_skill_path(normalized_path):
|
|
168
210
|
return None
|
|
211
|
+
return _validate_context_entry_target(
|
|
212
|
+
context_file,
|
|
213
|
+
line,
|
|
214
|
+
entry_type,
|
|
215
|
+
normalized_path,
|
|
216
|
+
full_path,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _context_error_issue_code(error: TaskContextError) -> str:
|
|
221
|
+
if error.code == "TASK-CONTEXT-TYPE-001":
|
|
222
|
+
return "invalid_entry_type"
|
|
223
|
+
return "invalid_path"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _validate_context_entry_target(
|
|
227
|
+
context_file: str,
|
|
228
|
+
line: int,
|
|
229
|
+
entry_type: str,
|
|
230
|
+
normalized_path: str,
|
|
231
|
+
full_path: Path,
|
|
232
|
+
) -> ContextValidationIssue | None:
|
|
169
233
|
if entry_type == "planned-file":
|
|
170
|
-
|
|
171
|
-
return _context_issue(
|
|
172
|
-
context_file,
|
|
173
|
-
line,
|
|
174
|
-
"invalid_path",
|
|
175
|
-
f"Planned file is a directory: {normalized_path}",
|
|
176
|
-
)
|
|
177
|
-
return None
|
|
234
|
+
return _planned_file_issue(context_file, line, normalized_path, full_path)
|
|
178
235
|
if entry_type == "directory" and not full_path.is_dir():
|
|
179
236
|
return _context_issue(
|
|
180
237
|
context_file,
|
|
@@ -187,11 +244,27 @@ def validate_context_entry(
|
|
|
187
244
|
context_file,
|
|
188
245
|
line,
|
|
189
246
|
"file_not_found",
|
|
190
|
-
f"File not found: {normalized_path}",
|
|
247
|
+
f"File not found: {normalized_path}. {PLANNED_FILE_HINT}",
|
|
191
248
|
)
|
|
192
249
|
return None
|
|
193
250
|
|
|
194
251
|
|
|
252
|
+
def _planned_file_issue(
|
|
253
|
+
context_file: str,
|
|
254
|
+
line: int,
|
|
255
|
+
normalized_path: str,
|
|
256
|
+
full_path: Path,
|
|
257
|
+
) -> ContextValidationIssue | None:
|
|
258
|
+
if not full_path.is_dir():
|
|
259
|
+
return None
|
|
260
|
+
return _context_issue(
|
|
261
|
+
context_file,
|
|
262
|
+
line,
|
|
263
|
+
"invalid_path",
|
|
264
|
+
f"Planned file is a directory: {normalized_path}",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
195
268
|
@dataclass(frozen=True)
|
|
196
269
|
class ContextInitializationResult:
|
|
197
270
|
created: tuple[str, ...]
|
|
@@ -463,6 +536,7 @@ class TaskContextService:
|
|
|
463
536
|
created.append(file_name)
|
|
464
537
|
entry_counts[file_name] = len(entries)
|
|
465
538
|
|
|
539
|
+
self.ensure_task_artifact_placeholders(task_dir)
|
|
466
540
|
return ContextInitializationResult(
|
|
467
541
|
created=tuple(created),
|
|
468
542
|
skipped=tuple(skipped),
|
|
@@ -607,6 +681,33 @@ class TaskContextService:
|
|
|
607
681
|
)
|
|
608
682
|
return tuple(summaries)
|
|
609
683
|
|
|
684
|
+
def ensure_task_artifact_placeholders(self, task_dir: Path) -> tuple[str, ...]:
|
|
685
|
+
"""Create empty placeholders for planned task-local context files."""
|
|
686
|
+
task_dir = Path(task_dir)
|
|
687
|
+
created: list[str] = []
|
|
688
|
+
for entry in self.entries(task_dir, "implement"):
|
|
689
|
+
if entry.get("type") == "planned-file":
|
|
690
|
+
continue
|
|
691
|
+
file_path = str(entry.get("file", "")).strip()
|
|
692
|
+
if not file_path:
|
|
693
|
+
continue
|
|
694
|
+
try:
|
|
695
|
+
normalized, full_path = normalize_context_path(
|
|
696
|
+
self.repo_root,
|
|
697
|
+
file_path,
|
|
698
|
+
"planned-file",
|
|
699
|
+
)
|
|
700
|
+
except TaskContextError:
|
|
701
|
+
continue
|
|
702
|
+
if not _is_task_local_artifact(task_dir, full_path):
|
|
703
|
+
continue
|
|
704
|
+
if full_path.exists():
|
|
705
|
+
continue
|
|
706
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
707
|
+
full_path.write_text("", encoding="utf-8")
|
|
708
|
+
created.append(normalized)
|
|
709
|
+
return tuple(created)
|
|
710
|
+
|
|
610
711
|
def _implement_entries(self, dev_type: str) -> list[dict]:
|
|
611
712
|
entries = get_implement_base()
|
|
612
713
|
entries.extend(
|
|
@@ -654,3 +755,11 @@ class TaskContextService:
|
|
|
654
755
|
if context_name.endswith(".jsonl"):
|
|
655
756
|
return context_name
|
|
656
757
|
return f"{context_name}.jsonl"
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _is_task_local_artifact(task_dir: Path, full_path: Path) -> bool:
|
|
761
|
+
try:
|
|
762
|
+
full_path.resolve(strict=False).relative_to(task_dir.resolve(strict=False))
|
|
763
|
+
except ValueError:
|
|
764
|
+
return False
|
|
765
|
+
return not full_path.exists() or full_path.is_file()
|
|
@@ -124,11 +124,13 @@ class TaskLifecycleService:
|
|
|
124
124
|
task: str | Path,
|
|
125
125
|
*,
|
|
126
126
|
completed_at: str | None = None,
|
|
127
|
+
allow_spec_file_modifications: bool = False,
|
|
127
128
|
) -> LifecycleResult:
|
|
128
129
|
return self.execute(
|
|
129
130
|
COMPLETE_STAGE,
|
|
130
131
|
task,
|
|
131
132
|
completed_at=completed_at,
|
|
133
|
+
allow_spec_file_modifications=allow_spec_file_modifications,
|
|
132
134
|
)
|
|
133
135
|
|
|
134
136
|
def execute(
|
|
@@ -308,7 +310,7 @@ class TaskLifecycleService:
|
|
|
308
310
|
task_dir: Path,
|
|
309
311
|
already_at_target: bool,
|
|
310
312
|
) -> LifecycleResult | None:
|
|
311
|
-
if not already_at_target or stage.name
|
|
313
|
+
if not already_at_target or stage.name != START_STAGE.name:
|
|
312
314
|
return None
|
|
313
315
|
return LifecycleResult(
|
|
314
316
|
ok=True,
|