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.
- package/CHANGELOG.md +41 -0
- package/README.md +102 -23
- package/package.json +8 -3
- package/src/cli.js +6 -0
- package/src/commands/init.js +0 -83
- package/src/commands/source-refresh.js +40 -0
- package/src/commands/sync.js +6 -0
- package/src/commands/update.js +59 -6
- package/src/lib/copy-template.js +200 -50
- package/src/lib/host-assets.js +367 -64
- package/src/lib/plan-applier.js +47 -0
- package/template/.claude/hooks/inject-workflow-state.py +7 -0
- package/template/.codex/hooks/inject-workflow-state.py +7 -0
- package/template/.codex/hooks.json +1 -1
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/config.yaml +0 -11
- package/template/.cowork-flow/scripts/adapters/cli/init_developer.py +0 -1
- package/template/.cowork-flow/scripts/adapters/cli/subagent.py +7 -4
- package/template/.cowork-flow/scripts/adapters/cli/task.py +8 -484
- package/template/.cowork-flow/scripts/adapters/cli/task_archive_commands.py +15 -134
- package/template/.cowork-flow/scripts/adapters/cli/task_context_commands.py +7 -1
- package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +388 -0
- package/template/.cowork-flow/scripts/adapters/cli/task_navigation.py +163 -2
- package/template/.cowork-flow/scripts/adapters/git/git_context.py +16 -193
- package/template/.cowork-flow/scripts/adapters/host/host_manifest.py +170 -84
- package/template/.cowork-flow/scripts/infra/config.py +0 -17
- package/template/.cowork-flow/scripts/infra/developer_profile.py +0 -95
- package/template/.cowork-flow/scripts/infra/paths.py +0 -79
- 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/kernel/workflow_route.py +15 -15
- package/template/.cowork-flow/scripts/run.py +0 -5
- 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 +186 -69
- 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/readiness.py +3 -170
- package/template/.cowork-flow/scripts/services/task_archive.py +115 -24
- package/template/.cowork-flow/scripts/services/task_context.py +103 -365
- package/template/.cowork-flow/scripts/services/task_creation.py +40 -5
- package/template/.cowork-flow/scripts/services/task_lifecycle.py +155 -18
- package/template/.cowork-flow/scripts/services/task_routing.py +80 -67
- package/template/.cowork-flow/scripts/services/task_utils.py +0 -146
- package/template/.cowork-flow/scripts/services/workflow_runtime.py +1 -1
- package/template/.cowork-flow/spec/contracts/capabilities.md +23 -0
- package/template/.cowork-flow/spec/contracts/decision-anchor.md +2 -2
- package/template/.cowork-flow/spec/contracts/error-output-as-data.md +8 -0
- package/template/.cowork-flow/spec/contracts/index.md +1 -0
- package/template/.cowork-flow/spec/contracts/party-mode-v2-board.md +20 -0
- package/template/.cowork-flow/spec/contracts/plan-binding.md +50 -0
- package/template/.cowork-flow/spec/contracts/skill-owned-actions.md +31 -7
- package/template/.cowork-flow/spec/guides/index.md +3 -1
- package/template/.cowork-flow/spec/index.md +5 -4
- package/template/.cowork-flow/spec/references/definition-of-done.md +2 -3
- package/template/.cowork-flow/spec/references/orchestration-patterns.md +1 -1
- package/template/.cowork-flow/spec/runtime/contract-registry.json +14 -0
- package/template/.cowork-flow/spec/runtime/host-assets.json +73 -3
- package/template/.cowork-flow/spec/runtime/index.md +1 -1
- package/template/.cowork-flow/spec/schemas/host-assets.schema.json +150 -8
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/skills/batch-execution/SKILL.md +37 -3
- package/template/skills/batch-execution/scripts/batch_action.py +4 -0
- package/template/skills/batch-execution/scripts/batch_execution.py +120 -499
- 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/brainstorming/SKILL.md +8 -7
- package/template/skills/cowork-flow/SKILL.md +3 -0
- package/template/skills/party-mode/scripts/party_action_contract.py +113 -0
- package/template/skills/party-mode/scripts/party_actions.py +95 -0
- package/template/skills/party-mode/scripts/party_board_store.py +148 -0
- package/template/skills/party-mode/scripts/party_mode_v2.py +345 -317
- 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/SKILL.md +14 -2
- package/template/skills/runtime-health/scripts/doctor.py +278 -41
- package/template/skills/task-planning/SKILL.md +105 -61
- package/template/skills/task-review/SKILL.md +4 -4
- package/template/skills/task-review/scripts/review_check.py +277 -20
- package/template/skills/test-first/SKILL.md +18 -3
- package/template/skills/test-first/manifest.json +8 -4
- package/template/.cowork-flow/changes/archive/.gitkeep +0 -1
- package/template/.cowork-flow/scripts/adapters/cli/add_session.py +0 -465
- package/template/.cowork-flow/scripts/adapters/cli/change.py +0 -412
- package/template/.cowork-flow/workspace/index.md +0 -102
- package/template/.zcode/scaffold/AGENTS.md +0 -145
- 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
|
|
143
|
-
|
|
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__(
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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 =
|
|
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) +
|
|
316
|
+
json.dumps(entry, ensure_ascii=False) + parsed.line_ending
|
|
284
317
|
)
|
|
285
318
|
changed = True
|
|
286
319
|
if changed:
|
|
287
|
-
context_file.
|
|
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
|
|
315
|
-
|
|
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
|
-
) ->
|
|
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
|
|
334
|
-
|
|
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
|
-
|
|
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
|
-
|
|
342
|
-
except
|
|
343
|
-
|
|
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
|
|
347
|
-
|
|
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)
|