cowork-flow 0.0.47 → 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 (90) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +102 -23
  3. package/package.json +8 -3
  4. package/src/cli.js +6 -0
  5. package/src/commands/init.js +0 -83
  6. package/src/commands/source-refresh.js +40 -0
  7. package/src/commands/sync.js +6 -0
  8. package/src/commands/update.js +59 -6
  9. package/src/lib/copy-template.js +200 -50
  10. package/src/lib/host-assets.js +367 -64
  11. package/src/lib/plan-applier.js +47 -0
  12. package/template/.claude/hooks/inject-workflow-state.py +7 -0
  13. package/template/.codex/hooks/inject-workflow-state.py +7 -0
  14. package/template/.codex/hooks.json +1 -1
  15. package/template/.cowork-flow/.version +1 -1
  16. package/template/.cowork-flow/config.yaml +0 -11
  17. package/template/.cowork-flow/scripts/adapters/cli/init_developer.py +0 -1
  18. package/template/.cowork-flow/scripts/adapters/cli/subagent.py +7 -4
  19. package/template/.cowork-flow/scripts/adapters/cli/task.py +8 -484
  20. package/template/.cowork-flow/scripts/adapters/cli/task_archive_commands.py +15 -134
  21. package/template/.cowork-flow/scripts/adapters/cli/task_context_commands.py +7 -1
  22. package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +388 -0
  23. package/template/.cowork-flow/scripts/adapters/cli/task_navigation.py +163 -2
  24. package/template/.cowork-flow/scripts/adapters/git/git_context.py +16 -193
  25. package/template/.cowork-flow/scripts/adapters/host/host_manifest.py +170 -84
  26. package/template/.cowork-flow/scripts/infra/config.py +0 -17
  27. package/template/.cowork-flow/scripts/infra/developer_profile.py +0 -95
  28. package/template/.cowork-flow/scripts/infra/paths.py +0 -79
  29. package/template/.cowork-flow/scripts/infra/skill_manifest.py +23 -3
  30. package/template/.cowork-flow/scripts/infra/storage/operation_log.py +40 -0
  31. package/template/.cowork-flow/scripts/infra/storage/state_store.py +356 -1
  32. package/template/.cowork-flow/scripts/kernel/workflow_route.py +15 -15
  33. package/template/.cowork-flow/scripts/run.py +0 -5
  34. package/template/.cowork-flow/scripts/services/context_discovery.py +156 -0
  35. package/template/.cowork-flow/scripts/services/context_jsonl.py +119 -0
  36. package/template/.cowork-flow/scripts/services/context_paths.py +186 -0
  37. package/template/.cowork-flow/scripts/services/lifecycle_checks.py +186 -69
  38. package/template/.cowork-flow/scripts/services/lifecycle_policy.py +128 -0
  39. package/template/.cowork-flow/scripts/services/plan_binding.py +146 -0
  40. package/template/.cowork-flow/scripts/services/readiness.py +3 -170
  41. package/template/.cowork-flow/scripts/services/task_archive.py +115 -24
  42. package/template/.cowork-flow/scripts/services/task_context.py +103 -365
  43. package/template/.cowork-flow/scripts/services/task_creation.py +40 -5
  44. package/template/.cowork-flow/scripts/services/task_lifecycle.py +155 -18
  45. package/template/.cowork-flow/scripts/services/task_routing.py +80 -67
  46. package/template/.cowork-flow/scripts/services/task_utils.py +0 -146
  47. package/template/.cowork-flow/scripts/services/workflow_runtime.py +1 -1
  48. package/template/.cowork-flow/spec/contracts/capabilities.md +23 -0
  49. package/template/.cowork-flow/spec/contracts/decision-anchor.md +2 -2
  50. package/template/.cowork-flow/spec/contracts/error-output-as-data.md +8 -0
  51. package/template/.cowork-flow/spec/contracts/index.md +1 -0
  52. package/template/.cowork-flow/spec/contracts/party-mode-v2-board.md +20 -0
  53. package/template/.cowork-flow/spec/contracts/plan-binding.md +50 -0
  54. package/template/.cowork-flow/spec/contracts/skill-owned-actions.md +31 -7
  55. package/template/.cowork-flow/spec/guides/index.md +3 -1
  56. package/template/.cowork-flow/spec/index.md +5 -4
  57. package/template/.cowork-flow/spec/references/definition-of-done.md +2 -3
  58. package/template/.cowork-flow/spec/references/orchestration-patterns.md +1 -1
  59. package/template/.cowork-flow/spec/runtime/contract-registry.json +14 -0
  60. package/template/.cowork-flow/spec/runtime/host-assets.json +73 -3
  61. package/template/.cowork-flow/spec/runtime/index.md +1 -1
  62. package/template/.cowork-flow/spec/schemas/host-assets.schema.json +150 -8
  63. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  64. package/template/skills/batch-execution/SKILL.md +37 -3
  65. package/template/skills/batch-execution/scripts/batch_action.py +4 -0
  66. package/template/skills/batch-execution/scripts/batch_execution.py +120 -499
  67. package/template/skills/batch-execution/scripts/batch_recovery.py +94 -0
  68. package/template/skills/batch-execution/scripts/batch_state_machine.py +201 -0
  69. package/template/skills/batch-execution/scripts/batch_verification.py +326 -0
  70. package/template/skills/brainstorming/SKILL.md +8 -7
  71. package/template/skills/cowork-flow/SKILL.md +3 -0
  72. package/template/skills/party-mode/scripts/party_action_contract.py +113 -0
  73. package/template/skills/party-mode/scripts/party_actions.py +95 -0
  74. package/template/skills/party-mode/scripts/party_board_store.py +148 -0
  75. package/template/skills/party-mode/scripts/party_mode_v2.py +345 -317
  76. package/template/skills/party-mode/scripts/party_reports.py +177 -0
  77. package/template/skills/party-mode/scripts/party_state_machine.py +189 -0
  78. package/template/skills/runtime-health/SKILL.md +14 -2
  79. package/template/skills/runtime-health/scripts/doctor.py +278 -41
  80. package/template/skills/task-planning/SKILL.md +105 -61
  81. package/template/skills/task-review/SKILL.md +4 -4
  82. package/template/skills/task-review/scripts/review_check.py +277 -20
  83. package/template/skills/test-first/SKILL.md +18 -3
  84. package/template/skills/test-first/manifest.json +8 -4
  85. package/template/.cowork-flow/changes/archive/.gitkeep +0 -1
  86. package/template/.cowork-flow/scripts/adapters/cli/add_session.py +0 -465
  87. package/template/.cowork-flow/scripts/adapters/cli/change.py +0 -412
  88. package/template/.cowork-flow/workspace/index.md +0 -102
  89. package/template/.zcode/scaffold/AGENTS.md +0 -145
  90. package/template/.zcode/scaffold/CLAUDE.md +0 -5
@@ -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 []
@@ -3,177 +3,10 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
- import json
7
6
  from pathlib import Path
8
7
 
9
- from infra.paths import DIR_ARCHIVE, DIR_CHANGES, DIR_TASKS, DIR_WORKFLOW, FILE_TASK_JSON, get_tasks_dir
10
- from services.task_utils import find_task_by_name
11
-
12
- from infra.files import read_text_utf8
13
-
14
-
15
- def _read_text(path: Path) -> str:
16
- return read_text_utf8(path).strip()
17
-
18
-
19
- def _read_json(path: Path) -> dict:
20
- try:
21
- data = json.loads(path.read_text(encoding="utf-8"))
22
- except (OSError, json.JSONDecodeError):
23
- return {}
24
- return data if isinstance(data, dict) else {}
25
-
26
-
27
- def _parse_scalar(value: str) -> object:
28
- if value == "null":
29
- return None
30
- if value == "true":
31
- return True
32
- if value == "false":
33
- return False
34
- return value
35
-
36
-
37
- def _read_metadata(path: Path) -> dict[str, object]:
38
- data: dict[str, object] = {}
39
- text = _read_text(path)
40
- if not text:
41
- return data
42
-
43
- for raw_line in text.splitlines():
44
- line = raw_line.strip()
45
- if not line or line.startswith("#") or ":" not in line:
46
- continue
47
- key, value = line.split(":", 1)
48
- data[key.strip()] = _parse_scalar(value.strip())
49
- return data
50
-
51
-
52
- def _display_path(repo_root: Path, path: Path) -> str:
53
- try:
54
- return path.resolve().relative_to(repo_root.resolve()).as_posix()
55
- except ValueError:
56
- return str(path)
57
-
58
-
59
- def _resolve_link(repo_root: Path, base_dir: str, value: object) -> Path | None:
60
- if not isinstance(value, str) or not value.strip():
61
- return None
62
-
63
- raw_path = Path(value)
64
- if raw_path.is_absolute():
65
- return raw_path
66
-
67
- direct = repo_root / raw_path
68
- workflow_base = Path(DIR_WORKFLOW) / base_dir
69
- if direct.exists() or raw_path.parts[:2] == workflow_base.parts:
70
- return direct
71
- return repo_root / workflow_base / raw_path
72
-
73
-
74
- def _same_path(left: Path, right: Path) -> bool:
75
- try:
76
- return left.resolve() == right.resolve()
77
- except OSError:
78
- return left == right
79
-
80
-
81
- def _task_ancestry(repo_root: Path, task_dir: Path) -> list[Path]:
82
- tasks_dir = get_tasks_dir(repo_root)
83
- ancestry = [task_dir]
84
- seen = {task_dir.name}
85
- current = task_dir
86
-
87
- while True:
88
- data = _read_json(current / FILE_TASK_JSON)
89
- parent = data.get("parent")
90
- if not isinstance(parent, str) or not parent.strip() or parent in seen:
91
- break
92
- parent_dir = find_task_by_name(parent, tasks_dir)
93
- if parent_dir is None:
94
- break
95
- ancestry.append(parent_dir)
96
- seen.add(parent)
97
- current = parent_dir
98
-
99
- return ancestry
100
-
101
-
102
- def _change_applies_to_task(
103
- repo_root: Path,
104
- metadata: dict[str, object],
105
- task_dir: Path,
106
- ) -> tuple[bool, bool]:
107
- ancestry = _task_ancestry(repo_root, task_dir)
108
- linked_task = _resolve_link(repo_root, DIR_TASKS, metadata.get("task"))
109
- if linked_task is not None and any(_same_path(linked_task, item) for item in ancestry):
110
- return True, False
111
-
112
- if linked_task is None:
113
- plan_path = _resolve_link(repo_root, "plans", metadata.get("plan"))
114
- if plan_path is not None:
115
- return True, True
116
-
117
- return False, False
118
-
119
-
120
- def _append_missing_file_blocker(
121
- blockers: list[str],
122
- slug: str,
123
- path: Path,
124
- label: str,
125
- ) -> None:
126
- if not _read_text(path):
127
- blockers.append(f"L2 readiness ({slug}): {label} is missing or empty")
128
-
129
-
130
- def _iter_change_dirs(repo_root: Path) -> list[Path]:
131
- changes_dir = repo_root / DIR_WORKFLOW / DIR_CHANGES
132
- if not changes_dir.is_dir():
133
- return []
134
- return [
135
- path
136
- for path in sorted(changes_dir.iterdir())
137
- if path.is_dir() and path.name != DIR_ARCHIVE
138
- ]
139
-
140
8
 
141
9
  def task_readiness_blockers(repo_root: Path, task_dir: Path) -> list[str]:
142
- """Return readiness blockers for work linked to L2 changes."""
143
- blockers: list[str] = []
144
-
145
- for change_dir in _iter_change_dirs(repo_root):
146
- metadata = _read_metadata(change_dir / "change.yaml")
147
- if metadata.get("level") != "L2" or metadata.get("status") == "archived":
148
- continue
149
-
150
- applies, missing_task_link = _change_applies_to_task(repo_root, metadata, task_dir)
151
- if not applies:
152
- continue
153
-
154
- slug = change_dir.name
155
- if missing_task_link:
156
- blockers.append(f"L2 readiness ({slug}): change.yaml task link is missing")
157
-
158
- _append_missing_file_blocker(blockers, slug, change_dir / "proposal.md", "proposal.md")
159
- _append_missing_file_blocker(blockers, slug, change_dir / "spec.md", "spec.md")
160
- _append_missing_file_blocker(blockers, slug, change_dir / "design.md", "design.md")
161
-
162
- plan_path = _resolve_link(repo_root, "plans", metadata.get("plan"))
163
- if plan_path is None:
164
- blockers.append(f"L2 readiness ({slug}): change.yaml plan link is missing")
165
- elif not plan_path.exists():
166
- blockers.append(
167
- f"L2 readiness ({slug}): plan points to missing path: {_display_path(repo_root, plan_path)}"
168
- )
169
- else:
170
- if not _read_text(plan_path):
171
- blockers.append(f"L2 readiness ({slug}): linked plan is empty")
172
-
173
- linked_task = _resolve_link(repo_root, DIR_TASKS, metadata.get("task"))
174
- if linked_task is not None and not linked_task.exists():
175
- blockers.append(
176
- f"L2 readiness ({slug}): task points to missing path: {_display_path(repo_root, linked_task)}"
177
- )
178
-
179
- return blockers
10
+ """Return additional readiness blockers not covered by task context gates."""
11
+ del repo_root, task_dir
12
+ return []
@@ -12,6 +12,7 @@ from pathlib import Path
12
12
  from infra.paths import DIR_ARCHIVE, get_tasks_dir
13
13
  from runtime.session_state import clear_task_from_sessions
14
14
  from infra.archive_utils import archive_directory_resumable
15
+ from services.context_jsonl import read_context_jsonl_entries
15
16
  from services.task_repository import TaskRepository, TaskRepositoryError
16
17
  from services.task_utils import find_task_by_name
17
18
 
@@ -20,13 +21,37 @@ from kernel.task_state import DONE_STATUSES # noqa: F401
20
21
  ArchiveFinalizer = Callable[[], bool]
21
22
 
22
23
 
24
+ @dataclass(frozen=True)
25
+ class RollbackIssue:
26
+ """Best-effort compensation issue that did not replace the primary error."""
27
+
28
+ stage: str
29
+ path: Path
30
+ detail: str
31
+
32
+ def to_dict(self) -> dict[str, str]:
33
+ return {
34
+ "stage": self.stage,
35
+ "path": str(self.path),
36
+ "detail": self.detail,
37
+ }
38
+
39
+
23
40
  class TaskArchiveError(RuntimeError):
24
41
  """Raised when an archive_task action cannot complete safely."""
25
42
 
26
- def __init__(self, code: str, path: Path, detail: str) -> None:
43
+ def __init__(
44
+ self,
45
+ code: str,
46
+ path: Path,
47
+ detail: str,
48
+ *,
49
+ rollback_issues: tuple[RollbackIssue, ...] = (),
50
+ ) -> None:
27
51
  self.code = code
28
52
  self.path = path
29
53
  self.detail = detail
54
+ self.rollback_issues = tuple(rollback_issues)
30
55
  super().__init__(f"{code}: {detail}: {path}")
31
56
 
32
57
 
@@ -156,10 +181,10 @@ class TaskArchiveService:
156
181
  raise TaskArchiveError(
157
182
  "TASK-ARCHIVE-FINALIZE-001",
158
183
  destination,
159
- "linked change archive failed",
184
+ "archive finalizer failed",
160
185
  )
161
186
  except Exception as error:
162
- self._rollback(
187
+ rollback_issues = self._rollback(
163
188
  task_dir,
164
189
  destination,
165
190
  task_data,
@@ -167,17 +192,22 @@ class TaskArchiveService:
167
192
  context_snapshots,
168
193
  )
169
194
  if isinstance(error, TaskArchiveError):
195
+ error.rollback_issues = tuple(
196
+ (*error.rollback_issues, *rollback_issues)
197
+ )
170
198
  raise
171
199
  if isinstance(error, TaskRepositoryError):
172
200
  raise TaskArchiveError(
173
201
  "TASK-ARCHIVE-WRITE-001",
174
202
  error.path,
175
203
  error.detail,
204
+ rollback_issues=rollback_issues,
176
205
  ) from error
177
206
  raise TaskArchiveError(
178
207
  "TASK-ARCHIVE-FINALIZE-001",
179
208
  destination,
180
209
  f"archive finalizer failed: {error}",
210
+ rollback_issues=rollback_issues,
181
211
  ) from error
182
212
 
183
213
  def _relationship_updates(
@@ -258,18 +288,20 @@ class TaskArchiveService:
258
288
  source_path: str,
259
289
  destination_path: str,
260
290
  ) -> None:
261
- original = context_file.read_text(encoding="utf-8")
291
+ with context_file.open("r", encoding="utf-8", newline="") as stream:
292
+ original = stream.read()
293
+ entries_by_line = {
294
+ entry.line: entry
295
+ for entry in read_context_jsonl_entries(context_file).entries
296
+ }
262
297
  rendered: list[str] = []
263
298
  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:
299
+ for line_number, line in enumerate(original.splitlines(keepends=True), start=1):
300
+ parsed = entries_by_line.get(line_number)
301
+ if parsed is None or not isinstance(parsed.data, dict):
270
302
  rendered.append(line)
271
303
  continue
272
- file_path = entry.get("file") if isinstance(entry, dict) else None
304
+ file_path = parsed.data.get("file")
273
305
  normalized = TaskArchiveService._archived_context_path(
274
306
  file_path,
275
307
  source_path,
@@ -278,13 +310,15 @@ class TaskArchiveService:
278
310
  if normalized == file_path:
279
311
  rendered.append(line)
280
312
  continue
313
+ entry = dict(parsed.data)
281
314
  entry["file"] = normalized
282
315
  rendered.append(
283
- json.dumps(entry, ensure_ascii=False) + ending
316
+ json.dumps(entry, ensure_ascii=False) + parsed.line_ending
284
317
  )
285
318
  changed = True
286
319
  if changed:
287
- context_file.write_text("".join(rendered), encoding="utf-8")
320
+ with context_file.open("w", encoding="utf-8", newline="") as stream:
321
+ stream.write("".join(rendered))
288
322
 
289
323
  @staticmethod
290
324
  def _archived_context_path(
@@ -308,15 +342,23 @@ class TaskArchiveService:
308
342
  self.repository.save(task_dir, changes)
309
343
  applied.append((task_dir, original))
310
344
  except TaskRepositoryError as error:
345
+ rollback_issues: list[RollbackIssue] = []
311
346
  for task_dir, original in reversed(applied):
312
347
  try:
313
348
  self.repository.replace(task_dir, original)
314
- except TaskRepositoryError:
315
- pass
349
+ except Exception as rollback_error:
350
+ rollback_issues.append(
351
+ RollbackIssue(
352
+ "relationship_restore",
353
+ Path(str(getattr(rollback_error, "path", task_dir / "task.json"))),
354
+ str(getattr(rollback_error, "detail", rollback_error)),
355
+ )
356
+ )
316
357
  raise TaskArchiveError(
317
358
  "TASK-ARCHIVE-RELATIONSHIP-001",
318
359
  error.path,
319
360
  error.detail,
361
+ rollback_issues=tuple(rollback_issues),
320
362
  ) from error
321
363
 
322
364
  def _rollback(
@@ -326,22 +368,71 @@ class TaskArchiveService:
326
368
  task_data: dict,
327
369
  relationship_updates: list[tuple[Path, dict, dict]],
328
370
  context_snapshots: dict[str, bytes],
329
- ) -> None:
371
+ ) -> tuple[RollbackIssue, ...]:
372
+ issues: list[RollbackIssue] = []
330
373
  for task_dir, original, _ in relationship_updates:
331
374
  try:
332
375
  self.repository.replace(task_dir, original)
333
- except TaskRepositoryError:
334
- pass
376
+ except Exception as error:
377
+ issues.append(
378
+ RollbackIssue(
379
+ "relationship_restore",
380
+ Path(str(getattr(error, "path", task_dir / "task.json"))),
381
+ str(getattr(error, "detail", error)),
382
+ )
383
+ )
335
384
 
385
+ directory_restore_attempted = False
336
386
  if destination.is_dir() and not source.exists():
337
- archive_directory_resumable(destination, source)
387
+ directory_restore_attempted = True
388
+ try:
389
+ move_result = archive_directory_resumable(destination, source)
390
+ except Exception as error: # best-effort compensation boundary
391
+ issues.append(
392
+ RollbackIssue(
393
+ "directory_restore",
394
+ source,
395
+ f"failed to restore archived directory: {error}",
396
+ )
397
+ )
398
+ else:
399
+ if not move_result.ok:
400
+ issues.append(
401
+ RollbackIssue(
402
+ "directory_restore",
403
+ move_result.destination,
404
+ move_result.message,
405
+ )
406
+ )
338
407
  if source.is_dir():
339
408
  for name, content in context_snapshots.items():
409
+ context_path = source / name
340
410
  try:
341
- (source / name).write_bytes(content)
342
- except OSError:
343
- pass
411
+ context_path.write_bytes(content)
412
+ except Exception as error:
413
+ issues.append(
414
+ RollbackIssue(
415
+ "context_restore",
416
+ context_path,
417
+ str(error),
418
+ )
419
+ )
344
420
  try:
345
421
  self.repository.replace(source, task_data)
346
- except TaskRepositoryError:
347
- pass
422
+ except Exception as error:
423
+ issues.append(
424
+ RollbackIssue(
425
+ "task_json_restore",
426
+ Path(str(getattr(error, "path", source / "task.json"))),
427
+ str(getattr(error, "detail", error)),
428
+ )
429
+ )
430
+ elif not source.exists() and not directory_restore_attempted:
431
+ issues.append(
432
+ RollbackIssue(
433
+ "directory_restore",
434
+ source,
435
+ "source task directory was not restored",
436
+ )
437
+ )
438
+ return tuple(issues)