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.
Files changed (43) hide show
  1. package/README.md +16 -1
  2. package/package.json +6 -3
  3. package/src/cli.js +6 -0
  4. package/src/commands/source-refresh.js +40 -0
  5. package/src/lib/copy-template.js +130 -1
  6. package/src/lib/host-assets.js +216 -71
  7. package/template/.codex/hooks.json +1 -1
  8. package/template/.cowork-flow/.version +1 -1
  9. package/template/.cowork-flow/scripts/adapters/cli/task.py +0 -2
  10. package/template/.cowork-flow/scripts/adapters/cli/task_archive_commands.py +12 -0
  11. package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +18 -133
  12. package/template/.cowork-flow/scripts/adapters/cli/task_navigation.py +82 -1
  13. package/template/.cowork-flow/scripts/adapters/host/host_manifest.py +98 -114
  14. package/template/.cowork-flow/scripts/infra/skill_manifest.py +23 -3
  15. package/template/.cowork-flow/scripts/infra/storage/operation_log.py +40 -0
  16. package/template/.cowork-flow/scripts/infra/storage/state_store.py +356 -1
  17. package/template/.cowork-flow/scripts/services/context_discovery.py +156 -0
  18. package/template/.cowork-flow/scripts/services/context_jsonl.py +119 -0
  19. package/template/.cowork-flow/scripts/services/context_paths.py +186 -0
  20. package/template/.cowork-flow/scripts/services/lifecycle_checks.py +23 -4
  21. package/template/.cowork-flow/scripts/services/lifecycle_policy.py +128 -0
  22. package/template/.cowork-flow/scripts/services/plan_binding.py +146 -0
  23. package/template/.cowork-flow/scripts/services/task_archive.py +100 -14
  24. package/template/.cowork-flow/scripts/services/task_context.py +49 -479
  25. package/template/.cowork-flow/scripts/services/task_creation.py +31 -27
  26. package/template/.cowork-flow/scripts/services/task_lifecycle.py +155 -18
  27. package/template/.cowork-flow/spec/contracts/error-output-as-data.md +8 -0
  28. package/template/.cowork-flow/spec/runtime/index.md +1 -1
  29. package/template/.cowork-flow/spec/schemas/host-assets.schema.json +32 -6
  30. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  31. package/template/skills/batch-execution/scripts/batch_execution.py +111 -629
  32. package/template/skills/batch-execution/scripts/batch_recovery.py +94 -0
  33. package/template/skills/batch-execution/scripts/batch_state_machine.py +201 -0
  34. package/template/skills/batch-execution/scripts/batch_verification.py +326 -0
  35. package/template/skills/cowork-flow/SKILL.md +3 -0
  36. package/template/skills/party-mode/scripts/party_actions.py +95 -0
  37. package/template/skills/party-mode/scripts/party_mode_v2.py +129 -205
  38. package/template/skills/party-mode/scripts/party_reports.py +177 -0
  39. package/template/skills/party-mode/scripts/party_state_machine.py +189 -0
  40. package/template/skills/runtime-health/scripts/doctor.py +204 -15
  41. package/template/skills/task-review/scripts/review_check.py +135 -2
  42. package/template/skills/test-first/SKILL.md +18 -3
  43. 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.quality_sources import quality_source_entries
13
- from infra.skill_manifest import context_entries
14
- from infra.paths import (
15
- DIR_AGENTS,
16
- DIR_SPEC,
17
- DIR_WORKFLOW,
18
- FILE_TASK_JSON,
19
- get_repo_root,
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
- entry = {"file": normalized_path, "reason": reason}
160
- if entry_type != "file":
161
- entry["type"] = entry_type
162
- return entry_type, normalized_path, entry
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
- meta = task_data.get("meta")
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
- normalized = plan_file.replace("\\", "/").strip()
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
- request.from_plan,
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
- plan_path = Path(from_plan)
164
- candidate = plan_path if plan_path.is_absolute() else self.repo_root / plan_path
165
- if not candidate.is_file():
166
- raise TaskCreationError(
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
- candidate,
179
+ error.path,
169
180
  "plan file does not exist",
170
181
  )
171
- try:
172
- relative = candidate.resolve().relative_to(
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
- candidate,
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 {"planFile": relative}
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
- from_plan: str | Path | None,
197
+ plan_path: Path | None,
193
198
  ) -> bool:
194
- if not from_plan:
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