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,119 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Task context JSONL codec."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Iterator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ContextValidationIssue:
|
|
14
|
+
context_file: str
|
|
15
|
+
line: int
|
|
16
|
+
code: str
|
|
17
|
+
message: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ContextJsonlEntry:
|
|
22
|
+
context_file: str
|
|
23
|
+
line: int
|
|
24
|
+
data: object
|
|
25
|
+
text: str
|
|
26
|
+
line_ending: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ContextJsonlReadResult:
|
|
31
|
+
context_file: str
|
|
32
|
+
exists: bool
|
|
33
|
+
entry_count: int
|
|
34
|
+
entries: tuple[ContextJsonlEntry, ...]
|
|
35
|
+
issues: tuple[ContextValidationIssue, ...]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def write_jsonl(path: Path, entries: list[dict]) -> None:
|
|
39
|
+
lines = [json.dumps(entry, ensure_ascii=False) for entry in entries]
|
|
40
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def iter_jsonl_lines(path: Path) -> Iterator[tuple[int, str]]:
|
|
44
|
+
with path.open("r", encoding="utf-8") as stream:
|
|
45
|
+
for line_number, line in enumerate(stream, start=1):
|
|
46
|
+
yield line_number, line.rstrip("\n")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def read_context_jsonl_entries(context_file: Path) -> ContextJsonlReadResult:
|
|
50
|
+
"""Parse a context JSONL file into reusable line-level facts."""
|
|
51
|
+
context_file = Path(context_file)
|
|
52
|
+
if not context_file.is_file():
|
|
53
|
+
return ContextJsonlReadResult(
|
|
54
|
+
context_file=context_file.name,
|
|
55
|
+
exists=False,
|
|
56
|
+
entry_count=0,
|
|
57
|
+
entries=(),
|
|
58
|
+
issues=(),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
entries: list[ContextJsonlEntry] = []
|
|
62
|
+
issues: list[ContextValidationIssue] = []
|
|
63
|
+
entry_count = 0
|
|
64
|
+
try:
|
|
65
|
+
lines: list[tuple[int, str, str]] = []
|
|
66
|
+
with context_file.open("r", encoding="utf-8", newline="") as stream:
|
|
67
|
+
for line_number, raw_line in enumerate(stream, start=1):
|
|
68
|
+
text = raw_line.rstrip("\r\n")
|
|
69
|
+
line_ending = raw_line[len(text):]
|
|
70
|
+
lines.append((line_number, text, line_ending))
|
|
71
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
72
|
+
return ContextJsonlReadResult(
|
|
73
|
+
context_file=context_file.name,
|
|
74
|
+
exists=True,
|
|
75
|
+
entry_count=0,
|
|
76
|
+
entries=(),
|
|
77
|
+
issues=(
|
|
78
|
+
ContextValidationIssue(
|
|
79
|
+
context_file=context_file.name,
|
|
80
|
+
line=0,
|
|
81
|
+
code="read_error",
|
|
82
|
+
message=f"Cannot read {context_file.name}: {error}",
|
|
83
|
+
),
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
for line_number, line, line_ending in lines:
|
|
88
|
+
if not line.strip():
|
|
89
|
+
continue
|
|
90
|
+
entry_count += 1
|
|
91
|
+
try:
|
|
92
|
+
data = json.loads(line)
|
|
93
|
+
except json.JSONDecodeError:
|
|
94
|
+
issues.append(
|
|
95
|
+
ContextValidationIssue(
|
|
96
|
+
context_file=context_file.name,
|
|
97
|
+
line=line_number,
|
|
98
|
+
code="invalid_json",
|
|
99
|
+
message="Invalid JSON",
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
continue
|
|
103
|
+
entries.append(
|
|
104
|
+
ContextJsonlEntry(
|
|
105
|
+
context_file=context_file.name,
|
|
106
|
+
line=line_number,
|
|
107
|
+
data=data,
|
|
108
|
+
text=line,
|
|
109
|
+
line_ending=line_ending,
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return ContextJsonlReadResult(
|
|
114
|
+
context_file=context_file.name,
|
|
115
|
+
exists=True,
|
|
116
|
+
entry_count=entry_count,
|
|
117
|
+
entries=tuple(entries),
|
|
118
|
+
issues=tuple(issues),
|
|
119
|
+
)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Task context repository-relative path policy."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
CONTEXT_ENTRY_TYPES = frozenset(("file", "directory", "planned-file", "deleted-file"))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TaskContextError(RuntimeError):
|
|
14
|
+
"""Raised when a context mutation cannot be performed."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, code: str, path: Path, detail: str) -> None:
|
|
17
|
+
self.code = code
|
|
18
|
+
self.path = path
|
|
19
|
+
self.detail = detail
|
|
20
|
+
super().__init__(f"{code}: {detail}: {path}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def normalize_context_path(
|
|
24
|
+
repo_root: Path,
|
|
25
|
+
path: str,
|
|
26
|
+
entry_type: str,
|
|
27
|
+
) -> tuple[str, Path]:
|
|
28
|
+
"""Return a canonical repo-relative context path and its absolute target."""
|
|
29
|
+
candidate = Path(repo_root) / str(path)
|
|
30
|
+
_validate_context_entry_type(candidate, entry_type)
|
|
31
|
+
normalized = _normalized_context_path(path, entry_type)
|
|
32
|
+
segments = normalized.split("/")
|
|
33
|
+
_validate_context_path(candidate, normalized, segments, path, entry_type)
|
|
34
|
+
repo_root, full_path = _resolved_context_target(repo_root, segments)
|
|
35
|
+
return _typed_context_path(normalized, full_path, repo_root, entry_type)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _validate_context_entry_type(candidate: Path, entry_type: str) -> None:
|
|
39
|
+
if entry_type not in CONTEXT_ENTRY_TYPES:
|
|
40
|
+
raise TaskContextError(
|
|
41
|
+
"TASK-CONTEXT-TYPE-001",
|
|
42
|
+
candidate,
|
|
43
|
+
f"unsupported context entry type: {entry_type}",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _normalized_context_path(path: str, entry_type: str) -> str:
|
|
48
|
+
normalized = str(path).replace("\\", "/")
|
|
49
|
+
while normalized.startswith("./"):
|
|
50
|
+
normalized = normalized[2:]
|
|
51
|
+
if entry_type == "directory" and normalized.endswith("/"):
|
|
52
|
+
normalized = normalized[:-1]
|
|
53
|
+
return normalized
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _validate_context_path(
|
|
57
|
+
candidate: Path,
|
|
58
|
+
normalized: str,
|
|
59
|
+
segments: list[str],
|
|
60
|
+
path: str,
|
|
61
|
+
entry_type: str,
|
|
62
|
+
) -> None:
|
|
63
|
+
if not _is_valid_context_path(normalized, segments, path, entry_type):
|
|
64
|
+
raise TaskContextError(
|
|
65
|
+
"TASK-CONTEXT-PATH-002",
|
|
66
|
+
candidate,
|
|
67
|
+
"context path must be a canonical repository-relative path",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_valid_context_path(
|
|
72
|
+
normalized: str,
|
|
73
|
+
segments: list[str],
|
|
74
|
+
path: str,
|
|
75
|
+
entry_type: str,
|
|
76
|
+
) -> bool:
|
|
77
|
+
return not (
|
|
78
|
+
not normalized
|
|
79
|
+
or normalized.startswith("/")
|
|
80
|
+
or re.match(r"^[A-Za-z]:", normalized) is not None
|
|
81
|
+
or any(segment in ("", ".", "..") for segment in segments)
|
|
82
|
+
or any(character in normalized for character in "*?[]")
|
|
83
|
+
or (entry_type in {"planned-file", "deleted-file"} and str(path).endswith(("/", "\\")))
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _resolved_context_target(
|
|
88
|
+
repo_root: Path,
|
|
89
|
+
segments: list[str],
|
|
90
|
+
) -> tuple[Path, Path]:
|
|
91
|
+
resolved_root = Path(repo_root).resolve()
|
|
92
|
+
return resolved_root, resolved_root.joinpath(*segments).resolve(strict=False)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _typed_context_path(
|
|
96
|
+
normalized: str,
|
|
97
|
+
full_path: Path,
|
|
98
|
+
repo_root: Path,
|
|
99
|
+
entry_type: str,
|
|
100
|
+
) -> tuple[str, Path]:
|
|
101
|
+
try:
|
|
102
|
+
full_path.relative_to(repo_root)
|
|
103
|
+
except ValueError as error:
|
|
104
|
+
raise TaskContextError(
|
|
105
|
+
"TASK-CONTEXT-PATH-002",
|
|
106
|
+
full_path,
|
|
107
|
+
"context path resolves outside the repository",
|
|
108
|
+
) from error
|
|
109
|
+
|
|
110
|
+
if entry_type == "directory":
|
|
111
|
+
normalized = f"{normalized}/"
|
|
112
|
+
return normalized, full_path
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def prepare_context_entry(
|
|
116
|
+
repo_root: Path,
|
|
117
|
+
path: str,
|
|
118
|
+
reason: str,
|
|
119
|
+
requested_type: str | None,
|
|
120
|
+
) -> tuple[str, str, dict]:
|
|
121
|
+
"""Build one validated context entry without writing it."""
|
|
122
|
+
entry_type = requested_type
|
|
123
|
+
if entry_type is None:
|
|
124
|
+
candidate = Path(repo_root) / path
|
|
125
|
+
entry_type = "directory" if candidate.is_dir() else "file"
|
|
126
|
+
|
|
127
|
+
normalized_path, full_path = normalize_context_path(
|
|
128
|
+
repo_root,
|
|
129
|
+
path,
|
|
130
|
+
entry_type,
|
|
131
|
+
)
|
|
132
|
+
valid_target = {
|
|
133
|
+
"directory": full_path.is_dir(),
|
|
134
|
+
"planned-file": not full_path.is_dir(),
|
|
135
|
+
"deleted-file": not full_path.is_dir(),
|
|
136
|
+
"file": full_path.is_file(),
|
|
137
|
+
}[entry_type]
|
|
138
|
+
if not valid_target:
|
|
139
|
+
raise TaskContextError(
|
|
140
|
+
"TASK-CONTEXT-PATH-001",
|
|
141
|
+
full_path,
|
|
142
|
+
f"context {entry_type} path does not exist or has the wrong type",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
entry = {"file": normalized_path, "reason": reason}
|
|
146
|
+
if entry_type != "file":
|
|
147
|
+
entry["type"] = entry_type
|
|
148
|
+
return entry_type, normalized_path, entry
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def normalize_context_file_scope_entry(
|
|
152
|
+
repo_root: Path,
|
|
153
|
+
entry: dict,
|
|
154
|
+
) -> tuple[str | None, str | None]:
|
|
155
|
+
"""Return the file-scope path allowed by one context entry.
|
|
156
|
+
|
|
157
|
+
Directory entries are valid context, but they do not authorize arbitrary
|
|
158
|
+
changed files for lifecycle review.
|
|
159
|
+
"""
|
|
160
|
+
entry_type = entry.get("type", "file")
|
|
161
|
+
file_path = entry.get("file")
|
|
162
|
+
if not isinstance(file_path, str) or not file_path:
|
|
163
|
+
return None, "missing file path"
|
|
164
|
+
if entry_type == "directory":
|
|
165
|
+
return _normalize_context_file_scope_path(repo_root, file_path, entry_type)
|
|
166
|
+
if entry_type not in ("file", "planned-file", "deleted-file"):
|
|
167
|
+
return None, f"unsupported type {entry_type!r}"
|
|
168
|
+
return _normalize_context_file_scope_path(repo_root, file_path, entry_type)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _normalize_context_file_scope_path(
|
|
172
|
+
repo_root: Path,
|
|
173
|
+
file_path: str,
|
|
174
|
+
entry_type: str,
|
|
175
|
+
) -> tuple[str | None, str | None]:
|
|
176
|
+
try:
|
|
177
|
+
normalized, _full_path = normalize_context_path(
|
|
178
|
+
repo_root,
|
|
179
|
+
file_path,
|
|
180
|
+
entry_type,
|
|
181
|
+
)
|
|
182
|
+
except TaskContextError:
|
|
183
|
+
return None, f"non-canonical path {file_path!r}"
|
|
184
|
+
if entry_type == "directory":
|
|
185
|
+
return None, None
|
|
186
|
+
return normalized, None
|
|
@@ -8,12 +8,16 @@ task metadata and the current git snapshot.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
-
import json
|
|
12
11
|
import re
|
|
13
12
|
from dataclasses import dataclass
|
|
14
13
|
from pathlib import Path
|
|
15
14
|
|
|
16
15
|
from infra.git_snapshot import collect_changed_paths
|
|
16
|
+
from services.lifecycle_policy import LifecycleExecutionPolicy
|
|
17
|
+
from services.task_context import (
|
|
18
|
+
normalize_context_file_scope_entry,
|
|
19
|
+
read_context_jsonl_entries,
|
|
20
|
+
)
|
|
17
21
|
|
|
18
22
|
|
|
19
23
|
PROTECTED_WORKFLOW_PATTERNS = (
|
|
@@ -24,12 +28,30 @@ PROTECTED_WORKFLOW_PATTERNS = (
|
|
|
24
28
|
)
|
|
25
29
|
|
|
26
30
|
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class LifecycleCheckIssue:
|
|
33
|
+
"""Machine-readable lifecycle check issue with stable message compatibility."""
|
|
34
|
+
|
|
35
|
+
code: str
|
|
36
|
+
message: str
|
|
37
|
+
path: str | None = None
|
|
38
|
+
|
|
39
|
+
|
|
27
40
|
@dataclass(frozen=True)
|
|
28
41
|
class LifecycleCheckResult:
|
|
29
42
|
"""Result of direct lifecycle fact checks."""
|
|
30
43
|
|
|
31
44
|
stage: str
|
|
32
45
|
blockers: tuple[str, ...] = ()
|
|
46
|
+
issues: tuple[LifecycleCheckIssue, ...] = ()
|
|
47
|
+
|
|
48
|
+
def __post_init__(self) -> None:
|
|
49
|
+
if self.issues and not self.blockers:
|
|
50
|
+
object.__setattr__(
|
|
51
|
+
self,
|
|
52
|
+
"blockers",
|
|
53
|
+
tuple(issue.message for issue in self.issues),
|
|
54
|
+
)
|
|
33
55
|
|
|
34
56
|
@property
|
|
35
57
|
def blocked(self) -> bool:
|
|
@@ -50,16 +72,20 @@ class LifecycleCheckRunner:
|
|
|
50
72
|
self,
|
|
51
73
|
task_dir: Path,
|
|
52
74
|
*,
|
|
53
|
-
allow_spec_file_modifications: bool =
|
|
75
|
+
allow_spec_file_modifications: bool | None = None,
|
|
76
|
+
execution_policy: LifecycleExecutionPolicy | None = None,
|
|
54
77
|
) -> LifecycleCheckResult:
|
|
55
78
|
return LifecycleCheckResult(
|
|
56
79
|
stage="review",
|
|
57
|
-
|
|
58
|
-
|
|
80
|
+
issues=tuple(
|
|
81
|
+
_review_completion_issues(
|
|
59
82
|
self.repo_root,
|
|
60
83
|
task_dir,
|
|
61
|
-
allow_spec_file_modifications=
|
|
62
|
-
|
|
84
|
+
allow_spec_file_modifications=_policy_allows_spec_changes(
|
|
85
|
+
execution_policy,
|
|
86
|
+
allow_spec_file_modifications,
|
|
87
|
+
),
|
|
88
|
+
),
|
|
63
89
|
),
|
|
64
90
|
)
|
|
65
91
|
|
|
@@ -67,63 +93,127 @@ class LifecycleCheckRunner:
|
|
|
67
93
|
self,
|
|
68
94
|
task_dir: Path,
|
|
69
95
|
*,
|
|
70
|
-
allow_spec_file_modifications: bool =
|
|
96
|
+
allow_spec_file_modifications: bool | None = None,
|
|
97
|
+
execution_policy: LifecycleExecutionPolicy | None = None,
|
|
71
98
|
) -> LifecycleCheckResult:
|
|
72
99
|
return LifecycleCheckResult(
|
|
73
100
|
stage="complete",
|
|
74
|
-
|
|
75
|
-
|
|
101
|
+
issues=tuple(
|
|
102
|
+
_review_completion_issues(
|
|
76
103
|
self.repo_root,
|
|
77
104
|
task_dir,
|
|
78
|
-
allow_spec_file_modifications=
|
|
79
|
-
|
|
105
|
+
allow_spec_file_modifications=_policy_allows_spec_changes(
|
|
106
|
+
execution_policy,
|
|
107
|
+
allow_spec_file_modifications,
|
|
108
|
+
),
|
|
109
|
+
),
|
|
80
110
|
),
|
|
81
111
|
)
|
|
82
112
|
|
|
83
113
|
|
|
84
|
-
def
|
|
114
|
+
def _policy_allows_spec_changes(
|
|
115
|
+
execution_policy: LifecycleExecutionPolicy | None,
|
|
116
|
+
allow_spec_file_modifications: bool | None,
|
|
117
|
+
) -> bool:
|
|
118
|
+
if execution_policy is not None:
|
|
119
|
+
return execution_policy.allow_spec_file_modifications
|
|
120
|
+
return bool(allow_spec_file_modifications)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _review_completion_issues(
|
|
85
125
|
repo_root: Path,
|
|
86
126
|
task_dir: Path,
|
|
87
127
|
*,
|
|
88
128
|
allow_spec_file_modifications: bool,
|
|
89
|
-
) -> list[
|
|
129
|
+
) -> list[LifecycleCheckIssue]:
|
|
90
130
|
changed_files = collect_changed_paths(repo_root)
|
|
91
|
-
|
|
131
|
+
issues: list[LifecycleCheckIssue] = []
|
|
92
132
|
if not allow_spec_file_modifications:
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
133
|
+
issues.extend(_protected_workflow_file_issues(changed_files))
|
|
134
|
+
issues.extend(
|
|
135
|
+
_allowed_file_scope_issues(
|
|
136
|
+
task_dir,
|
|
137
|
+
changed_files,
|
|
138
|
+
repo_root=repo_root,
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
return issues
|
|
96
142
|
|
|
97
143
|
|
|
98
|
-
def
|
|
99
|
-
|
|
144
|
+
def _review_completion_blockers(
|
|
145
|
+
repo_root: Path,
|
|
146
|
+
task_dir: Path,
|
|
147
|
+
*,
|
|
148
|
+
allow_spec_file_modifications: bool,
|
|
149
|
+
) -> list[str]:
|
|
150
|
+
return [
|
|
151
|
+
issue.message
|
|
152
|
+
for issue in _review_completion_issues(
|
|
153
|
+
repo_root,
|
|
154
|
+
task_dir,
|
|
155
|
+
allow_spec_file_modifications=allow_spec_file_modifications,
|
|
156
|
+
)
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _protected_workflow_file_issues(changed_files: list[str]) -> list[LifecycleCheckIssue]:
|
|
161
|
+
issues: list[LifecycleCheckIssue] = []
|
|
100
162
|
for file_path in changed_files:
|
|
101
163
|
normalized = _normalize_git_path(file_path)
|
|
102
164
|
if any(re.search(pattern, normalized) for pattern in PROTECTED_WORKFLOW_PATTERNS):
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
165
|
+
issues.append(
|
|
166
|
+
LifecycleCheckIssue(
|
|
167
|
+
code="protected_workflow_file",
|
|
168
|
+
path=normalized,
|
|
169
|
+
message=(
|
|
170
|
+
"Protected workflow/spec file changed outside main session: "
|
|
171
|
+
f"{normalized}"
|
|
172
|
+
),
|
|
173
|
+
)
|
|
106
174
|
)
|
|
107
|
-
return
|
|
175
|
+
return issues
|
|
108
176
|
|
|
109
177
|
|
|
110
|
-
def
|
|
178
|
+
def _protected_workflow_file_blockers(changed_files: list[str]) -> list[str]:
|
|
179
|
+
return [issue.message for issue in _protected_workflow_file_issues(changed_files)]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _allowed_file_scope_issues(
|
|
183
|
+
task_dir: Path,
|
|
184
|
+
changed_files: list[str],
|
|
185
|
+
*,
|
|
186
|
+
repo_root: Path | None = None,
|
|
187
|
+
) -> list[LifecycleCheckIssue]:
|
|
111
188
|
implement_jsonl = task_dir / "implement.jsonl"
|
|
112
189
|
if not implement_jsonl.exists():
|
|
113
190
|
return []
|
|
114
191
|
|
|
115
|
-
allowed_files,
|
|
116
|
-
|
|
117
|
-
|
|
192
|
+
allowed_files, scope_issues = _load_allowed_context_files(
|
|
193
|
+
implement_jsonl,
|
|
194
|
+
repo_root=repo_root,
|
|
195
|
+
)
|
|
196
|
+
if scope_issues:
|
|
197
|
+
return scope_issues
|
|
118
198
|
|
|
119
|
-
|
|
199
|
+
issues: list[LifecycleCheckIssue] = []
|
|
120
200
|
for file_path in changed_files:
|
|
121
201
|
normalized = _normalize_git_path(file_path)
|
|
122
202
|
if _is_runtime_metadata_path(normalized):
|
|
123
203
|
continue
|
|
124
204
|
if normalized not in allowed_files:
|
|
125
|
-
|
|
126
|
-
|
|
205
|
+
issues.append(
|
|
206
|
+
LifecycleCheckIssue(
|
|
207
|
+
code="unlisted_changed_file",
|
|
208
|
+
path=normalized,
|
|
209
|
+
message=f"Modified file not listed in implement.jsonl: {normalized}",
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
return issues
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _allowed_file_scope_blockers(task_dir: Path, changed_files: list[str]) -> list[str]:
|
|
216
|
+
return [issue.message for issue in _allowed_file_scope_issues(task_dir, changed_files)]
|
|
127
217
|
|
|
128
218
|
|
|
129
219
|
def _is_runtime_metadata_path(file_path: str) -> bool:
|
|
@@ -136,55 +226,82 @@ def _is_runtime_metadata_path(file_path: str) -> bool:
|
|
|
136
226
|
)
|
|
137
227
|
|
|
138
228
|
|
|
139
|
-
def
|
|
229
|
+
def _context_issue(code: str, message: str) -> LifecycleCheckIssue:
|
|
230
|
+
return LifecycleCheckIssue(code=code, message=message)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _load_allowed_context_files(
|
|
234
|
+
implement_jsonl: Path,
|
|
235
|
+
*,
|
|
236
|
+
repo_root: Path | None = None,
|
|
237
|
+
) -> tuple[set[str], list[LifecycleCheckIssue]]:
|
|
140
238
|
allowed_files: set[str] = set()
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
239
|
+
issues: list[LifecycleCheckIssue] = []
|
|
240
|
+
repo_root = Path(repo_root) if repo_root is not None else _repo_root_from_task_dir(
|
|
241
|
+
implement_jsonl.parent
|
|
242
|
+
)
|
|
243
|
+
parsed = read_context_jsonl_entries(implement_jsonl)
|
|
244
|
+
for issue in parsed.issues:
|
|
245
|
+
if issue.code == "read_error":
|
|
246
|
+
issues.append(
|
|
247
|
+
_context_issue(
|
|
248
|
+
"implement_jsonl_read_error",
|
|
249
|
+
issue.message,
|
|
250
|
+
)
|
|
251
|
+
)
|
|
150
252
|
continue
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
253
|
+
if issue.code == "invalid_json":
|
|
254
|
+
issues.append(
|
|
255
|
+
_context_issue(
|
|
256
|
+
"invalid_implement_jsonl_json",
|
|
257
|
+
f"Invalid implement.jsonl JSON at line {issue.line}",
|
|
258
|
+
)
|
|
259
|
+
)
|
|
155
260
|
continue
|
|
261
|
+
issues.append(_context_issue(issue.code, issue.message))
|
|
262
|
+
|
|
263
|
+
for context_entry in parsed.entries:
|
|
264
|
+
entry = context_entry.data
|
|
156
265
|
if not isinstance(entry, dict):
|
|
157
|
-
|
|
266
|
+
issues.append(
|
|
267
|
+
_context_issue(
|
|
268
|
+
"invalid_implement_jsonl_entry",
|
|
269
|
+
(
|
|
270
|
+
"Invalid implement.jsonl entry at line "
|
|
271
|
+
f"{context_entry.line}: expected object"
|
|
272
|
+
),
|
|
273
|
+
)
|
|
274
|
+
)
|
|
158
275
|
continue
|
|
159
|
-
normalized, error =
|
|
276
|
+
normalized, error = normalize_context_file_scope_entry(repo_root, entry)
|
|
160
277
|
if error is not None:
|
|
161
|
-
|
|
278
|
+
issues.append(
|
|
279
|
+
_context_issue(
|
|
280
|
+
"invalid_implement_jsonl_file_scope",
|
|
281
|
+
(
|
|
282
|
+
"Invalid implement.jsonl file scope at line "
|
|
283
|
+
f"{context_entry.line}: {error}"
|
|
284
|
+
),
|
|
285
|
+
)
|
|
286
|
+
)
|
|
162
287
|
continue
|
|
163
288
|
if normalized is not None:
|
|
164
289
|
allowed_files.add(normalized)
|
|
165
290
|
if not allowed_files:
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if
|
|
178
|
-
return
|
|
179
|
-
|
|
180
|
-
segments = normalized.split("/")
|
|
181
|
-
invalid = (
|
|
182
|
-
normalized.startswith("/")
|
|
183
|
-
or re.match(r"^[A-Za-z]:", normalized)
|
|
184
|
-
or any(segment in ("", ".", "..") for segment in segments)
|
|
185
|
-
or any(character in normalized for character in "*?[]")
|
|
186
|
-
)
|
|
187
|
-
return (None, f"non-canonical path {file_path!r}") if invalid else (normalized, None)
|
|
291
|
+
issues.append(
|
|
292
|
+
_context_issue(
|
|
293
|
+
"empty_implement_jsonl_file_scope",
|
|
294
|
+
"implement.jsonl contains no valid file-scope entries",
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
return allowed_files, issues
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _repo_root_from_task_dir(task_dir: Path) -> Path:
|
|
301
|
+
task_dir = Path(task_dir)
|
|
302
|
+
if task_dir.parent.name == "tasks" and task_dir.parent.parent.name == ".cowork-flow":
|
|
303
|
+
return task_dir.parent.parent.parent
|
|
304
|
+
return task_dir.parent
|
|
188
305
|
|
|
189
306
|
|
|
190
307
|
def _normalize_git_path(file_path: str) -> str:
|