cowork-flow 0.0.37 → 0.0.39
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/README.md +27 -14
- package/package.json +1 -1
- package/src/commands/init.js +39 -28
- package/src/commands/sync.js +2 -2
- package/src/lib/asset-plan.js +127 -0
- package/src/lib/copy-template.js +101 -141
- package/src/lib/host-assets.js +231 -0
- package/src/lib/plan-applier.js +329 -0
- package/src/lib/platforms.js +15 -76
- package/src/lib/skill-registry.js +503 -0
- package/template/.claude/agents/cowork-check.md +10 -1
- package/template/.claude/agents/cowork-implement.md +11 -2
- package/template/.claude/hooks/inject-workflow-state.py +51 -254
- package/template/.codex/agents/cowork-check.toml +12 -3
- package/template/.codex/agents/cowork-implement.toml +11 -2
- package/template/.codex/agents/default.toml +3 -2
- package/template/.codex/agents/explorer.toml +3 -2
- package/template/.codex/agents/worker.toml +2 -1
- package/template/.codex/hooks/inject-workflow-state.py +46 -265
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/application/__init__.py +1 -0
- package/template/.cowork-flow/scripts/application/batch_execution.py +743 -0
- package/template/.cowork-flow/scripts/application/runtime_context_service.py +365 -0
- package/template/.cowork-flow/scripts/application/task_archive.py +220 -0
- package/template/.cowork-flow/scripts/application/task_context.py +765 -0
- package/template/.cowork-flow/scripts/application/task_creation.py +201 -0
- package/template/.cowork-flow/scripts/application/task_lifecycle.py +532 -0
- package/template/.cowork-flow/scripts/application/task_tree.py +179 -0
- package/template/.cowork-flow/scripts/commands/doctor.py +78 -57
- package/template/.cowork-flow/scripts/commands/party_mode_v2.py +671 -425
- package/template/.cowork-flow/scripts/commands/subagent.py +38 -26
- package/template/.cowork-flow/scripts/commands/task.py +535 -1953
- package/template/.cowork-flow/scripts/commands/task_archive_commands.py +195 -0
- package/template/.cowork-flow/scripts/commands/task_context_commands.py +240 -0
- package/template/.cowork-flow/scripts/commands/task_create_command.py +164 -0
- package/template/.cowork-flow/scripts/commands/task_navigation.py +407 -0
- package/template/.cowork-flow/scripts/commands/task_parser.py +364 -0
- package/template/.cowork-flow/scripts/commands/task_support.py +76 -0
- package/template/.cowork-flow/scripts/commands/task_tree_commands.py +160 -0
- package/template/.cowork-flow/scripts/common/core/execution_context.py +161 -85
- package/template/.cowork-flow/scripts/common/core/host_manifest.py +393 -0
- package/template/.cowork-flow/scripts/common/core/skill_registry.py +568 -0
- package/template/.cowork-flow/scripts/common/gates/coding_standards.py +58 -7
- package/template/.cowork-flow/scripts/common/gates/gates.py +323 -46
- package/template/.cowork-flow/scripts/common/gates/models.py +196 -0
- package/template/.cowork-flow/scripts/common/gates/registry.py +79 -0
- package/template/.cowork-flow/scripts/common/gates/tdd_evidence.py +13 -13
- package/template/.cowork-flow/scripts/common/gates/validate_coding_standards.py +104 -62
- package/template/.cowork-flow/scripts/common/gates/validate_implementation.py +39 -22
- package/template/.cowork-flow/scripts/common/gates/validate_rules.py +223 -40
- package/template/.cowork-flow/scripts/common/git/git_context.py +216 -182
- package/template/.cowork-flow/scripts/common/host/__init__.py +1 -0
- package/template/.cowork-flow/scripts/common/host/workflow_state_hook.py +303 -0
- package/template/.cowork-flow/scripts/common/storage/__init__.py +1 -0
- package/template/.cowork-flow/scripts/common/storage/operation_log.py +72 -0
- package/template/.cowork-flow/scripts/common/storage/state_store.py +279 -0
- package/template/.cowork-flow/scripts/common/storage/unit_of_work.py +214 -0
- package/template/.cowork-flow/scripts/common/task/active_task.py +93 -135
- package/template/.cowork-flow/scripts/common/task/batch_mode.py +33 -120
- package/template/.cowork-flow/scripts/common/task/decision_review.py +85 -0
- package/template/.cowork-flow/scripts/common/task/readiness.py +9 -15
- package/template/.cowork-flow/scripts/common/task/task_repository.py +156 -0
- package/template/.cowork-flow/scripts/run.py +24 -0
- package/template/.cowork-flow/spec/contracts/decision-anchor.md +8 -2
- package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +2 -4
- package/template/.cowork-flow/spec/contracts/workflow-state-templates.md +1 -1
- package/template/.cowork-flow/spec/protocols/decision-review.md +28 -0
- package/template/.cowork-flow/spec/protocols/review.md +34 -0
- package/template/.cowork-flow/spec/protocols/spec-maintenance.md +16 -0
- package/template/.cowork-flow/spec/protocols/tdd.md +30 -0
- package/template/.cowork-flow/spec/references/definition-of-done.md +4 -4
- package/template/.cowork-flow/spec/references/orchestration-patterns.md +1 -1
- package/template/.cowork-flow/spec/runtime/contract-registry.json +25 -0
- package/template/.cowork-flow/spec/runtime/host-assets.json +214 -0
- package/template/.cowork-flow/spec/runtime/index.md +8 -0
- package/template/.cowork-flow/spec/runtime/rules.json +61 -10
- package/template/.cowork-flow/spec/runtime/skill-registry.json +397 -0
- package/template/.cowork-flow/spec/schemas/host-assets.schema.json +173 -0
- package/template/.cowork-flow/spec/schemas/rules.schema.json +10 -1
- package/template/.cowork-flow/spec/schemas/skill-registry.schema.json +202 -0
- package/template/.cowork-flow/workflow.md +18 -13
- package/template/.opencode/agents/cowork-check.md +11 -0
- package/template/.opencode/agents/cowork-implement.md +16 -1
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/.zcode/hooks/inject-context.js +3 -85
- package/template/.zcode/hooks/inject-context.selfcheck.mjs +6 -6
- package/template/.zcode/scaffold/.cowork-flow/spec/contracts/decision-anchor.md +8 -2
- package/template/.zcode/scaffold/.cowork-flow/spec/contracts/subagent-dispatch.md +2 -4
- package/template/.zcode/scaffold/.cowork-flow/spec/runtime/rules.json +61 -10
- package/template/.zcode/scaffold/.cowork-flow/spec/schemas/rules.schema.json +10 -1
- package/template/.zcode/scaffold/.cowork-flow/workflow.md +18 -13
- package/template/.zcode/scaffold/AGENTS.md +18 -8
- package/template/AGENTS.md +1 -1
- package/template/skills/batch-mode/SKILL.md +140 -76
- package/template/skills/brainstorming/SKILL.md +2 -2
- package/template/skills/cowork-flow/SKILL.md +42 -0
- package/template/skills/party-mode/SHARED-BOUNDARIES.md +1 -1
- package/template/skills/party-mode/SKILL.md +48 -119
- package/template/.cowork-flow/scripts/common/gates/spec_validator.py +0 -53
- package/template/.cowork-flow/scripts/common/gates/validate_jsonl.py +0 -32
- package/template/skills/before-dev/SKILL.md +0 -41
- package/template/skills/check/SKILL.md +0 -53
- package/template/skills/check/simplification-guide.md +0 -47
- package/template/skills/continue/SKILL.md +0 -29
- package/template/skills/doubt-review/SKILL.md +0 -118
- package/template/skills/finish-work/SKILL.md +0 -32
- package/template/skills/party-mode-v2/SKILL.md +0 -75
- package/template/skills/start/SKILL.md +0 -78
- package/template/skills/tdd/SKILL.md +0 -74
- package/template/skills/update-spec/SKILL.md +0 -39
- package/template/skills/using-cowork-flow/SKILL.md +0 -80
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Task context application service."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from common.core.files import read_text_utf8
|
|
12
|
+
from common.core.skill_registry import SkillRegistryError, load_skill_registry
|
|
13
|
+
from common.core.paths import (
|
|
14
|
+
DIR_AGENTS,
|
|
15
|
+
DIR_SPEC,
|
|
16
|
+
DIR_WORKFLOW,
|
|
17
|
+
FILE_TASK_JSON,
|
|
18
|
+
get_repo_root,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
CONTEXT_JSONL_FILES = ("implement.jsonl", "check.jsonl", "debug.jsonl")
|
|
23
|
+
CONTEXT_ENTRY_TYPES = frozenset(("file", "directory", "planned-file"))
|
|
24
|
+
PLANNED_FILE_HINT = (
|
|
25
|
+
"If this is a planned new file, run "
|
|
26
|
+
"./.cowork-flow/run task add-planned-file <dir> <jsonl> <path> "
|
|
27
|
+
'"reason" or add "type": "planned-file" to the JSONL entry.'
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TaskContextError(RuntimeError):
|
|
32
|
+
"""Raised when a context mutation cannot be performed."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, code: str, path: Path, detail: str) -> None:
|
|
35
|
+
self.code = code
|
|
36
|
+
self.path = path
|
|
37
|
+
self.detail = detail
|
|
38
|
+
super().__init__(f"{code}: {detail}: {path}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def normalize_context_path(
|
|
42
|
+
repo_root: Path,
|
|
43
|
+
path: str,
|
|
44
|
+
entry_type: str,
|
|
45
|
+
) -> tuple[str, Path]:
|
|
46
|
+
"""Return a canonical repo-relative context path and its absolute target."""
|
|
47
|
+
candidate = Path(repo_root) / str(path)
|
|
48
|
+
_validate_context_entry_type(candidate, entry_type)
|
|
49
|
+
normalized = _normalized_context_path(path, entry_type)
|
|
50
|
+
segments = normalized.split("/")
|
|
51
|
+
_validate_context_path(candidate, normalized, segments, path, entry_type)
|
|
52
|
+
repo_root, full_path = _resolved_context_target(repo_root, segments)
|
|
53
|
+
return _typed_context_path(normalized, full_path, repo_root, entry_type)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _validate_context_entry_type(candidate: Path, entry_type: str) -> None:
|
|
57
|
+
if entry_type not in CONTEXT_ENTRY_TYPES:
|
|
58
|
+
raise TaskContextError(
|
|
59
|
+
"TASK-CONTEXT-TYPE-001",
|
|
60
|
+
candidate,
|
|
61
|
+
f"unsupported context entry type: {entry_type}",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _normalized_context_path(path: str, entry_type: str) -> str:
|
|
66
|
+
normalized = str(path).replace("\\", "/")
|
|
67
|
+
while normalized.startswith("./"):
|
|
68
|
+
normalized = normalized[2:]
|
|
69
|
+
if entry_type == "directory" and normalized.endswith("/"):
|
|
70
|
+
normalized = normalized[:-1]
|
|
71
|
+
return normalized
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _validate_context_path(
|
|
75
|
+
candidate: Path,
|
|
76
|
+
normalized: str,
|
|
77
|
+
segments: list[str],
|
|
78
|
+
path: str,
|
|
79
|
+
entry_type: str,
|
|
80
|
+
) -> None:
|
|
81
|
+
if not _is_valid_context_path(normalized, segments, path, entry_type):
|
|
82
|
+
raise TaskContextError(
|
|
83
|
+
"TASK-CONTEXT-PATH-002",
|
|
84
|
+
candidate,
|
|
85
|
+
"context path must be a canonical repository-relative path",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _is_valid_context_path(
|
|
90
|
+
normalized: str,
|
|
91
|
+
segments: list[str],
|
|
92
|
+
path: str,
|
|
93
|
+
entry_type: str,
|
|
94
|
+
) -> bool:
|
|
95
|
+
return not (
|
|
96
|
+
not normalized
|
|
97
|
+
or normalized.startswith("/")
|
|
98
|
+
or re.match(r"^[A-Za-z]:", normalized) is not None
|
|
99
|
+
or any(segment in ("", ".", "..") for segment in segments)
|
|
100
|
+
or any(character in normalized for character in "*?[]")
|
|
101
|
+
or (entry_type == "planned-file" and str(path).endswith(("/", "\\")))
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _resolved_context_target(
|
|
106
|
+
repo_root: Path,
|
|
107
|
+
segments: list[str],
|
|
108
|
+
) -> tuple[Path, Path]:
|
|
109
|
+
resolved_root = Path(repo_root).resolve()
|
|
110
|
+
return resolved_root, resolved_root.joinpath(*segments).resolve(strict=False)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _typed_context_path(
|
|
114
|
+
normalized: str,
|
|
115
|
+
full_path: Path,
|
|
116
|
+
repo_root: Path,
|
|
117
|
+
entry_type: str,
|
|
118
|
+
) -> tuple[str, Path]:
|
|
119
|
+
try:
|
|
120
|
+
full_path.relative_to(repo_root)
|
|
121
|
+
except ValueError as error:
|
|
122
|
+
raise TaskContextError(
|
|
123
|
+
"TASK-CONTEXT-PATH-002",
|
|
124
|
+
full_path,
|
|
125
|
+
"context path resolves outside the repository",
|
|
126
|
+
) from error
|
|
127
|
+
|
|
128
|
+
if entry_type == "directory":
|
|
129
|
+
normalized = f"{normalized}/"
|
|
130
|
+
return normalized, full_path
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def prepare_context_entry(
|
|
134
|
+
repo_root: Path,
|
|
135
|
+
path: str,
|
|
136
|
+
reason: str,
|
|
137
|
+
requested_type: str | None,
|
|
138
|
+
) -> tuple[str, str, dict]:
|
|
139
|
+
"""Build one validated context entry without writing it."""
|
|
140
|
+
entry_type = requested_type
|
|
141
|
+
if entry_type is None:
|
|
142
|
+
candidate = Path(repo_root) / path
|
|
143
|
+
entry_type = "directory" if candidate.is_dir() else "file"
|
|
144
|
+
|
|
145
|
+
normalized_path, full_path = normalize_context_path(
|
|
146
|
+
repo_root,
|
|
147
|
+
path,
|
|
148
|
+
entry_type,
|
|
149
|
+
)
|
|
150
|
+
valid_target = {
|
|
151
|
+
"directory": full_path.is_dir(),
|
|
152
|
+
"planned-file": not full_path.is_dir(),
|
|
153
|
+
"file": full_path.is_file(),
|
|
154
|
+
}[entry_type]
|
|
155
|
+
if not valid_target:
|
|
156
|
+
raise TaskContextError(
|
|
157
|
+
"TASK-CONTEXT-PATH-001",
|
|
158
|
+
full_path,
|
|
159
|
+
f"context {entry_type} path does not exist or has the wrong type",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
entry = {"file": normalized_path, "reason": reason}
|
|
163
|
+
if entry_type != "file":
|
|
164
|
+
entry["type"] = entry_type
|
|
165
|
+
return entry_type, normalized_path, entry
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _context_issue(
|
|
169
|
+
context_file: str,
|
|
170
|
+
line: int,
|
|
171
|
+
code: str,
|
|
172
|
+
message: str,
|
|
173
|
+
) -> ContextValidationIssue:
|
|
174
|
+
return ContextValidationIssue(
|
|
175
|
+
context_file=context_file,
|
|
176
|
+
line=line,
|
|
177
|
+
code=code,
|
|
178
|
+
message=message,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def validate_context_entry(
|
|
183
|
+
repo_root: Path,
|
|
184
|
+
context_file: str,
|
|
185
|
+
line: int,
|
|
186
|
+
data: object,
|
|
187
|
+
) -> ContextValidationIssue | None:
|
|
188
|
+
"""Validate one parsed JSONL entry."""
|
|
189
|
+
if not isinstance(data, dict) or not data.get("file"):
|
|
190
|
+
return _context_issue(
|
|
191
|
+
context_file,
|
|
192
|
+
line,
|
|
193
|
+
"missing_file_field",
|
|
194
|
+
"Missing file field",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
entry_type = data.get("type", "file")
|
|
198
|
+
normalized = str(data["file"])
|
|
199
|
+
try:
|
|
200
|
+
normalized_path, full_path = normalize_context_path(
|
|
201
|
+
repo_root,
|
|
202
|
+
normalized,
|
|
203
|
+
entry_type,
|
|
204
|
+
)
|
|
205
|
+
except TaskContextError as error:
|
|
206
|
+
code = _context_error_issue_code(error)
|
|
207
|
+
return _context_issue(context_file, line, code, error.detail)
|
|
208
|
+
|
|
209
|
+
if is_skill_path(normalized_path):
|
|
210
|
+
return None
|
|
211
|
+
return _validate_context_entry_target(
|
|
212
|
+
context_file,
|
|
213
|
+
line,
|
|
214
|
+
entry_type,
|
|
215
|
+
normalized_path,
|
|
216
|
+
full_path,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _context_error_issue_code(error: TaskContextError) -> str:
|
|
221
|
+
if error.code == "TASK-CONTEXT-TYPE-001":
|
|
222
|
+
return "invalid_entry_type"
|
|
223
|
+
return "invalid_path"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _validate_context_entry_target(
|
|
227
|
+
context_file: str,
|
|
228
|
+
line: int,
|
|
229
|
+
entry_type: str,
|
|
230
|
+
normalized_path: str,
|
|
231
|
+
full_path: Path,
|
|
232
|
+
) -> ContextValidationIssue | None:
|
|
233
|
+
if entry_type == "planned-file":
|
|
234
|
+
return _planned_file_issue(context_file, line, normalized_path, full_path)
|
|
235
|
+
if entry_type == "directory" and not full_path.is_dir():
|
|
236
|
+
return _context_issue(
|
|
237
|
+
context_file,
|
|
238
|
+
line,
|
|
239
|
+
"directory_not_found",
|
|
240
|
+
f"Directory not found: {normalized_path}",
|
|
241
|
+
)
|
|
242
|
+
if entry_type == "file" and not full_path.is_file():
|
|
243
|
+
return _context_issue(
|
|
244
|
+
context_file,
|
|
245
|
+
line,
|
|
246
|
+
"file_not_found",
|
|
247
|
+
f"File not found: {normalized_path}. {PLANNED_FILE_HINT}",
|
|
248
|
+
)
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _planned_file_issue(
|
|
253
|
+
context_file: str,
|
|
254
|
+
line: int,
|
|
255
|
+
normalized_path: str,
|
|
256
|
+
full_path: Path,
|
|
257
|
+
) -> ContextValidationIssue | None:
|
|
258
|
+
if not full_path.is_dir():
|
|
259
|
+
return None
|
|
260
|
+
return _context_issue(
|
|
261
|
+
context_file,
|
|
262
|
+
line,
|
|
263
|
+
"invalid_path",
|
|
264
|
+
f"Planned file is a directory: {normalized_path}",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@dataclass(frozen=True)
|
|
269
|
+
class ContextInitializationResult:
|
|
270
|
+
created: tuple[str, ...]
|
|
271
|
+
skipped: tuple[str, ...]
|
|
272
|
+
entry_counts: dict[str, int]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@dataclass(frozen=True)
|
|
276
|
+
class ContextAddResult:
|
|
277
|
+
added: bool
|
|
278
|
+
entry_type: str
|
|
279
|
+
path: str
|
|
280
|
+
entry: dict
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@dataclass(frozen=True)
|
|
284
|
+
class ContextValidationIssue:
|
|
285
|
+
context_file: str
|
|
286
|
+
line: int
|
|
287
|
+
code: str
|
|
288
|
+
message: str
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass(frozen=True)
|
|
292
|
+
class ContextFileValidation:
|
|
293
|
+
context_file: str
|
|
294
|
+
exists: bool
|
|
295
|
+
entry_count: int
|
|
296
|
+
issues: tuple[ContextValidationIssue, ...]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def get_implement_base() -> list[dict]:
|
|
300
|
+
return [
|
|
301
|
+
{
|
|
302
|
+
"file": "AGENTS.md",
|
|
303
|
+
"reason": "Project collaboration rules and workflow gates",
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
"file": f"{DIR_WORKFLOW}/workflow.md",
|
|
307
|
+
"reason": "Project workflow and conventions",
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/guides/index.md",
|
|
311
|
+
"reason": "Pre-implementation thinking guides",
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
"file": (
|
|
315
|
+
f"{DIR_WORKFLOW}/{DIR_SPEC}/guides/"
|
|
316
|
+
"pre-implementation-checklist.md"
|
|
317
|
+
),
|
|
318
|
+
"reason": "Mandatory pre-coding checklist",
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
"file": protocol_path("tdd"),
|
|
322
|
+
"reason": "Behavior-change red-green evidence protocol",
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
"file": protocol_path("decision-review"),
|
|
326
|
+
"reason": "Structured decision review evidence contract",
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
"file": protocol_path("spec-maintenance"),
|
|
330
|
+
"reason": "Specification maintenance decision protocol",
|
|
331
|
+
},
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def get_implement_backend() -> list[dict]:
|
|
336
|
+
return [
|
|
337
|
+
{
|
|
338
|
+
"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/backend/index.md",
|
|
339
|
+
"reason": "Backend development guide",
|
|
340
|
+
}
|
|
341
|
+
]
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def get_implement_frontend() -> list[dict]:
|
|
345
|
+
return [
|
|
346
|
+
{
|
|
347
|
+
"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/frontend/index.md",
|
|
348
|
+
"reason": "Frontend development guide",
|
|
349
|
+
}
|
|
350
|
+
]
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def get_implement_spec() -> list[dict]:
|
|
354
|
+
return [
|
|
355
|
+
{
|
|
356
|
+
"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/index.md",
|
|
357
|
+
"reason": "Spec index — read before modifying spec/",
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/contracts/index.md",
|
|
361
|
+
"reason": "Contract definitions",
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/schemas/index.md",
|
|
365
|
+
"reason": "Schema definitions",
|
|
366
|
+
},
|
|
367
|
+
]
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def detect_installed_platforms(repo_root: Path | None = None) -> list[str]:
|
|
371
|
+
repo_root = Path(repo_root) if repo_root is not None else get_repo_root()
|
|
372
|
+
platforms: list[str] = []
|
|
373
|
+
if (repo_root / ".codex").is_dir():
|
|
374
|
+
platforms.append("codex")
|
|
375
|
+
if (repo_root / ".opencode").is_dir():
|
|
376
|
+
platforms.append("opencode")
|
|
377
|
+
if (repo_root / ".claude").is_dir() or (repo_root / "CLAUDE.md").is_file():
|
|
378
|
+
platforms.append("claude-code")
|
|
379
|
+
return platforms
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def use_claude_skill_context(repo_root: Path | None = None) -> bool:
|
|
383
|
+
return detect_installed_platforms(repo_root) == ["claude-code"]
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def skill_path(name: str, repo_root: Path | None = None) -> str:
|
|
387
|
+
if use_claude_skill_context(repo_root):
|
|
388
|
+
return f".claude/skills/{name}/SKILL.md"
|
|
389
|
+
return f"{DIR_AGENTS}/skills/{name}/SKILL.md"
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def get_domain_skill_context(
|
|
393
|
+
repo_root: Path,
|
|
394
|
+
*,
|
|
395
|
+
dev_type: str | None = None,
|
|
396
|
+
paths: tuple[str, ...] = (),
|
|
397
|
+
) -> list[dict]:
|
|
398
|
+
try:
|
|
399
|
+
registry = load_skill_registry(repo_root, validate_sources=False)
|
|
400
|
+
except SkillRegistryError:
|
|
401
|
+
return []
|
|
402
|
+
return [
|
|
403
|
+
{
|
|
404
|
+
"file": skill_path(entry.id, repo_root),
|
|
405
|
+
"reason": f"Auto-routed {entry.display_name} domain guide",
|
|
406
|
+
}
|
|
407
|
+
for entry in registry.domain_entries_for(
|
|
408
|
+
dev_type=dev_type,
|
|
409
|
+
paths=paths,
|
|
410
|
+
)
|
|
411
|
+
]
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def protocol_path(name: str) -> str:
|
|
415
|
+
return f"{DIR_WORKFLOW}/{DIR_SPEC}/protocols/{name}.md"
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def is_skill_path(file_path: str) -> bool:
|
|
419
|
+
return (
|
|
420
|
+
file_path.startswith(f"{DIR_AGENTS}/skills/")
|
|
421
|
+
or file_path.startswith(".claude/skills/")
|
|
422
|
+
or ".skills/" in file_path
|
|
423
|
+
or "/skills/" in file_path
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def discover_spec_files(repo_root: Path, dev_type: str) -> list[str]:
|
|
428
|
+
if dev_type == "spec":
|
|
429
|
+
return [f"{DIR_WORKFLOW}/{DIR_SPEC}/index.md"]
|
|
430
|
+
|
|
431
|
+
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC / dev_type
|
|
432
|
+
if not spec_dir.is_dir():
|
|
433
|
+
return []
|
|
434
|
+
return sorted(
|
|
435
|
+
f"{DIR_WORKFLOW}/{DIR_SPEC}/{dev_type}/{path.name}"
|
|
436
|
+
for path in spec_dir.glob("*.md")
|
|
437
|
+
if path.is_file()
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def get_check_context(repo_root: Path, dev_type: str) -> list[dict]:
|
|
442
|
+
entries = [
|
|
443
|
+
{
|
|
444
|
+
"file": protocol_path("review"),
|
|
445
|
+
"reason": "Quality, contract, and template consistency check",
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
"file": protocol_path("decision-review"),
|
|
449
|
+
"reason": "Verify structured decision review evidence",
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
"file": protocol_path("spec-maintenance"),
|
|
453
|
+
"reason": "Verify specification maintenance decisions",
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
"file": skill_path("cowork-flow", repo_root),
|
|
457
|
+
"reason": "Workflow review, completion, and archive routing",
|
|
458
|
+
},
|
|
459
|
+
]
|
|
460
|
+
entries.extend(
|
|
461
|
+
{
|
|
462
|
+
"file": spec_file,
|
|
463
|
+
"reason": f"Verify {Path(spec_file).name} compliance",
|
|
464
|
+
}
|
|
465
|
+
for spec_file in discover_spec_files(repo_root, dev_type)
|
|
466
|
+
)
|
|
467
|
+
return entries
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def get_debug_context(
|
|
471
|
+
dev_type: str,
|
|
472
|
+
repo_root: Path | None = None,
|
|
473
|
+
) -> list[dict]:
|
|
474
|
+
root = Path(repo_root) if repo_root is not None else get_repo_root()
|
|
475
|
+
del dev_type
|
|
476
|
+
return [
|
|
477
|
+
{
|
|
478
|
+
"file": skill_path("break-loop", root),
|
|
479
|
+
"reason": "Deep bug analysis workflow",
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
"file": protocol_path("spec-maintenance"),
|
|
483
|
+
"reason": "Capture implementation lessons and contracts",
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
"file": protocol_path("review"),
|
|
487
|
+
"reason": "Verify the fix and related contracts",
|
|
488
|
+
},
|
|
489
|
+
]
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def write_jsonl(path: Path, entries: list[dict]) -> None:
|
|
493
|
+
lines = [json.dumps(entry, ensure_ascii=False) for entry in entries]
|
|
494
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def iter_jsonl_lines(path: Path):
|
|
498
|
+
with path.open("r", encoding="utf-8") as stream:
|
|
499
|
+
for line_number, line in enumerate(stream, start=1):
|
|
500
|
+
yield line_number, line.rstrip("\n")
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
class TaskContextService:
|
|
504
|
+
"""Manage task JSONL context without CLI rendering."""
|
|
505
|
+
|
|
506
|
+
def __init__(self, repo_root: Path) -> None:
|
|
507
|
+
self.repo_root = Path(repo_root)
|
|
508
|
+
|
|
509
|
+
def initialize(
|
|
510
|
+
self,
|
|
511
|
+
task_dir: Path,
|
|
512
|
+
dev_type: str,
|
|
513
|
+
) -> ContextInitializationResult:
|
|
514
|
+
task_dir = Path(task_dir)
|
|
515
|
+
if not task_dir.is_dir():
|
|
516
|
+
raise TaskContextError(
|
|
517
|
+
"TASK-CONTEXT-DIR-001",
|
|
518
|
+
task_dir,
|
|
519
|
+
"task directory does not exist",
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
entries_by_file = {
|
|
523
|
+
"implement.jsonl": self._implement_entries(dev_type),
|
|
524
|
+
"check.jsonl": get_check_context(self.repo_root, dev_type),
|
|
525
|
+
"debug.jsonl": get_debug_context(dev_type, self.repo_root),
|
|
526
|
+
}
|
|
527
|
+
created: list[str] = []
|
|
528
|
+
skipped: list[str] = []
|
|
529
|
+
entry_counts: dict[str, int] = {}
|
|
530
|
+
for file_name, entries in entries_by_file.items():
|
|
531
|
+
context_file = task_dir / file_name
|
|
532
|
+
if context_file.is_file():
|
|
533
|
+
skipped.append(file_name)
|
|
534
|
+
continue
|
|
535
|
+
write_jsonl(context_file, entries)
|
|
536
|
+
created.append(file_name)
|
|
537
|
+
entry_counts[file_name] = len(entries)
|
|
538
|
+
|
|
539
|
+
self.ensure_task_artifact_placeholders(task_dir)
|
|
540
|
+
return ContextInitializationResult(
|
|
541
|
+
created=tuple(created),
|
|
542
|
+
skipped=tuple(skipped),
|
|
543
|
+
entry_counts=entry_counts,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
def add(
|
|
547
|
+
self,
|
|
548
|
+
task_dir: Path,
|
|
549
|
+
context_name: str,
|
|
550
|
+
path: str,
|
|
551
|
+
reason: str,
|
|
552
|
+
entry_type: str | None = None,
|
|
553
|
+
) -> ContextAddResult:
|
|
554
|
+
task_dir = Path(task_dir)
|
|
555
|
+
if not task_dir.is_dir():
|
|
556
|
+
raise TaskContextError(
|
|
557
|
+
"TASK-CONTEXT-DIR-001",
|
|
558
|
+
task_dir,
|
|
559
|
+
"task directory does not exist",
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
context_file = task_dir / self._context_file_name(context_name)
|
|
563
|
+
entry_type, normalized_path, entry = prepare_context_entry(
|
|
564
|
+
self.repo_root,
|
|
565
|
+
path,
|
|
566
|
+
reason,
|
|
567
|
+
entry_type,
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
existing_entries = self.entries(task_dir, context_name)
|
|
571
|
+
already_exists = any(
|
|
572
|
+
existing.get("file") == normalized_path
|
|
573
|
+
for existing in existing_entries
|
|
574
|
+
)
|
|
575
|
+
if not already_exists:
|
|
576
|
+
with context_file.open("a", encoding="utf-8") as stream:
|
|
577
|
+
stream.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
578
|
+
if self._context_file_name(context_name) == "implement.jsonl":
|
|
579
|
+
self._append_domain_guides(
|
|
580
|
+
context_file,
|
|
581
|
+
paths=(normalized_path,),
|
|
582
|
+
)
|
|
583
|
+
return ContextAddResult(
|
|
584
|
+
added=not already_exists,
|
|
585
|
+
entry_type=entry_type,
|
|
586
|
+
path=normalized_path,
|
|
587
|
+
entry=entry,
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
def entries(self, task_dir: Path, context_name: str) -> list[dict]:
|
|
591
|
+
context_file = Path(task_dir) / self._context_file_name(context_name)
|
|
592
|
+
if not context_file.is_file():
|
|
593
|
+
return []
|
|
594
|
+
|
|
595
|
+
entries: list[dict] = []
|
|
596
|
+
for _, line in iter_jsonl_lines(context_file):
|
|
597
|
+
if not line.strip():
|
|
598
|
+
continue
|
|
599
|
+
try:
|
|
600
|
+
data = json.loads(line)
|
|
601
|
+
except json.JSONDecodeError:
|
|
602
|
+
continue
|
|
603
|
+
if isinstance(data, dict):
|
|
604
|
+
entries.append(data)
|
|
605
|
+
return entries
|
|
606
|
+
|
|
607
|
+
def validate(self, task_dir: Path) -> tuple[ContextValidationIssue, ...]:
|
|
608
|
+
issues: list[ContextValidationIssue] = []
|
|
609
|
+
for context_file in CONTEXT_JSONL_FILES:
|
|
610
|
+
issues.extend(self.validate_file(task_dir, context_file).issues)
|
|
611
|
+
return tuple(issues)
|
|
612
|
+
|
|
613
|
+
def validate_file(
|
|
614
|
+
self,
|
|
615
|
+
task_dir: Path,
|
|
616
|
+
context_name: str,
|
|
617
|
+
) -> ContextFileValidation:
|
|
618
|
+
context_file = Path(task_dir) / self._context_file_name(context_name)
|
|
619
|
+
if not context_file.is_file():
|
|
620
|
+
return ContextFileValidation(
|
|
621
|
+
context_file=context_file.name,
|
|
622
|
+
exists=False,
|
|
623
|
+
entry_count=0,
|
|
624
|
+
issues=(),
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
issues: list[ContextValidationIssue] = []
|
|
628
|
+
entry_count = 0
|
|
629
|
+
for line_number, line in iter_jsonl_lines(context_file):
|
|
630
|
+
if not line.strip():
|
|
631
|
+
continue
|
|
632
|
+
entry_count += 1
|
|
633
|
+
try:
|
|
634
|
+
data = json.loads(line)
|
|
635
|
+
except json.JSONDecodeError:
|
|
636
|
+
issues.append(
|
|
637
|
+
ContextValidationIssue(
|
|
638
|
+
context_file=context_file.name,
|
|
639
|
+
line=line_number,
|
|
640
|
+
code="invalid_json",
|
|
641
|
+
message="Invalid JSON",
|
|
642
|
+
)
|
|
643
|
+
)
|
|
644
|
+
continue
|
|
645
|
+
|
|
646
|
+
issue = validate_context_entry(
|
|
647
|
+
self.repo_root,
|
|
648
|
+
context_file.name,
|
|
649
|
+
line_number,
|
|
650
|
+
data,
|
|
651
|
+
)
|
|
652
|
+
if issue is not None:
|
|
653
|
+
issues.append(issue)
|
|
654
|
+
|
|
655
|
+
return ContextFileValidation(
|
|
656
|
+
context_file=context_file.name,
|
|
657
|
+
exists=True,
|
|
658
|
+
entry_count=entry_count,
|
|
659
|
+
issues=tuple(issues),
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
def start_blockers(self, task_dir: Path) -> tuple[str, ...]:
|
|
663
|
+
task_dir = Path(task_dir)
|
|
664
|
+
blockers: list[str] = []
|
|
665
|
+
if not (task_dir / FILE_TASK_JSON).is_file():
|
|
666
|
+
blockers.append("task.json is missing")
|
|
667
|
+
if not read_text_utf8(task_dir / "decision-anchor.md"):
|
|
668
|
+
blockers.append("decision-anchor.md is missing or empty")
|
|
669
|
+
for context_file in CONTEXT_JSONL_FILES:
|
|
670
|
+
if not read_text_utf8(task_dir / context_file):
|
|
671
|
+
blockers.append(f"{context_file} is missing or empty")
|
|
672
|
+
return tuple(blockers)
|
|
673
|
+
|
|
674
|
+
def validation_issue_summaries(self, task_dir: Path) -> tuple[str, ...]:
|
|
675
|
+
summaries: list[str] = []
|
|
676
|
+
for context_file in CONTEXT_JSONL_FILES:
|
|
677
|
+
result = self.validate_file(task_dir, context_file)
|
|
678
|
+
if result.issues:
|
|
679
|
+
summaries.append(
|
|
680
|
+
f"{context_file} has {len(result.issues)} validation error(s)"
|
|
681
|
+
)
|
|
682
|
+
return tuple(summaries)
|
|
683
|
+
|
|
684
|
+
def ensure_task_artifact_placeholders(self, task_dir: Path) -> tuple[str, ...]:
|
|
685
|
+
"""Create empty placeholders for planned task-local context files."""
|
|
686
|
+
task_dir = Path(task_dir)
|
|
687
|
+
created: list[str] = []
|
|
688
|
+
for entry in self.entries(task_dir, "implement"):
|
|
689
|
+
if entry.get("type") == "planned-file":
|
|
690
|
+
continue
|
|
691
|
+
file_path = str(entry.get("file", "")).strip()
|
|
692
|
+
if not file_path:
|
|
693
|
+
continue
|
|
694
|
+
try:
|
|
695
|
+
normalized, full_path = normalize_context_path(
|
|
696
|
+
self.repo_root,
|
|
697
|
+
file_path,
|
|
698
|
+
"planned-file",
|
|
699
|
+
)
|
|
700
|
+
except TaskContextError:
|
|
701
|
+
continue
|
|
702
|
+
if not _is_task_local_artifact(task_dir, full_path):
|
|
703
|
+
continue
|
|
704
|
+
if full_path.exists():
|
|
705
|
+
continue
|
|
706
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
707
|
+
full_path.write_text("", encoding="utf-8")
|
|
708
|
+
created.append(normalized)
|
|
709
|
+
return tuple(created)
|
|
710
|
+
|
|
711
|
+
def _implement_entries(self, dev_type: str) -> list[dict]:
|
|
712
|
+
entries = get_implement_base()
|
|
713
|
+
entries.extend(
|
|
714
|
+
get_domain_skill_context(
|
|
715
|
+
self.repo_root,
|
|
716
|
+
dev_type=dev_type,
|
|
717
|
+
)
|
|
718
|
+
)
|
|
719
|
+
if dev_type in ("backend", "test"):
|
|
720
|
+
entries.extend(get_implement_backend())
|
|
721
|
+
elif dev_type == "frontend":
|
|
722
|
+
entries.extend(get_implement_frontend())
|
|
723
|
+
elif dev_type == "fullstack":
|
|
724
|
+
entries.extend(get_implement_backend())
|
|
725
|
+
entries.extend(get_implement_frontend())
|
|
726
|
+
elif dev_type == "spec":
|
|
727
|
+
entries.extend(get_implement_spec())
|
|
728
|
+
return entries
|
|
729
|
+
|
|
730
|
+
def _append_domain_guides(
|
|
731
|
+
self,
|
|
732
|
+
context_file: Path,
|
|
733
|
+
*,
|
|
734
|
+
paths: tuple[str, ...],
|
|
735
|
+
) -> None:
|
|
736
|
+
guide_entries = get_domain_skill_context(
|
|
737
|
+
self.repo_root,
|
|
738
|
+
paths=paths,
|
|
739
|
+
)
|
|
740
|
+
if not guide_entries:
|
|
741
|
+
return
|
|
742
|
+
existing_files = {
|
|
743
|
+
entry.get("file")
|
|
744
|
+
for entry in self.entries(context_file.parent, context_file.name)
|
|
745
|
+
}
|
|
746
|
+
with context_file.open("a", encoding="utf-8") as stream:
|
|
747
|
+
for guide in guide_entries:
|
|
748
|
+
if guide["file"] in existing_files:
|
|
749
|
+
continue
|
|
750
|
+
stream.write(json.dumps(guide, ensure_ascii=False) + "\n")
|
|
751
|
+
existing_files.add(guide["file"])
|
|
752
|
+
|
|
753
|
+
@staticmethod
|
|
754
|
+
def _context_file_name(context_name: str) -> str:
|
|
755
|
+
if context_name.endswith(".jsonl"):
|
|
756
|
+
return context_name
|
|
757
|
+
return f"{context_name}.jsonl"
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _is_task_local_artifact(task_dir: Path, full_path: Path) -> bool:
|
|
761
|
+
try:
|
|
762
|
+
full_path.resolve(strict=False).relative_to(task_dir.resolve(strict=False))
|
|
763
|
+
except ValueError:
|
|
764
|
+
return False
|
|
765
|
+
return not full_path.exists() or full_path.is_file()
|