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,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 = False,
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
- blockers=tuple(
58
- _review_completion_blockers(
80
+ issues=tuple(
81
+ _review_completion_issues(
59
82
  self.repo_root,
60
83
  task_dir,
61
- allow_spec_file_modifications=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 = False,
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
- blockers=tuple(
75
- _review_completion_blockers(
101
+ issues=tuple(
102
+ _review_completion_issues(
76
103
  self.repo_root,
77
104
  task_dir,
78
- allow_spec_file_modifications=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 _review_completion_blockers(
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[str]:
129
+ ) -> list[LifecycleCheckIssue]:
90
130
  changed_files = collect_changed_paths(repo_root)
91
- blockers: list[str] = []
131
+ issues: list[LifecycleCheckIssue] = []
92
132
  if not allow_spec_file_modifications:
93
- blockers.extend(_protected_workflow_file_blockers(changed_files))
94
- blockers.extend(_allowed_file_scope_blockers(task_dir, changed_files))
95
- return blockers
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 _protected_workflow_file_blockers(changed_files: list[str]) -> list[str]:
99
- blockers: list[str] = []
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
- blockers.append(
104
- "Protected workflow/spec file changed outside main session: "
105
- f"{normalized}"
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 blockers
175
+ return issues
108
176
 
109
177
 
110
- def _allowed_file_scope_blockers(task_dir: Path, changed_files: list[str]) -> list[str]:
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, scope_blockers = _load_allowed_context_files(implement_jsonl)
116
- if scope_blockers:
117
- return scope_blockers
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
- blockers: list[str] = []
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
- blockers.append(f"Modified file not listed in implement.jsonl: {normalized}")
126
- return blockers
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 _load_allowed_context_files(implement_jsonl: Path) -> tuple[set[str], list[str]]:
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
- blockers: list[str] = []
142
- try:
143
- lines = implement_jsonl.read_text(encoding="utf-8").splitlines()
144
- except (OSError, UnicodeDecodeError) as error:
145
- return allowed_files, [f"Cannot read implement.jsonl: {error}"]
146
-
147
- for line_number, line in enumerate(lines, start=1):
148
- line = line.strip()
149
- if not line:
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
- try:
152
- entry = json.loads(line)
153
- except json.JSONDecodeError:
154
- blockers.append(f"Invalid implement.jsonl JSON at line {line_number}")
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
- blockers.append(f"Invalid implement.jsonl entry at line {line_number}: expected object")
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 = _normalize_allowed_context_file(entry)
276
+ normalized, error = normalize_context_file_scope_entry(repo_root, entry)
160
277
  if error is not None:
161
- blockers.append(f"Invalid implement.jsonl file scope at line {line_number}: {error}")
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
- blockers.append("implement.jsonl contains no valid file-scope entries")
167
- return allowed_files, blockers
168
-
169
-
170
- def _normalize_allowed_context_file(entry: dict) -> tuple[str | None, str | None]:
171
- entry_type = entry.get("type", "file")
172
- file_path = entry.get("file")
173
- if entry_type == "directory":
174
- return None, None
175
- if entry_type not in ("file", "planned-file", "deleted-file"):
176
- return None, f"unsupported type {entry_type!r}"
177
- if not isinstance(file_path, str) or not file_path:
178
- return None, "missing file path"
179
- normalized = _normalize_git_path(file_path)
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: