cowork-flow 0.0.48 → 0.0.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -1
- package/package.json +6 -3
- package/src/cli.js +6 -0
- package/src/commands/source-refresh.js +40 -0
- package/src/lib/copy-template.js +130 -1
- package/src/lib/host-assets.js +216 -71
- package/template/.codex/hooks.json +1 -1
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/adapters/cli/task.py +0 -2
- package/template/.cowork-flow/scripts/adapters/cli/task_archive_commands.py +12 -0
- package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +18 -133
- package/template/.cowork-flow/scripts/adapters/cli/task_navigation.py +82 -1
- package/template/.cowork-flow/scripts/adapters/host/host_manifest.py +98 -114
- package/template/.cowork-flow/scripts/infra/skill_manifest.py +23 -3
- package/template/.cowork-flow/scripts/infra/storage/operation_log.py +40 -0
- package/template/.cowork-flow/scripts/infra/storage/state_store.py +356 -1
- package/template/.cowork-flow/scripts/services/context_discovery.py +156 -0
- package/template/.cowork-flow/scripts/services/context_jsonl.py +119 -0
- package/template/.cowork-flow/scripts/services/context_paths.py +186 -0
- package/template/.cowork-flow/scripts/services/lifecycle_checks.py +23 -4
- package/template/.cowork-flow/scripts/services/lifecycle_policy.py +128 -0
- package/template/.cowork-flow/scripts/services/plan_binding.py +146 -0
- package/template/.cowork-flow/scripts/services/task_archive.py +100 -14
- package/template/.cowork-flow/scripts/services/task_context.py +49 -479
- package/template/.cowork-flow/scripts/services/task_creation.py +31 -27
- package/template/.cowork-flow/scripts/services/task_lifecycle.py +155 -18
- package/template/.cowork-flow/spec/contracts/error-output-as-data.md +8 -0
- package/template/.cowork-flow/spec/runtime/index.md +1 -1
- package/template/.cowork-flow/spec/schemas/host-assets.schema.json +32 -6
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/skills/batch-execution/scripts/batch_execution.py +111 -629
- package/template/skills/batch-execution/scripts/batch_recovery.py +94 -0
- package/template/skills/batch-execution/scripts/batch_state_machine.py +201 -0
- package/template/skills/batch-execution/scripts/batch_verification.py +326 -0
- package/template/skills/cowork-flow/SKILL.md +3 -0
- package/template/skills/party-mode/scripts/party_actions.py +95 -0
- package/template/skills/party-mode/scripts/party_mode_v2.py +129 -205
- package/template/skills/party-mode/scripts/party_reports.py +177 -0
- package/template/skills/party-mode/scripts/party_state_machine.py +189 -0
- package/template/skills/runtime-health/scripts/doctor.py +204 -15
- package/template/skills/task-review/scripts/review_check.py +135 -2
- package/template/skills/test-first/SKILL.md +18 -3
- package/template/skills/test-first/manifest.json +8 -4
|
@@ -4,162 +4,67 @@
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import json
|
|
7
|
-
import re
|
|
8
7
|
from dataclasses import dataclass
|
|
9
8
|
from pathlib import Path
|
|
10
9
|
|
|
11
10
|
from infra.files import read_text_utf8
|
|
12
|
-
from infra.
|
|
13
|
-
from
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
11
|
+
from infra.paths import FILE_TASK_JSON
|
|
12
|
+
from services.context_discovery import (
|
|
13
|
+
detect_installed_platforms,
|
|
14
|
+
discover_spec_files,
|
|
15
|
+
get_check_context,
|
|
16
|
+
get_debug_context,
|
|
17
|
+
get_domain_skill_context,
|
|
18
|
+
get_implement_backend,
|
|
19
|
+
get_implement_base,
|
|
20
|
+
get_implement_frontend,
|
|
21
|
+
get_implement_spec,
|
|
22
|
+
is_skill_path,
|
|
23
|
+
skill_path,
|
|
24
|
+
use_claude_skill_context,
|
|
20
25
|
)
|
|
26
|
+
from services.context_jsonl import (
|
|
27
|
+
ContextJsonlEntry,
|
|
28
|
+
ContextJsonlReadResult,
|
|
29
|
+
ContextValidationIssue,
|
|
30
|
+
iter_jsonl_lines,
|
|
31
|
+
read_context_jsonl_entries,
|
|
32
|
+
write_jsonl,
|
|
33
|
+
)
|
|
34
|
+
from services.context_paths import (
|
|
35
|
+
CONTEXT_ENTRY_TYPES,
|
|
36
|
+
TaskContextError,
|
|
37
|
+
normalize_context_file_scope_entry,
|
|
38
|
+
normalize_context_path,
|
|
39
|
+
prepare_context_entry,
|
|
40
|
+
)
|
|
41
|
+
from services.plan_binding import normalize_plan_file, plan_file_blockers
|
|
21
42
|
|
|
22
43
|
|
|
23
44
|
CONTEXT_JSONL_FILES = ("implement.jsonl", "check.jsonl", "debug.jsonl")
|
|
24
|
-
CONTEXT_ENTRY_TYPES = frozenset(("file", "directory", "planned-file", "deleted-file"))
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
class TaskContextError(RuntimeError):
|
|
28
|
-
"""Raised when a context mutation cannot be performed."""
|
|
29
|
-
|
|
30
|
-
def __init__(self, code: str, path: Path, detail: str) -> None:
|
|
31
|
-
self.code = code
|
|
32
|
-
self.path = path
|
|
33
|
-
self.detail = detail
|
|
34
|
-
super().__init__(f"{code}: {detail}: {path}")
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def normalize_context_path(
|
|
38
|
-
repo_root: Path,
|
|
39
|
-
path: str,
|
|
40
|
-
entry_type: str,
|
|
41
|
-
) -> tuple[str, Path]:
|
|
42
|
-
"""Return a canonical repo-relative context path and its absolute target."""
|
|
43
|
-
candidate = Path(repo_root) / str(path)
|
|
44
|
-
_validate_context_entry_type(candidate, entry_type)
|
|
45
|
-
normalized = _normalized_context_path(path, entry_type)
|
|
46
|
-
segments = normalized.split("/")
|
|
47
|
-
_validate_context_path(candidate, normalized, segments, path, entry_type)
|
|
48
|
-
repo_root, full_path = _resolved_context_target(repo_root, segments)
|
|
49
|
-
return _typed_context_path(normalized, full_path, repo_root, entry_type)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def _validate_context_entry_type(candidate: Path, entry_type: str) -> None:
|
|
53
|
-
if entry_type not in CONTEXT_ENTRY_TYPES:
|
|
54
|
-
raise TaskContextError(
|
|
55
|
-
"TASK-CONTEXT-TYPE-001",
|
|
56
|
-
candidate,
|
|
57
|
-
f"unsupported context entry type: {entry_type}",
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def _normalized_context_path(path: str, entry_type: str) -> str:
|
|
62
|
-
normalized = str(path).replace("\\", "/")
|
|
63
|
-
while normalized.startswith("./"):
|
|
64
|
-
normalized = normalized[2:]
|
|
65
|
-
if entry_type == "directory" and normalized.endswith("/"):
|
|
66
|
-
normalized = normalized[:-1]
|
|
67
|
-
return normalized
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def _validate_context_path(
|
|
71
|
-
candidate: Path,
|
|
72
|
-
normalized: str,
|
|
73
|
-
segments: list[str],
|
|
74
|
-
path: str,
|
|
75
|
-
entry_type: str,
|
|
76
|
-
) -> None:
|
|
77
|
-
if not _is_valid_context_path(normalized, segments, path, entry_type):
|
|
78
|
-
raise TaskContextError(
|
|
79
|
-
"TASK-CONTEXT-PATH-002",
|
|
80
|
-
candidate,
|
|
81
|
-
"context path must be a canonical repository-relative path",
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def _is_valid_context_path(
|
|
86
|
-
normalized: str,
|
|
87
|
-
segments: list[str],
|
|
88
|
-
path: str,
|
|
89
|
-
entry_type: str,
|
|
90
|
-
) -> bool:
|
|
91
|
-
return not (
|
|
92
|
-
not normalized
|
|
93
|
-
or normalized.startswith("/")
|
|
94
|
-
or re.match(r"^[A-Za-z]:", normalized) is not None
|
|
95
|
-
or any(segment in ("", ".", "..") for segment in segments)
|
|
96
|
-
or any(character in normalized for character in "*?[]")
|
|
97
|
-
or (entry_type in {"planned-file", "deleted-file"} and str(path).endswith(("/", "\\")))
|
|
98
|
-
)
|
|
99
|
-
|
|
100
45
|
|
|
101
|
-
def _resolved_context_target(
|
|
102
|
-
repo_root: Path,
|
|
103
|
-
segments: list[str],
|
|
104
|
-
) -> tuple[Path, Path]:
|
|
105
|
-
resolved_root = Path(repo_root).resolve()
|
|
106
|
-
return resolved_root, resolved_root.joinpath(*segments).resolve(strict=False)
|
|
107
46
|
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class ContextInitializationResult:
|
|
49
|
+
created: tuple[str, ...]
|
|
50
|
+
skipped: tuple[str, ...]
|
|
51
|
+
entry_counts: dict[str, int]
|
|
108
52
|
|
|
109
|
-
def _typed_context_path(
|
|
110
|
-
normalized: str,
|
|
111
|
-
full_path: Path,
|
|
112
|
-
repo_root: Path,
|
|
113
|
-
entry_type: str,
|
|
114
|
-
) -> tuple[str, Path]:
|
|
115
|
-
try:
|
|
116
|
-
full_path.relative_to(repo_root)
|
|
117
|
-
except ValueError as error:
|
|
118
|
-
raise TaskContextError(
|
|
119
|
-
"TASK-CONTEXT-PATH-002",
|
|
120
|
-
full_path,
|
|
121
|
-
"context path resolves outside the repository",
|
|
122
|
-
) from error
|
|
123
|
-
|
|
124
|
-
if entry_type == "directory":
|
|
125
|
-
normalized = f"{normalized}/"
|
|
126
|
-
return normalized, full_path
|
|
127
53
|
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class ContextAddResult:
|
|
56
|
+
added: bool
|
|
57
|
+
entry_type: str
|
|
58
|
+
path: str
|
|
59
|
+
entry: dict
|
|
128
60
|
|
|
129
|
-
def prepare_context_entry(
|
|
130
|
-
repo_root: Path,
|
|
131
|
-
path: str,
|
|
132
|
-
reason: str,
|
|
133
|
-
requested_type: str | None,
|
|
134
|
-
) -> tuple[str, str, dict]:
|
|
135
|
-
"""Build one validated context entry without writing it."""
|
|
136
|
-
entry_type = requested_type
|
|
137
|
-
if entry_type is None:
|
|
138
|
-
candidate = Path(repo_root) / path
|
|
139
|
-
entry_type = "directory" if candidate.is_dir() else "file"
|
|
140
|
-
|
|
141
|
-
normalized_path, full_path = normalize_context_path(
|
|
142
|
-
repo_root,
|
|
143
|
-
path,
|
|
144
|
-
entry_type,
|
|
145
|
-
)
|
|
146
|
-
valid_target = {
|
|
147
|
-
"directory": full_path.is_dir(),
|
|
148
|
-
"planned-file": not full_path.is_dir(),
|
|
149
|
-
"deleted-file": not full_path.is_dir(),
|
|
150
|
-
"file": full_path.is_file(),
|
|
151
|
-
}[entry_type]
|
|
152
|
-
if not valid_target:
|
|
153
|
-
raise TaskContextError(
|
|
154
|
-
"TASK-CONTEXT-PATH-001",
|
|
155
|
-
full_path,
|
|
156
|
-
f"context {entry_type} path does not exist or has the wrong type",
|
|
157
|
-
)
|
|
158
61
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class ContextFileValidation:
|
|
64
|
+
context_file: str
|
|
65
|
+
exists: bool
|
|
66
|
+
entry_count: int
|
|
67
|
+
issues: tuple[ContextValidationIssue, ...]
|
|
163
68
|
|
|
164
69
|
|
|
165
70
|
def _context_issue(
|
|
@@ -280,318 +185,6 @@ def _deleted_file_issue(
|
|
|
280
185
|
)
|
|
281
186
|
|
|
282
187
|
|
|
283
|
-
@dataclass(frozen=True)
|
|
284
|
-
class ContextInitializationResult:
|
|
285
|
-
created: tuple[str, ...]
|
|
286
|
-
skipped: tuple[str, ...]
|
|
287
|
-
entry_counts: dict[str, int]
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
@dataclass(frozen=True)
|
|
291
|
-
class ContextAddResult:
|
|
292
|
-
added: bool
|
|
293
|
-
entry_type: str
|
|
294
|
-
path: str
|
|
295
|
-
entry: dict
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
@dataclass(frozen=True)
|
|
299
|
-
class ContextValidationIssue:
|
|
300
|
-
context_file: str
|
|
301
|
-
line: int
|
|
302
|
-
code: str
|
|
303
|
-
message: str
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
@dataclass(frozen=True)
|
|
307
|
-
class ContextFileValidation:
|
|
308
|
-
context_file: str
|
|
309
|
-
exists: bool
|
|
310
|
-
entry_count: int
|
|
311
|
-
issues: tuple[ContextValidationIssue, ...]
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
@dataclass(frozen=True)
|
|
315
|
-
class ContextJsonlEntry:
|
|
316
|
-
context_file: str
|
|
317
|
-
line: int
|
|
318
|
-
data: object
|
|
319
|
-
text: str
|
|
320
|
-
line_ending: str
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
@dataclass(frozen=True)
|
|
324
|
-
class ContextJsonlReadResult:
|
|
325
|
-
context_file: str
|
|
326
|
-
exists: bool
|
|
327
|
-
entry_count: int
|
|
328
|
-
entries: tuple[ContextJsonlEntry, ...]
|
|
329
|
-
issues: tuple[ContextValidationIssue, ...]
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
def get_implement_base(repo_root: Path | None = None) -> list[dict]:
|
|
333
|
-
root = Path(repo_root) if repo_root is not None else get_repo_root()
|
|
334
|
-
entries = [
|
|
335
|
-
{
|
|
336
|
-
"file": "AGENTS.md",
|
|
337
|
-
"reason": "Project collaboration rules and workflow checks",
|
|
338
|
-
},
|
|
339
|
-
{
|
|
340
|
-
"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/guides/index.md",
|
|
341
|
-
"reason": "Pre-implementation thinking guides",
|
|
342
|
-
},
|
|
343
|
-
{
|
|
344
|
-
"file": (
|
|
345
|
-
f"{DIR_WORKFLOW}/{DIR_SPEC}/guides/"
|
|
346
|
-
"pre-implementation-checklist.md"
|
|
347
|
-
),
|
|
348
|
-
"reason": "Mandatory pre-coding checklist",
|
|
349
|
-
},
|
|
350
|
-
]
|
|
351
|
-
entries.extend(context_entries(root, context="implement"))
|
|
352
|
-
return entries
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
def get_implement_backend() -> list[dict]:
|
|
356
|
-
return [
|
|
357
|
-
{
|
|
358
|
-
"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/backend/index.md",
|
|
359
|
-
"reason": "Backend development guide",
|
|
360
|
-
}
|
|
361
|
-
]
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
def get_implement_frontend() -> list[dict]:
|
|
365
|
-
return [
|
|
366
|
-
{
|
|
367
|
-
"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/frontend/index.md",
|
|
368
|
-
"reason": "Frontend development guide",
|
|
369
|
-
}
|
|
370
|
-
]
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
def get_implement_spec() -> list[dict]:
|
|
374
|
-
return [
|
|
375
|
-
{
|
|
376
|
-
"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/index.md",
|
|
377
|
-
"reason": "Spec index — read before modifying spec/",
|
|
378
|
-
},
|
|
379
|
-
{
|
|
380
|
-
"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/contracts/index.md",
|
|
381
|
-
"reason": "Contract definitions",
|
|
382
|
-
},
|
|
383
|
-
{
|
|
384
|
-
"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/schemas/index.md",
|
|
385
|
-
"reason": "Schema definitions",
|
|
386
|
-
},
|
|
387
|
-
]
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
def detect_installed_platforms(repo_root: Path | None = None) -> list[str]:
|
|
391
|
-
repo_root = Path(repo_root) if repo_root is not None else get_repo_root()
|
|
392
|
-
platforms: list[str] = []
|
|
393
|
-
if (repo_root / ".codex").is_dir():
|
|
394
|
-
platforms.append("codex")
|
|
395
|
-
if (repo_root / ".opencode").is_dir():
|
|
396
|
-
platforms.append("opencode")
|
|
397
|
-
if (repo_root / ".claude").is_dir() or (repo_root / "CLAUDE.md").is_file():
|
|
398
|
-
platforms.append("claude-code")
|
|
399
|
-
return platforms
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
def use_claude_skill_context(repo_root: Path | None = None) -> bool:
|
|
403
|
-
return detect_installed_platforms(repo_root) == ["claude-code"]
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
def skill_path(name: str, repo_root: Path | None = None) -> str:
|
|
407
|
-
if use_claude_skill_context(repo_root):
|
|
408
|
-
return f".claude/skills/{name}/SKILL.md"
|
|
409
|
-
return f"{DIR_AGENTS}/skills/{name}/SKILL.md"
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
def get_domain_skill_context(
|
|
413
|
-
repo_root: Path,
|
|
414
|
-
*,
|
|
415
|
-
dev_type: str | None = None,
|
|
416
|
-
paths: tuple[str, ...] = (),
|
|
417
|
-
) -> list[dict]:
|
|
418
|
-
return context_entries(
|
|
419
|
-
repo_root,
|
|
420
|
-
context="implement",
|
|
421
|
-
dev_type=dev_type,
|
|
422
|
-
paths=paths,
|
|
423
|
-
include_wildcard=False,
|
|
424
|
-
)
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
def is_skill_path(file_path: str) -> bool:
|
|
428
|
-
return (
|
|
429
|
-
file_path.startswith(f"{DIR_AGENTS}/skills/")
|
|
430
|
-
or file_path.startswith(".claude/skills/")
|
|
431
|
-
or ".skills/" in file_path
|
|
432
|
-
or "/skills/" in file_path
|
|
433
|
-
)
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
def discover_spec_files(repo_root: Path, dev_type: str) -> list[str]:
|
|
437
|
-
if dev_type == "spec":
|
|
438
|
-
return [f"{DIR_WORKFLOW}/{DIR_SPEC}/index.md"]
|
|
439
|
-
|
|
440
|
-
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC / dev_type
|
|
441
|
-
if not spec_dir.is_dir():
|
|
442
|
-
return []
|
|
443
|
-
return sorted(
|
|
444
|
-
f"{DIR_WORKFLOW}/{DIR_SPEC}/{dev_type}/{path.name}"
|
|
445
|
-
for path in spec_dir.glob("*.md")
|
|
446
|
-
if path.is_file()
|
|
447
|
-
)
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
def get_check_context(repo_root: Path, dev_type: str) -> list[dict]:
|
|
451
|
-
entries = context_entries(repo_root, context="check", dev_type=dev_type)
|
|
452
|
-
entries.sort(key=lambda entry: entry["file"])
|
|
453
|
-
if dev_type == "spec":
|
|
454
|
-
entries.extend(
|
|
455
|
-
{
|
|
456
|
-
"file": spec_file,
|
|
457
|
-
"reason": f"Verify {Path(spec_file).name} compliance",
|
|
458
|
-
}
|
|
459
|
-
for spec_file in discover_spec_files(repo_root, dev_type)
|
|
460
|
-
)
|
|
461
|
-
entries.extend(quality_source_entries(repo_root, dev_type))
|
|
462
|
-
return entries
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
def get_debug_context(
|
|
466
|
-
dev_type: str,
|
|
467
|
-
repo_root: Path | None = None,
|
|
468
|
-
) -> list[dict]:
|
|
469
|
-
root = Path(repo_root) if repo_root is not None else get_repo_root()
|
|
470
|
-
return context_entries(root, context="debug", dev_type=dev_type)
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
def write_jsonl(path: Path, entries: list[dict]) -> None:
|
|
474
|
-
lines = [json.dumps(entry, ensure_ascii=False) for entry in entries]
|
|
475
|
-
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
def iter_jsonl_lines(path: Path):
|
|
479
|
-
with path.open("r", encoding="utf-8") as stream:
|
|
480
|
-
for line_number, line in enumerate(stream, start=1):
|
|
481
|
-
yield line_number, line.rstrip("\n")
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
def read_context_jsonl_entries(context_file: Path) -> ContextJsonlReadResult:
|
|
485
|
-
"""Parse a context JSONL file into reusable line-level facts."""
|
|
486
|
-
context_file = Path(context_file)
|
|
487
|
-
if not context_file.is_file():
|
|
488
|
-
return ContextJsonlReadResult(
|
|
489
|
-
context_file=context_file.name,
|
|
490
|
-
exists=False,
|
|
491
|
-
entry_count=0,
|
|
492
|
-
entries=(),
|
|
493
|
-
issues=(),
|
|
494
|
-
)
|
|
495
|
-
|
|
496
|
-
entries: list[ContextJsonlEntry] = []
|
|
497
|
-
issues: list[ContextValidationIssue] = []
|
|
498
|
-
entry_count = 0
|
|
499
|
-
try:
|
|
500
|
-
lines: list[tuple[int, str, str]] = []
|
|
501
|
-
with context_file.open("r", encoding="utf-8", newline="") as stream:
|
|
502
|
-
for line_number, raw_line in enumerate(stream, start=1):
|
|
503
|
-
text = raw_line.rstrip("\r\n")
|
|
504
|
-
line_ending = raw_line[len(text):]
|
|
505
|
-
lines.append((line_number, text, line_ending))
|
|
506
|
-
except (OSError, UnicodeDecodeError) as error:
|
|
507
|
-
return ContextJsonlReadResult(
|
|
508
|
-
context_file=context_file.name,
|
|
509
|
-
exists=True,
|
|
510
|
-
entry_count=0,
|
|
511
|
-
entries=(),
|
|
512
|
-
issues=(
|
|
513
|
-
ContextValidationIssue(
|
|
514
|
-
context_file=context_file.name,
|
|
515
|
-
line=0,
|
|
516
|
-
code="read_error",
|
|
517
|
-
message=f"Cannot read {context_file.name}: {error}",
|
|
518
|
-
),
|
|
519
|
-
),
|
|
520
|
-
)
|
|
521
|
-
|
|
522
|
-
for line_number, line, line_ending in lines:
|
|
523
|
-
if not line.strip():
|
|
524
|
-
continue
|
|
525
|
-
entry_count += 1
|
|
526
|
-
try:
|
|
527
|
-
data = json.loads(line)
|
|
528
|
-
except json.JSONDecodeError:
|
|
529
|
-
issues.append(
|
|
530
|
-
ContextValidationIssue(
|
|
531
|
-
context_file=context_file.name,
|
|
532
|
-
line=line_number,
|
|
533
|
-
code="invalid_json",
|
|
534
|
-
message="Invalid JSON",
|
|
535
|
-
)
|
|
536
|
-
)
|
|
537
|
-
continue
|
|
538
|
-
entries.append(
|
|
539
|
-
ContextJsonlEntry(
|
|
540
|
-
context_file=context_file.name,
|
|
541
|
-
line=line_number,
|
|
542
|
-
data=data,
|
|
543
|
-
text=line,
|
|
544
|
-
line_ending=line_ending,
|
|
545
|
-
)
|
|
546
|
-
)
|
|
547
|
-
|
|
548
|
-
return ContextJsonlReadResult(
|
|
549
|
-
context_file=context_file.name,
|
|
550
|
-
exists=True,
|
|
551
|
-
entry_count=entry_count,
|
|
552
|
-
entries=tuple(entries),
|
|
553
|
-
issues=tuple(issues),
|
|
554
|
-
)
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
def normalize_context_file_scope_entry(
|
|
558
|
-
repo_root: Path,
|
|
559
|
-
entry: dict,
|
|
560
|
-
) -> tuple[str | None, str | None]:
|
|
561
|
-
"""Return the file-scope path allowed by one context entry.
|
|
562
|
-
|
|
563
|
-
Directory entries are valid context, but they do not authorize arbitrary
|
|
564
|
-
changed files for lifecycle review.
|
|
565
|
-
"""
|
|
566
|
-
entry_type = entry.get("type", "file")
|
|
567
|
-
file_path = entry.get("file")
|
|
568
|
-
if not isinstance(file_path, str) or not file_path:
|
|
569
|
-
return None, "missing file path"
|
|
570
|
-
if entry_type == "directory":
|
|
571
|
-
return _normalize_context_file_scope_path(repo_root, file_path, entry_type)
|
|
572
|
-
if entry_type not in ("file", "planned-file", "deleted-file"):
|
|
573
|
-
return None, f"unsupported type {entry_type!r}"
|
|
574
|
-
return _normalize_context_file_scope_path(repo_root, file_path, entry_type)
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
def _normalize_context_file_scope_path(
|
|
578
|
-
repo_root: Path,
|
|
579
|
-
file_path: str,
|
|
580
|
-
entry_type: str,
|
|
581
|
-
) -> tuple[str | None, str | None]:
|
|
582
|
-
try:
|
|
583
|
-
normalized, _full_path = normalize_context_path(
|
|
584
|
-
repo_root,
|
|
585
|
-
file_path,
|
|
586
|
-
entry_type,
|
|
587
|
-
)
|
|
588
|
-
except TaskContextError:
|
|
589
|
-
return None, f"non-canonical path {file_path!r}"
|
|
590
|
-
if entry_type == "directory":
|
|
591
|
-
return None, None
|
|
592
|
-
return normalized, None
|
|
593
|
-
|
|
594
|
-
|
|
595
188
|
class TaskContextService:
|
|
596
189
|
"""Manage task JSONL context without CLI rendering."""
|
|
597
190
|
|
|
@@ -782,34 +375,11 @@ class TaskContextService:
|
|
|
782
375
|
return any(str(candidate).lower() == "tiny" for candidate in candidates)
|
|
783
376
|
|
|
784
377
|
def _plan_file_blockers(self, task_data: dict) -> list[str]:
|
|
785
|
-
|
|
786
|
-
plan_file = meta.get("planFile") if isinstance(meta, dict) else None
|
|
787
|
-
if not isinstance(plan_file, str) or not plan_file.strip():
|
|
788
|
-
return ["planFile is required before implementation starts"]
|
|
789
|
-
normalized = self._normalize_plan_file(plan_file)
|
|
790
|
-
if normalized is None:
|
|
791
|
-
return ["planFile must be a repo-relative .cowork-flow/plans path"]
|
|
792
|
-
plan_path = self.repo_root / normalized
|
|
793
|
-
if not plan_path.is_file():
|
|
794
|
-
return [f"planFile does not exist: {normalized}"]
|
|
795
|
-
if not read_text_utf8(plan_path).strip():
|
|
796
|
-
return [f"planFile is empty: {normalized}"]
|
|
797
|
-
return []
|
|
378
|
+
return plan_file_blockers(self.repo_root, task_data)
|
|
798
379
|
|
|
799
380
|
@staticmethod
|
|
800
381
|
def _normalize_plan_file(plan_file: str) -> str | None:
|
|
801
|
-
|
|
802
|
-
while normalized.startswith("./"):
|
|
803
|
-
normalized = normalized[2:]
|
|
804
|
-
segments = normalized.split("/")
|
|
805
|
-
if (
|
|
806
|
-
not normalized
|
|
807
|
-
or normalized.startswith("/")
|
|
808
|
-
or any(segment in ("", ".", "..") for segment in segments)
|
|
809
|
-
or not normalized.startswith(".cowork-flow/plans/")
|
|
810
|
-
):
|
|
811
|
-
return None
|
|
812
|
-
return normalized
|
|
382
|
+
return normalize_plan_file(plan_file)
|
|
813
383
|
|
|
814
384
|
def validation_issue_summaries(self, task_dir: Path) -> tuple[str, ...]:
|
|
815
385
|
summaries: list[str] = []
|
|
@@ -10,11 +10,11 @@ from pathlib import Path
|
|
|
10
10
|
|
|
11
11
|
from services.task_tree import TaskTreeError, TaskTreeService
|
|
12
12
|
from infra.paths import (
|
|
13
|
-
DIR_WORKFLOW,
|
|
14
13
|
FILE_TASK_JSON,
|
|
15
14
|
ensure_task_date_prefix,
|
|
16
15
|
get_tasks_dir,
|
|
17
16
|
)
|
|
17
|
+
from services.plan_binding import PlanBindingError, bind_plan_file
|
|
18
18
|
from services.task_repository import TaskRepository, TaskRepositoryError
|
|
19
19
|
|
|
20
20
|
|
|
@@ -83,7 +83,7 @@ class TaskCreationService:
|
|
|
83
83
|
tasks_dir = ensure_tasks_dir(self.repo_root)
|
|
84
84
|
task_name = self._task_name(request)
|
|
85
85
|
task_dir = tasks_dir / task_name
|
|
86
|
-
plan_metadata = self._plan_metadata(request.from_plan)
|
|
86
|
+
plan_metadata, bound_plan_path = self._plan_metadata(request.from_plan)
|
|
87
87
|
directory_existed = task_dir.exists()
|
|
88
88
|
task_dir.mkdir(parents=True, exist_ok=True)
|
|
89
89
|
|
|
@@ -139,7 +139,7 @@ class TaskCreationService:
|
|
|
139
139
|
|
|
140
140
|
generated_anchor = self._generate_anchor(
|
|
141
141
|
task_dir,
|
|
142
|
-
|
|
142
|
+
bound_plan_path,
|
|
143
143
|
)
|
|
144
144
|
return TaskCreationResult(
|
|
145
145
|
task_dir=task_dir,
|
|
@@ -157,43 +157,47 @@ class TaskCreationService:
|
|
|
157
157
|
return f"{request.date_prefix}-{request.slug}"
|
|
158
158
|
return ensure_task_date_prefix(request.slug)
|
|
159
159
|
|
|
160
|
-
def _plan_metadata(self, from_plan: str | Path | None) -> dict:
|
|
160
|
+
def _plan_metadata(self, from_plan: str | Path | None) -> tuple[dict, Path | None]:
|
|
161
161
|
if not from_plan:
|
|
162
|
-
return {}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
162
|
+
return {}, None
|
|
163
|
+
try:
|
|
164
|
+
bound = bind_plan_file(
|
|
165
|
+
self.repo_root,
|
|
166
|
+
from_plan,
|
|
167
|
+
allow_absolute=True,
|
|
168
|
+
require_exists=True,
|
|
169
|
+
)
|
|
170
|
+
except PlanBindingError as error:
|
|
171
|
+
raise self._plan_creation_error(error) from error
|
|
172
|
+
return {"planFile": bound.normalized}, bound.path
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _plan_creation_error(error: PlanBindingError) -> TaskCreationError:
|
|
176
|
+
if error.code == "missing":
|
|
177
|
+
return TaskCreationError(
|
|
167
178
|
"TASK-CREATE-PLAN-003",
|
|
168
|
-
|
|
179
|
+
error.path,
|
|
169
180
|
"plan file does not exist",
|
|
170
181
|
)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
self.repo_root.resolve()
|
|
174
|
-
).as_posix()
|
|
175
|
-
except ValueError as error:
|
|
176
|
-
raise TaskCreationError(
|
|
182
|
+
if error.code == "outside_repository":
|
|
183
|
+
return TaskCreationError(
|
|
177
184
|
"TASK-CREATE-PLAN-004",
|
|
178
|
-
|
|
185
|
+
error.path,
|
|
179
186
|
"plan file must be inside the repository",
|
|
180
|
-
) from error
|
|
181
|
-
if not relative.startswith(f"{DIR_WORKFLOW}/plans/"):
|
|
182
|
-
raise TaskCreationError(
|
|
183
|
-
"TASK-CREATE-PLAN-005",
|
|
184
|
-
candidate,
|
|
185
|
-
f"plan file must live under {DIR_WORKFLOW}/plans",
|
|
186
187
|
)
|
|
187
|
-
return
|
|
188
|
+
return TaskCreationError(
|
|
189
|
+
"TASK-CREATE-PLAN-005",
|
|
190
|
+
error.path,
|
|
191
|
+
"plan file must live under .cowork-flow/plans",
|
|
192
|
+
)
|
|
188
193
|
|
|
189
194
|
@staticmethod
|
|
190
195
|
def _generate_anchor(
|
|
191
196
|
task_dir: Path,
|
|
192
|
-
|
|
197
|
+
plan_path: Path | None,
|
|
193
198
|
) -> bool:
|
|
194
|
-
if
|
|
199
|
+
if plan_path is None:
|
|
195
200
|
return False
|
|
196
|
-
plan_path = Path(from_plan)
|
|
197
201
|
if not plan_path.is_file():
|
|
198
202
|
return False
|
|
199
203
|
|