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.
Files changed (22) hide show
  1. package/package.json +1 -1
  2. package/template/.cowork-flow/.version +1 -1
  3. package/template/.cowork-flow/scripts/application/task_archive.py +148 -21
  4. package/template/.cowork-flow/scripts/application/task_context.py +134 -25
  5. package/template/.cowork-flow/scripts/application/task_lifecycle.py +3 -1
  6. package/template/.cowork-flow/scripts/commands/add_session.py +137 -124
  7. package/template/.cowork-flow/scripts/commands/task.py +172 -110
  8. package/template/.cowork-flow/scripts/commands/task_archive_commands.py +135 -49
  9. package/template/.cowork-flow/scripts/commands/task_context_commands.py +18 -1
  10. package/template/.cowork-flow/scripts/commands/task_create_command.py +51 -24
  11. package/template/.cowork-flow/scripts/commands/task_navigation.py +145 -81
  12. package/template/.cowork-flow/scripts/commands/task_parser.py +23 -0
  13. package/template/.cowork-flow/scripts/common/core/paths.py +4 -1
  14. package/template/.cowork-flow/scripts/common/gates/gates.py +114 -108
  15. package/template/.cowork-flow/scripts/common/gates/validate_coding_standards.py +104 -62
  16. package/template/.cowork-flow/scripts/common/gates/validate_implementation.py +50 -28
  17. package/template/.cowork-flow/scripts/common/git/git_snapshot.py +14 -0
  18. package/template/.cowork-flow/scripts/common/task/active_task.py +36 -74
  19. package/template/.cowork-flow/spec/references/definition-of-done.md +1 -1
  20. package/template/.cowork-flow/workflow.md +6 -2
  21. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  22. package/template/.zcode/scaffold/.cowork-flow/workflow.md +6 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "0.0.38",
3
+ "version": "0.0.40",
4
4
  "description": "CLI for installing and maintaining cowork-flow collaboration templates.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1 +1 @@
1
- 0.0.38
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
- archive_date = archived_at or datetime.now().strftime("%Y-%m-%d")
83
- destination = (
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
- except (TaskRepositoryError, TaskArchiveError) as error:
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-WRITE-001",
122
- error.path,
123
- error.detail,
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
- segments = normalized.split("/")
57
- invalid_path = (
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
- repo_root = Path(repo_root).resolve()
73
- full_path = repo_root.joinpath(*segments).resolve(strict=False)
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
- str(data["file"]),
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
- if full_path.is_dir():
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 == REVIEW_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,