cowork-flow 0.0.39 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "0.0.39",
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.39
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:
@@ -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,
@@ -196,6 +196,42 @@ def generate_session_content(
196
196
  """
197
197
 
198
198
 
199
+ def _replace_index_section(
200
+ lines: list[str],
201
+ start_marker: str,
202
+ end_marker: str,
203
+ replacement: list[str],
204
+ ) -> list[str]:
205
+ rendered: list[str] = []
206
+ inside = False
207
+ for line in lines:
208
+ if start_marker in line:
209
+ rendered.extend((line, *replacement))
210
+ inside = True
211
+ elif end_marker in line:
212
+ rendered.append(line)
213
+ inside = False
214
+ elif not inside:
215
+ rendered.append(line)
216
+ return rendered
217
+
218
+
219
+ def _insert_session_history(lines: list[str], row: str) -> list[str]:
220
+ rendered: list[str] = []
221
+ inside = False
222
+ inserted = False
223
+ for line in lines:
224
+ if MARKER_SESSION_HISTORY_START in line:
225
+ inside = True
226
+ elif MARKER_SESSION_HISTORY_END in line:
227
+ inside = False
228
+ rendered.append(line)
229
+ if inside and re.match(r"^\|\s*-", line) and not inserted:
230
+ rendered.append(row)
231
+ inserted = True
232
+ return rendered
233
+
234
+
199
235
  def update_index(
200
236
  index_file: Path,
201
237
  dev_dir: Path,
@@ -207,8 +243,6 @@ def update_index(
207
243
  ) -> bool:
208
244
  """Update index.md with new session info."""
209
245
  commit_display = _format_commit_display(commit)
210
-
211
- # Get file number from active_file name
212
246
  match = re.search(r"(\d+)", active_file)
213
247
  active_num = int(match.group(1)) if match else 0
214
248
  files_table = count_journal_files(dev_dir, active_num)
@@ -225,69 +259,26 @@ def update_index(
225
259
  print("Error: index.md is missing auto-update markers. Please restore the template first.", file=sys.stderr)
226
260
  return False
227
261
 
228
- # Process sections
229
262
  lines = content.splitlines()
230
- new_lines = []
231
-
232
- in_current_status = False
233
- in_active_documents = False
234
- in_session_history = False
235
- header_written = False
236
-
237
- for line in lines:
238
- if MARKER_CURRENT_STATUS_START in line:
239
- new_lines.append(line)
240
- in_current_status = True
241
- new_lines.append(f"- **Current file**: `{active_file}`")
242
- new_lines.append(f"- **Total Sessions**: {new_session}")
243
- new_lines.append(f"- **Last Active**: {today}")
244
- continue
245
-
246
- if MARKER_CURRENT_STATUS_END in line:
247
- in_current_status = False
248
- new_lines.append(line)
249
- continue
250
-
251
- if MARKER_ACTIVE_DOCUMENTS_START in line:
252
- new_lines.append(line)
253
- in_active_documents = True
254
- new_lines.append("| File | Lines | Status |")
255
- new_lines.append("|------|------|------|")
256
- new_lines.append(files_table)
257
- continue
258
-
259
- if MARKER_ACTIVE_DOCUMENTS_END in line:
260
- in_active_documents = False
261
- new_lines.append(line)
262
- continue
263
-
264
- if MARKER_SESSION_HISTORY_START in line:
265
- new_lines.append(line)
266
- in_session_history = True
267
- header_written = False
268
- continue
269
-
270
- if MARKER_SESSION_HISTORY_END in line:
271
- in_session_history = False
272
- new_lines.append(line)
273
- continue
274
-
275
- if in_current_status:
276
- continue
277
-
278
- if in_active_documents:
279
- continue
280
-
281
- if in_session_history:
282
- new_lines.append(line)
283
- if re.match(r"^\|\s*-", line) and not header_written:
284
- new_lines.append(f"| {new_session} | {today} | {title} | {commit_display} |")
285
- header_written = True
286
- continue
287
-
288
- new_lines.append(line)
289
-
290
- index_file.write_text("\n".join(new_lines), encoding="utf-8")
263
+ lines = _replace_index_section(
264
+ lines,
265
+ MARKER_CURRENT_STATUS_START,
266
+ MARKER_CURRENT_STATUS_END,
267
+ [
268
+ f"- **Current file**: `{active_file}`",
269
+ f"- **Total Sessions**: {new_session}",
270
+ f"- **Last Active**: {today}",
271
+ ],
272
+ )
273
+ lines = _replace_index_section(
274
+ lines,
275
+ MARKER_ACTIVE_DOCUMENTS_START,
276
+ MARKER_ACTIVE_DOCUMENTS_END,
277
+ ["| File | Lines | Status |", "|------|------|------|", files_table],
278
+ )
279
+ row = f"| {new_session} | {today} | {title} | {commit_display} |"
280
+ lines = _insert_session_history(lines, row)
281
+ index_file.write_text("\n".join(lines), encoding="utf-8")
291
282
  print("[OK] index.md updated.", file=sys.stderr)
292
283
  return True
293
284
 
@@ -326,87 +317,109 @@ def _auto_commit_workspace(repo_root: Path) -> None:
326
317
  print(f"[WARN] Metadata auto-commit failed: {commit_result.stderr.strip()}", file=sys.stderr)
327
318
 
328
319
 
329
- def add_session(
330
- title: str,
331
- commit: str = "-",
332
- summary: str = "(add summary)",
333
- extra_content: str = "(add details)",
334
- auto_commit: bool = False,
335
- ) -> int:
336
- """Add a new session."""
337
- repo_root = get_repo_root()
338
- ensure_developer(repo_root)
339
-
320
+ def _session_workspace(repo_root: Path):
340
321
  developer = get_developer(repo_root)
341
322
  if not developer:
342
323
  print("Error: developer identity has not been initialized.", file=sys.stderr)
343
- return 1
344
-
324
+ return None
345
325
  dev_dir = get_workspace_dir(repo_root)
346
- if not dev_dir:
326
+ if dev_dir is None or not dev_dir.is_dir():
347
327
  print("Error: workspace directory not found.", file=sys.stderr)
348
- return 1
349
-
350
- max_lines = get_max_journal_lines(repo_root)
351
-
328
+ return None
352
329
  index_file = dev_dir / "index.md"
353
- today = datetime.now().strftime("%Y-%m-%d")
354
-
355
330
  journal_file, current_num, current_lines = get_latest_journal_info(dev_dir)
356
- current_session = get_current_session(index_file)
357
- new_session = current_session + 1
331
+ if not index_file.is_file():
332
+ print(f"Error: workspace index is missing: {index_file}", file=sys.stderr)
333
+ return None
334
+ if journal_file is None:
335
+ print(f"Error: workspace journal is missing: {dev_dir}", file=sys.stderr)
336
+ return None
337
+ return developer, dev_dir, index_file, journal_file, current_num, current_lines
358
338
 
359
- session_content = generate_session_content(
360
- new_session, title, commit, summary, extra_content, today
361
- )
362
- content_lines = len(session_content.splitlines())
363
339
 
364
- print("========================================", file=sys.stderr)
365
- print("Add Session Record", file=sys.stderr)
366
- print("========================================", file=sys.stderr)
367
- print("", file=sys.stderr)
368
- print(f"Session: {new_session}", file=sys.stderr)
340
+ def _print_session_preview(
341
+ new_session: int,
342
+ title: str,
343
+ commit: str,
344
+ current_num: int,
345
+ current_lines: int,
346
+ content_lines: int,
347
+ ) -> None:
348
+ print("=" * 40, "Add Session Record", "=" * 40, sep="\n", file=sys.stderr)
349
+ print(f"\nSession: {new_session}", file=sys.stderr)
369
350
  print(f"Title: {title}", file=sys.stderr)
370
- print(f"Commit: {commit}", file=sys.stderr)
371
- print("", file=sys.stderr)
351
+ print(f"Commit: {commit}\n", file=sys.stderr)
372
352
  print(f"Current journal file: {FILE_JOURNAL_PREFIX}{current_num}.md", file=sys.stderr)
373
353
  print(f"Current lines: {current_lines}", file=sys.stderr)
374
354
  print(f"New content lines: {content_lines}", file=sys.stderr)
375
- print(f"Total lines after append: {current_lines + content_lines}", file=sys.stderr)
376
- print("", file=sys.stderr)
377
-
378
- target_file = journal_file
379
- target_num = current_num
355
+ print(f"Total lines after append: {current_lines + content_lines}\n", file=sys.stderr)
380
356
 
381
- if current_lines + content_lines > max_lines:
382
- target_num = current_num + 1
383
- print(f"[!] Over {max_lines} lines, creating {FILE_JOURNAL_PREFIX}{target_num}.md", file=sys.stderr)
384
- target_file = create_new_journal_file(dev_dir, target_num, developer, today, max_lines)
385
- print(f"Created: {target_file}", file=sys.stderr)
386
357
 
387
- # Append session content
388
- if target_file:
389
- with target_file.open("a", encoding="utf-8") as f:
390
- f.write(session_content)
391
- print(f"[OK] Appended session to {target_file.name}", file=sys.stderr)
392
-
393
- print("", file=sys.stderr)
358
+ def _session_target(
359
+ journal_file: Path,
360
+ current_num: int,
361
+ current_lines: int,
362
+ content_lines: int,
363
+ max_lines: int,
364
+ dev_dir: Path,
365
+ developer: str,
366
+ today: str,
367
+ ) -> tuple[Path, int]:
368
+ if current_lines + content_lines <= max_lines:
369
+ return journal_file, current_num
370
+ target_num = current_num + 1
371
+ print(f"[!] Over {max_lines} lines, creating {FILE_JOURNAL_PREFIX}{target_num}.md", file=sys.stderr)
372
+ target_file = create_new_journal_file(
373
+ dev_dir, target_num, developer, today, max_lines
374
+ )
375
+ print(f"Created: {target_file}", file=sys.stderr)
376
+ return target_file, target_num
394
377
 
395
- # Update index.md
396
- active_file = f"{FILE_JOURNAL_PREFIX}{target_num}.md"
397
- if not update_index(index_file, dev_dir, title, commit, new_session, active_file, today):
398
- return 1
399
378
 
400
- print("", file=sys.stderr)
401
- print("========================================", file=sys.stderr)
379
+ def _print_session_success(new_session: int, target_file: Path) -> None:
380
+ print("\n" + "=" * 40, file=sys.stderr)
402
381
  print(f"[OK] Session {new_session} recorded successfully.", file=sys.stderr)
403
- print("========================================", file=sys.stderr)
404
- print("", file=sys.stderr)
382
+ print("=" * 40 + "\n", file=sys.stderr)
405
383
  print("Updated files:", file=sys.stderr)
406
- print(f" - {target_file.name if target_file else 'journal'}", file=sys.stderr)
384
+ print(f" - {target_file.name}", file=sys.stderr)
407
385
  print(" - index.md", file=sys.stderr)
408
386
 
409
- # Auto-commit workspace changes only when explicitly requested.
387
+
388
+ def add_session(
389
+ title: str,
390
+ commit: str = "-",
391
+ summary: str = "(add summary)",
392
+ extra_content: str = "(add details)",
393
+ auto_commit: bool = False,
394
+ ) -> int:
395
+ """Add a new session."""
396
+ repo_root = get_repo_root()
397
+ ensure_developer(repo_root)
398
+ workspace = _session_workspace(repo_root)
399
+ if workspace is None:
400
+ return 1
401
+ developer, dev_dir, index_file, journal_file, current_num, current_lines = workspace
402
+ max_lines = get_max_journal_lines(repo_root)
403
+ today = datetime.now().strftime("%Y-%m-%d")
404
+ new_session = get_current_session(index_file) + 1
405
+ session_content = generate_session_content(
406
+ new_session, title, commit, summary, extra_content, today
407
+ )
408
+ content_lines = len(session_content.splitlines())
409
+ _print_session_preview(
410
+ new_session, title, commit, current_num, current_lines, content_lines
411
+ )
412
+ target_file, target_num = _session_target(
413
+ journal_file, current_num, current_lines, content_lines,
414
+ max_lines, dev_dir, developer, today,
415
+ )
416
+ with target_file.open("a", encoding="utf-8") as stream:
417
+ stream.write(session_content)
418
+ print(f"[OK] Appended session to {target_file.name}\n", file=sys.stderr)
419
+ active_file = f"{FILE_JOURNAL_PREFIX}{target_num}.md"
420
+ if not update_index(index_file, dev_dir, title, commit, new_session, active_file, today):
421
+ return 1
422
+ _print_session_success(new_session, target_file)
410
423
  if auto_commit:
411
424
  print("", file=sys.stderr)
412
425
  _auto_commit_workspace(repo_root)
@@ -561,6 +561,7 @@ def cmd_complete(args: argparse.Namespace) -> int:
561
561
  result = service.complete(
562
562
  task_dir,
563
563
  completed_at=datetime.now().strftime("%Y-%m-%d"),
564
+ allow_spec_file_modifications=is_main_session(repo_root),
564
565
  )
565
566
  if not result.ok:
566
567
  if result.code == "LIFECYCLE-TRANSITION-001":