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.
Files changed (111) hide show
  1. package/README.md +27 -14
  2. package/package.json +1 -1
  3. package/src/commands/init.js +39 -28
  4. package/src/commands/sync.js +2 -2
  5. package/src/lib/asset-plan.js +127 -0
  6. package/src/lib/copy-template.js +101 -141
  7. package/src/lib/host-assets.js +231 -0
  8. package/src/lib/plan-applier.js +329 -0
  9. package/src/lib/platforms.js +15 -76
  10. package/src/lib/skill-registry.js +503 -0
  11. package/template/.claude/agents/cowork-check.md +10 -1
  12. package/template/.claude/agents/cowork-implement.md +11 -2
  13. package/template/.claude/hooks/inject-workflow-state.py +51 -254
  14. package/template/.codex/agents/cowork-check.toml +12 -3
  15. package/template/.codex/agents/cowork-implement.toml +11 -2
  16. package/template/.codex/agents/default.toml +3 -2
  17. package/template/.codex/agents/explorer.toml +3 -2
  18. package/template/.codex/agents/worker.toml +2 -1
  19. package/template/.codex/hooks/inject-workflow-state.py +46 -265
  20. package/template/.cowork-flow/.version +1 -1
  21. package/template/.cowork-flow/scripts/application/__init__.py +1 -0
  22. package/template/.cowork-flow/scripts/application/batch_execution.py +743 -0
  23. package/template/.cowork-flow/scripts/application/runtime_context_service.py +365 -0
  24. package/template/.cowork-flow/scripts/application/task_archive.py +220 -0
  25. package/template/.cowork-flow/scripts/application/task_context.py +765 -0
  26. package/template/.cowork-flow/scripts/application/task_creation.py +201 -0
  27. package/template/.cowork-flow/scripts/application/task_lifecycle.py +532 -0
  28. package/template/.cowork-flow/scripts/application/task_tree.py +179 -0
  29. package/template/.cowork-flow/scripts/commands/doctor.py +78 -57
  30. package/template/.cowork-flow/scripts/commands/party_mode_v2.py +671 -425
  31. package/template/.cowork-flow/scripts/commands/subagent.py +38 -26
  32. package/template/.cowork-flow/scripts/commands/task.py +535 -1953
  33. package/template/.cowork-flow/scripts/commands/task_archive_commands.py +195 -0
  34. package/template/.cowork-flow/scripts/commands/task_context_commands.py +240 -0
  35. package/template/.cowork-flow/scripts/commands/task_create_command.py +164 -0
  36. package/template/.cowork-flow/scripts/commands/task_navigation.py +407 -0
  37. package/template/.cowork-flow/scripts/commands/task_parser.py +364 -0
  38. package/template/.cowork-flow/scripts/commands/task_support.py +76 -0
  39. package/template/.cowork-flow/scripts/commands/task_tree_commands.py +160 -0
  40. package/template/.cowork-flow/scripts/common/core/execution_context.py +161 -85
  41. package/template/.cowork-flow/scripts/common/core/host_manifest.py +393 -0
  42. package/template/.cowork-flow/scripts/common/core/skill_registry.py +568 -0
  43. package/template/.cowork-flow/scripts/common/gates/coding_standards.py +58 -7
  44. package/template/.cowork-flow/scripts/common/gates/gates.py +323 -46
  45. package/template/.cowork-flow/scripts/common/gates/models.py +196 -0
  46. package/template/.cowork-flow/scripts/common/gates/registry.py +79 -0
  47. package/template/.cowork-flow/scripts/common/gates/tdd_evidence.py +13 -13
  48. package/template/.cowork-flow/scripts/common/gates/validate_coding_standards.py +104 -62
  49. package/template/.cowork-flow/scripts/common/gates/validate_implementation.py +39 -22
  50. package/template/.cowork-flow/scripts/common/gates/validate_rules.py +223 -40
  51. package/template/.cowork-flow/scripts/common/git/git_context.py +216 -182
  52. package/template/.cowork-flow/scripts/common/host/__init__.py +1 -0
  53. package/template/.cowork-flow/scripts/common/host/workflow_state_hook.py +303 -0
  54. package/template/.cowork-flow/scripts/common/storage/__init__.py +1 -0
  55. package/template/.cowork-flow/scripts/common/storage/operation_log.py +72 -0
  56. package/template/.cowork-flow/scripts/common/storage/state_store.py +279 -0
  57. package/template/.cowork-flow/scripts/common/storage/unit_of_work.py +214 -0
  58. package/template/.cowork-flow/scripts/common/task/active_task.py +93 -135
  59. package/template/.cowork-flow/scripts/common/task/batch_mode.py +33 -120
  60. package/template/.cowork-flow/scripts/common/task/decision_review.py +85 -0
  61. package/template/.cowork-flow/scripts/common/task/readiness.py +9 -15
  62. package/template/.cowork-flow/scripts/common/task/task_repository.py +156 -0
  63. package/template/.cowork-flow/scripts/run.py +24 -0
  64. package/template/.cowork-flow/spec/contracts/decision-anchor.md +8 -2
  65. package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +2 -4
  66. package/template/.cowork-flow/spec/contracts/workflow-state-templates.md +1 -1
  67. package/template/.cowork-flow/spec/protocols/decision-review.md +28 -0
  68. package/template/.cowork-flow/spec/protocols/review.md +34 -0
  69. package/template/.cowork-flow/spec/protocols/spec-maintenance.md +16 -0
  70. package/template/.cowork-flow/spec/protocols/tdd.md +30 -0
  71. package/template/.cowork-flow/spec/references/definition-of-done.md +4 -4
  72. package/template/.cowork-flow/spec/references/orchestration-patterns.md +1 -1
  73. package/template/.cowork-flow/spec/runtime/contract-registry.json +25 -0
  74. package/template/.cowork-flow/spec/runtime/host-assets.json +214 -0
  75. package/template/.cowork-flow/spec/runtime/index.md +8 -0
  76. package/template/.cowork-flow/spec/runtime/rules.json +61 -10
  77. package/template/.cowork-flow/spec/runtime/skill-registry.json +397 -0
  78. package/template/.cowork-flow/spec/schemas/host-assets.schema.json +173 -0
  79. package/template/.cowork-flow/spec/schemas/rules.schema.json +10 -1
  80. package/template/.cowork-flow/spec/schemas/skill-registry.schema.json +202 -0
  81. package/template/.cowork-flow/workflow.md +18 -13
  82. package/template/.opencode/agents/cowork-check.md +11 -0
  83. package/template/.opencode/agents/cowork-implement.md +16 -1
  84. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  85. package/template/.zcode/hooks/inject-context.js +3 -85
  86. package/template/.zcode/hooks/inject-context.selfcheck.mjs +6 -6
  87. package/template/.zcode/scaffold/.cowork-flow/spec/contracts/decision-anchor.md +8 -2
  88. package/template/.zcode/scaffold/.cowork-flow/spec/contracts/subagent-dispatch.md +2 -4
  89. package/template/.zcode/scaffold/.cowork-flow/spec/runtime/rules.json +61 -10
  90. package/template/.zcode/scaffold/.cowork-flow/spec/schemas/rules.schema.json +10 -1
  91. package/template/.zcode/scaffold/.cowork-flow/workflow.md +18 -13
  92. package/template/.zcode/scaffold/AGENTS.md +18 -8
  93. package/template/AGENTS.md +1 -1
  94. package/template/skills/batch-mode/SKILL.md +140 -76
  95. package/template/skills/brainstorming/SKILL.md +2 -2
  96. package/template/skills/cowork-flow/SKILL.md +42 -0
  97. package/template/skills/party-mode/SHARED-BOUNDARIES.md +1 -1
  98. package/template/skills/party-mode/SKILL.md +48 -119
  99. package/template/.cowork-flow/scripts/common/gates/spec_validator.py +0 -53
  100. package/template/.cowork-flow/scripts/common/gates/validate_jsonl.py +0 -32
  101. package/template/skills/before-dev/SKILL.md +0 -41
  102. package/template/skills/check/SKILL.md +0 -53
  103. package/template/skills/check/simplification-guide.md +0 -47
  104. package/template/skills/continue/SKILL.md +0 -29
  105. package/template/skills/doubt-review/SKILL.md +0 -118
  106. package/template/skills/finish-work/SKILL.md +0 -32
  107. package/template/skills/party-mode-v2/SKILL.md +0 -75
  108. package/template/skills/start/SKILL.md +0 -78
  109. package/template/skills/tdd/SKILL.md +0 -74
  110. package/template/skills/update-spec/SKILL.md +0 -39
  111. 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()