@alexey_platkovsky/taskpilot 0.1.0-beta.1

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 (121) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +141 -0
  3. package/bin/taskpilot +454 -0
  4. package/package.json +30 -0
  5. package/requirements.lock +66 -0
  6. package/src/taskpilot/__init__.py +1 -0
  7. package/src/taskpilot/__pycache__/__init__.cpython-311.pyc +0 -0
  8. package/src/taskpilot/__pycache__/__init__.cpython-314.pyc +0 -0
  9. package/src/taskpilot/cli/__init__.py +6 -0
  10. package/src/taskpilot/cli/__pycache__/__init__.cpython-311.pyc +0 -0
  11. package/src/taskpilot/cli/__pycache__/__init__.cpython-314.pyc +0 -0
  12. package/src/taskpilot/cli/__pycache__/app.cpython-311.pyc +0 -0
  13. package/src/taskpilot/cli/__pycache__/app.cpython-314.pyc +0 -0
  14. package/src/taskpilot/cli/__pycache__/context.cpython-311.pyc +0 -0
  15. package/src/taskpilot/cli/__pycache__/context.cpython-314.pyc +0 -0
  16. package/src/taskpilot/cli/__pycache__/errors.cpython-311.pyc +0 -0
  17. package/src/taskpilot/cli/__pycache__/errors.cpython-314.pyc +0 -0
  18. package/src/taskpilot/cli/__pycache__/exit_codes.cpython-311.pyc +0 -0
  19. package/src/taskpilot/cli/__pycache__/exit_codes.cpython-314.pyc +0 -0
  20. package/src/taskpilot/cli/__pycache__/output.cpython-311.pyc +0 -0
  21. package/src/taskpilot/cli/__pycache__/output.cpython-314.pyc +0 -0
  22. package/src/taskpilot/cli/__pycache__/registry.cpython-314.pyc +0 -0
  23. package/src/taskpilot/cli/__pycache__/workspace.cpython-311.pyc +0 -0
  24. package/src/taskpilot/cli/__pycache__/workspace.cpython-314.pyc +0 -0
  25. package/src/taskpilot/cli/app.py +61 -0
  26. package/src/taskpilot/cli/commands/__init__.py +6 -0
  27. package/src/taskpilot/cli/commands/__pycache__/__init__.cpython-311.pyc +0 -0
  28. package/src/taskpilot/cli/commands/__pycache__/__init__.cpython-314.pyc +0 -0
  29. package/src/taskpilot/cli/commands/__pycache__/init.cpython-311.pyc +0 -0
  30. package/src/taskpilot/cli/commands/__pycache__/init.cpython-314.pyc +0 -0
  31. package/src/taskpilot/cli/commands/__pycache__/item.cpython-311.pyc +0 -0
  32. package/src/taskpilot/cli/commands/__pycache__/item.cpython-314.pyc +0 -0
  33. package/src/taskpilot/cli/commands/__pycache__/project.cpython-311.pyc +0 -0
  34. package/src/taskpilot/cli/commands/__pycache__/project.cpython-314.pyc +0 -0
  35. package/src/taskpilot/cli/commands/__pycache__/serve.cpython-311.pyc +0 -0
  36. package/src/taskpilot/cli/commands/__pycache__/serve.cpython-314.pyc +0 -0
  37. package/src/taskpilot/cli/commands/__pycache__/validate.cpython-311.pyc +0 -0
  38. package/src/taskpilot/cli/commands/__pycache__/validate.cpython-314.pyc +0 -0
  39. package/src/taskpilot/cli/commands/init.py +116 -0
  40. package/src/taskpilot/cli/commands/item.py +305 -0
  41. package/src/taskpilot/cli/commands/project.py +50 -0
  42. package/src/taskpilot/cli/commands/serve.py +78 -0
  43. package/src/taskpilot/cli/commands/validate.py +61 -0
  44. package/src/taskpilot/cli/context.py +36 -0
  45. package/src/taskpilot/cli/errors.py +53 -0
  46. package/src/taskpilot/cli/exit_codes.py +20 -0
  47. package/src/taskpilot/cli/output.py +77 -0
  48. package/src/taskpilot/cli/workspace.py +33 -0
  49. package/src/taskpilot/core/__init__.py +5 -0
  50. package/src/taskpilot/core/__pycache__/__init__.cpython-311.pyc +0 -0
  51. package/src/taskpilot/core/__pycache__/__init__.cpython-314.pyc +0 -0
  52. package/src/taskpilot/core/__pycache__/comments.cpython-311.pyc +0 -0
  53. package/src/taskpilot/core/__pycache__/comments.cpython-314.pyc +0 -0
  54. package/src/taskpilot/core/__pycache__/item_io.cpython-311.pyc +0 -0
  55. package/src/taskpilot/core/__pycache__/item_io.cpython-314.pyc +0 -0
  56. package/src/taskpilot/core/__pycache__/layout.cpython-311.pyc +0 -0
  57. package/src/taskpilot/core/__pycache__/layout.cpython-314.pyc +0 -0
  58. package/src/taskpilot/core/__pycache__/loader.cpython-314.pyc +0 -0
  59. package/src/taskpilot/core/__pycache__/models.cpython-311.pyc +0 -0
  60. package/src/taskpilot/core/__pycache__/models.cpython-314.pyc +0 -0
  61. package/src/taskpilot/core/__pycache__/project.cpython-311.pyc +0 -0
  62. package/src/taskpilot/core/__pycache__/project.cpython-314.pyc +0 -0
  63. package/src/taskpilot/core/__pycache__/timestamps.cpython-311.pyc +0 -0
  64. package/src/taskpilot/core/__pycache__/timestamps.cpython-314.pyc +0 -0
  65. package/src/taskpilot/core/__pycache__/validation.cpython-311.pyc +0 -0
  66. package/src/taskpilot/core/__pycache__/validation.cpython-314.pyc +0 -0
  67. package/src/taskpilot/core/__pycache__/yaml_io.cpython-311.pyc +0 -0
  68. package/src/taskpilot/core/__pycache__/yaml_io.cpython-314.pyc +0 -0
  69. package/src/taskpilot/core/comments.py +238 -0
  70. package/src/taskpilot/core/item_io.py +123 -0
  71. package/src/taskpilot/core/layout.py +137 -0
  72. package/src/taskpilot/core/loader.py +102 -0
  73. package/src/taskpilot/core/models.py +114 -0
  74. package/src/taskpilot/core/project.py +151 -0
  75. package/src/taskpilot/core/timestamps.py +54 -0
  76. package/src/taskpilot/core/validation.py +385 -0
  77. package/src/taskpilot/core/yaml_io.py +57 -0
  78. package/src/taskpilot/server/__init__.py +0 -0
  79. package/src/taskpilot/server/__pycache__/__init__.cpython-311.pyc +0 -0
  80. package/src/taskpilot/server/__pycache__/__init__.cpython-314.pyc +0 -0
  81. package/src/taskpilot/server/__pycache__/app.cpython-311.pyc +0 -0
  82. package/src/taskpilot/server/__pycache__/app.cpython-314.pyc +0 -0
  83. package/src/taskpilot/server/__pycache__/schemas.cpython-311.pyc +0 -0
  84. package/src/taskpilot/server/__pycache__/schemas.cpython-314.pyc +0 -0
  85. package/src/taskpilot/server/app.py +134 -0
  86. package/src/taskpilot/server/routes/__init__.py +0 -0
  87. package/src/taskpilot/server/routes/__pycache__/__init__.cpython-311.pyc +0 -0
  88. package/src/taskpilot/server/routes/__pycache__/__init__.cpython-314.pyc +0 -0
  89. package/src/taskpilot/server/routes/__pycache__/projects.cpython-311.pyc +0 -0
  90. package/src/taskpilot/server/routes/__pycache__/projects.cpython-314.pyc +0 -0
  91. package/src/taskpilot/server/routes/projects.py +160 -0
  92. package/src/taskpilot/server/schemas.py +76 -0
  93. package/src/taskpilot/services/__init__.py +8 -0
  94. package/src/taskpilot/services/__pycache__/__init__.cpython-311.pyc +0 -0
  95. package/src/taskpilot/services/__pycache__/__init__.cpython-314.pyc +0 -0
  96. package/src/taskpilot/services/__pycache__/comment_service.cpython-311.pyc +0 -0
  97. package/src/taskpilot/services/__pycache__/comment_service.cpython-314.pyc +0 -0
  98. package/src/taskpilot/services/__pycache__/errors.cpython-311.pyc +0 -0
  99. package/src/taskpilot/services/__pycache__/errors.cpython-314.pyc +0 -0
  100. package/src/taskpilot/services/__pycache__/hierarchy.cpython-311.pyc +0 -0
  101. package/src/taskpilot/services/__pycache__/hierarchy.cpython-314.pyc +0 -0
  102. package/src/taskpilot/services/__pycache__/item_service.cpython-311.pyc +0 -0
  103. package/src/taskpilot/services/__pycache__/item_service.cpython-314.pyc +0 -0
  104. package/src/taskpilot/services/__pycache__/link_service.cpython-311.pyc +0 -0
  105. package/src/taskpilot/services/__pycache__/link_service.cpython-314.pyc +0 -0
  106. package/src/taskpilot/services/__pycache__/operation_validation.cpython-311.pyc +0 -0
  107. package/src/taskpilot/services/__pycache__/operation_validation.cpython-314.pyc +0 -0
  108. package/src/taskpilot/services/__pycache__/project_service.cpython-311.pyc +0 -0
  109. package/src/taskpilot/services/__pycache__/project_service.cpython-314.pyc +0 -0
  110. package/src/taskpilot/services/__pycache__/registry.cpython-311.pyc +0 -0
  111. package/src/taskpilot/services/__pycache__/registry.cpython-314.pyc +0 -0
  112. package/src/taskpilot/services/__pycache__/reverse_links.cpython-314.pyc +0 -0
  113. package/src/taskpilot/services/comment_service.py +62 -0
  114. package/src/taskpilot/services/errors.py +26 -0
  115. package/src/taskpilot/services/hierarchy.py +107 -0
  116. package/src/taskpilot/services/item_service.py +264 -0
  117. package/src/taskpilot/services/link_service.py +97 -0
  118. package/src/taskpilot/services/operation_validation.py +52 -0
  119. package/src/taskpilot/services/project_service.py +111 -0
  120. package/src/taskpilot/services/registry.py +194 -0
  121. package/src/taskpilot/services/reverse_links.py +60 -0
@@ -0,0 +1,137 @@
1
+ """Workspace layout constants and path resolution for the ``.taskpilot/`` tree.
2
+
3
+ This module is the storage foundation for feature F001 (task F001-T1). Every
4
+ other storage task (parser, writer, validator, project loader) resolves
5
+ canonical file locations through :class:`WorkspacePaths` rather than hard-coding
6
+ path fragments.
7
+
8
+ Canonical layout (see ``docs/specs/0002-alpha-product-and-stack-decisions.md``)::
9
+
10
+ repo-root/
11
+ .taskpilot/
12
+ project.yaml
13
+ items/
14
+ TP-1.yaml
15
+ comments/
16
+ TP-1/
17
+ 2026-06-23T10-00-00Z.md
18
+
19
+ This module only resolves and validates paths. It does not touch the filesystem
20
+ except for :meth:`WorkspacePaths.exists`, and it never reads, writes, or creates
21
+ canonical files — that belongs to later tasks.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+
29
+ __all__ = [
30
+ "WORKSPACE_DIRNAME",
31
+ "PROJECT_FILENAME",
32
+ "ITEMS_DIRNAME",
33
+ "COMMENTS_DIRNAME",
34
+ "ITEM_FILE_SUFFIX",
35
+ "COMMENT_FILE_SUFFIX",
36
+ "WorkspacePaths",
37
+ ]
38
+
39
+ #: Repository-local directory that holds all canonical TaskPilot data.
40
+ WORKSPACE_DIRNAME = ".taskpilot"
41
+ #: Canonical project identity file inside the workspace.
42
+ PROJECT_FILENAME = "project.yaml"
43
+ #: Directory holding one YAML file per item.
44
+ ITEMS_DIRNAME = "items"
45
+ #: Directory holding per-item comment folders.
46
+ COMMENTS_DIRNAME = "comments"
47
+ #: Suffix for canonical item files (``TP-1.yaml``).
48
+ ITEM_FILE_SUFFIX = ".yaml"
49
+ #: Suffix for canonical comment files (``<timestamp>.md``).
50
+ COMMENT_FILE_SUFFIX = ".md"
51
+
52
+
53
+ def _require_safe_segment(value: str, *, kind: str) -> str:
54
+ """Reject values that are empty or would escape their parent directory.
55
+
56
+ Path resolution must never let a caller-supplied identifier or filename
57
+ traverse outside the workspace. Item IDs and comment filenames are always
58
+ single path segments, so anything containing a separator or a ``..``
59
+ component is rejected.
60
+ """
61
+ if not value:
62
+ raise ValueError(f"{kind} must not be empty")
63
+ if "/" in value or "\\" in value:
64
+ raise ValueError(f"{kind} must not contain a path separator: {value!r}")
65
+ if value in (".", ".."):
66
+ raise ValueError(f"{kind} must not be a relative traversal segment: {value!r}")
67
+ return value
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class WorkspacePaths:
72
+ """Resolves canonical ``.taskpilot/`` paths relative to a repository root.
73
+
74
+ Construct with :meth:`for_root`. Path-returning members never touch the
75
+ filesystem; :meth:`exists` is the only member that reads disk state.
76
+ """
77
+
78
+ root: Path
79
+
80
+ @classmethod
81
+ def for_root(cls, root: Path | str) -> "WorkspacePaths":
82
+ """Build a resolver for the repository ``root`` that contains ``.taskpilot/``.
83
+
84
+ The root is normalized to an absolute path so resolved paths are stable
85
+ regardless of the current working directory.
86
+ """
87
+ return cls(root=Path(root).resolve())
88
+
89
+ @property
90
+ def workspace_dir(self) -> Path:
91
+ """The ``.taskpilot/`` directory."""
92
+ return self.root / WORKSPACE_DIRNAME
93
+
94
+ @property
95
+ def project_file(self) -> Path:
96
+ """The canonical ``project.yaml`` file."""
97
+ return self.workspace_dir / PROJECT_FILENAME
98
+
99
+ @property
100
+ def items_dir(self) -> Path:
101
+ """The ``items/`` directory holding one YAML file per item."""
102
+ return self.workspace_dir / ITEMS_DIRNAME
103
+
104
+ @property
105
+ def comments_dir(self) -> Path:
106
+ """The ``comments/`` directory holding per-item comment folders."""
107
+ return self.workspace_dir / COMMENTS_DIRNAME
108
+
109
+ def item_file(self, item_id: str) -> Path:
110
+ """Path to the canonical YAML file for ``item_id`` (e.g. ``items/TP-1.yaml``)."""
111
+ _require_safe_segment(item_id, kind="item id")
112
+ return self.items_dir / f"{item_id}{ITEM_FILE_SUFFIX}"
113
+
114
+ def item_comments_dir(self, item_id: str) -> Path:
115
+ """Path to the comment folder owned by ``item_id`` (e.g. ``comments/TP-1``)."""
116
+ _require_safe_segment(item_id, kind="item id")
117
+ return self.comments_dir / item_id
118
+
119
+ def comment_file(self, item_id: str, filename: str) -> Path:
120
+ """Path to a single comment file under ``item_id``'s comment folder."""
121
+ _require_safe_segment(filename, kind="comment filename")
122
+ return self.item_comments_dir(item_id) / filename
123
+
124
+ def relative_posix(self, path: Path) -> str:
125
+ """Render ``path`` relative to the root using ``/`` separators on all platforms.
126
+
127
+ Canonical references (validation findings, JSON output) use forward
128
+ slashes regardless of the host OS, e.g. ``.taskpilot/items/TP-1.yaml``.
129
+
130
+ Raises :class:`ValueError` when ``path`` is not located under the
131
+ workspace root.
132
+ """
133
+ return path.resolve().relative_to(self.root).as_posix()
134
+
135
+ def exists(self) -> bool:
136
+ """True only when the ``.taskpilot/`` workspace directory exists."""
137
+ return self.workspace_dir.is_dir()
@@ -0,0 +1,102 @@
1
+ """Project loader that separates valid items from validation errors (task F001-T8).
2
+
3
+ Loading is fault-tolerant (F001-R7): structurally valid items load and are
4
+ returned even when sibling files are invalid, and every problem is surfaced
5
+ through the :class:`~taskpilot.core.validation.ValidationReport` rather than
6
+ crashing or being silently dropped.
7
+
8
+ The authoritative findings come from :func:`validate_workspace`; this loader adds
9
+ the parsed item objects (so callers get usable data) and project-metadata
10
+ findings.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import yaml
16
+ from pydantic import BaseModel, ValidationError
17
+
18
+ from taskpilot.core.item_io import ItemParseError, parse_item_file
19
+ from taskpilot.core.layout import WorkspacePaths
20
+ from taskpilot.core.models import Item
21
+ from taskpilot.core.project import ProjectMeta, read_project
22
+ from taskpilot.core.validation import (
23
+ Finding,
24
+ Severity,
25
+ ValidationReport,
26
+ validate_workspace,
27
+ )
28
+
29
+ __all__ = ["LoadedProject", "load_project"]
30
+
31
+
32
+ class LoadedProject(BaseModel):
33
+ """Result of loading a project: metadata, the parsed items, and all findings.
34
+
35
+ ``items`` contains every *structurally parseable* item (sorted by numeric id).
36
+ An item can be structurally valid yet still carry cross-file error findings
37
+ (e.g. ``duplicate_id`` or ``id_filename_mismatch``), so it appears in ``items``
38
+ while ``ok`` is ``False``. ``items`` is therefore not a pre-filtered "valid and
39
+ listable" set; adapters (CLI/WebUI) apply listability rules such as
40
+ deleted-exclusion and the invalid marker, correlating items with ``report`` by
41
+ id/path.
42
+ """
43
+
44
+ project: ProjectMeta | None
45
+ items: list[Item]
46
+ report: ValidationReport
47
+
48
+ @property
49
+ def ok(self) -> bool:
50
+ return self.report.ok
51
+
52
+
53
+ def _numeric_id_key(item: Item) -> tuple[int, str]:
54
+ """Sort key for items by trailing numeric id suffix (``TP-2`` before ``TP-10``)."""
55
+ _, _, suffix = item.id.rpartition("-")
56
+ return (int(suffix), item.id) if suffix.isdigit() else (-1, item.id)
57
+
58
+
59
+ def load_project(paths: WorkspacePaths) -> LoadedProject:
60
+ """Load a project's items and validation report without crashing on bad files."""
61
+ report = validate_workspace(paths)
62
+ findings: list[Finding] = list(report.findings)
63
+
64
+ # Project metadata: surface a finding when missing or invalid, but keep loading.
65
+ project: ProjectMeta | None = None
66
+ project_rel = paths.relative_posix(paths.project_file)
67
+ if not paths.project_file.exists():
68
+ findings.append(
69
+ Finding(
70
+ severity=Severity.error,
71
+ code="project_missing",
72
+ path=project_rel,
73
+ message="Project file project.yaml is missing",
74
+ )
75
+ )
76
+ else:
77
+ try:
78
+ project = read_project(paths)
79
+ except (ValidationError, yaml.YAMLError, UnicodeDecodeError, OSError) as exc:
80
+ findings.append(
81
+ Finding(
82
+ severity=Severity.error,
83
+ code="project_invalid",
84
+ path=project_rel,
85
+ message=f"Invalid project.yaml: {exc}",
86
+ )
87
+ )
88
+
89
+ # Parse the structurally valid items; invalid files are already in the report.
90
+ items: list[Item] = []
91
+ if paths.items_dir.is_dir():
92
+ for file in sorted(p for p in paths.items_dir.glob("*.yaml") if p.is_file()):
93
+ try:
94
+ items.append(parse_item_file(file))
95
+ except (ItemParseError, ValidationError, UnicodeDecodeError, OSError):
96
+ continue
97
+ items.sort(key=_numeric_id_key)
98
+
99
+ findings.sort(key=lambda f: (f.path, f.code, f.field or "", f.message))
100
+ return LoadedProject(
101
+ project=project, items=items, report=ValidationReport(findings=findings)
102
+ )
@@ -0,0 +1,114 @@
1
+ """Canonical item domain models (task F001-T3).
2
+
3
+ Pydantic models for TaskPilot items, their enums, and non-parent links, matching
4
+ ``docs/specs/0002`` ("Item Fields", "Item Types", "Statuses", "Priority",
5
+ "Links"). Field declaration order is the canonical write order so deterministic
6
+ serialization (F001-T4) can rely on it.
7
+
8
+ Type/enum validation happens here at construction time. Cross-file rules
9
+ (unique IDs, id/filename match, reference existence) are validation-layer
10
+ concerns (F001-T7), not model concerns.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from enum import Enum
16
+
17
+ from pydantic import BaseModel, ConfigDict, Field, StringConstraints, field_validator
18
+ from typing_extensions import Annotated
19
+
20
+ from taskpilot.core.timestamps import is_canonical_iso
21
+
22
+ __all__ = ["SCHEMA_VERSION", "ItemType", "ItemStatus", "Priority", "ItemLinks", "Item"]
23
+
24
+ #: Current canonical schema version for item files.
25
+ SCHEMA_VERSION = 1
26
+
27
+
28
+ class ItemType(str, Enum):
29
+ """Fixed item types through Release."""
30
+
31
+ epic = "epic"
32
+ feature = "feature"
33
+ task = "task"
34
+ bug = "bug"
35
+
36
+
37
+ class ItemStatus(str, Enum):
38
+ """Alpha workflow statuses plus the reserved system status ``deleted``."""
39
+
40
+ backlog = "backlog"
41
+ ready = "ready"
42
+ in_progress = "in_progress"
43
+ done = "done"
44
+ cancelled = "cancelled"
45
+ deleted = "deleted"
46
+
47
+
48
+ class Priority(str, Enum):
49
+ """Item priority; required, defaults to ``normal``."""
50
+
51
+ low = "low"
52
+ normal = "normal"
53
+ high = "high"
54
+
55
+
56
+ NonEmptyStr = Annotated[str, StringConstraints(min_length=1)]
57
+
58
+
59
+ class ItemLinks(BaseModel):
60
+ """Non-parent links: a map of link type to target item IDs.
61
+
62
+ Alpha supports only ``blocks`` and ``relates_to``; unknown link types (e.g.
63
+ ``duplicates``) are rejected.
64
+ """
65
+
66
+ model_config = ConfigDict(extra="forbid")
67
+
68
+ blocks: list[str] = Field(default_factory=list)
69
+ relates_to: list[str] = Field(default_factory=list)
70
+
71
+
72
+ class Item(BaseModel):
73
+ """A canonical TaskPilot item.
74
+
75
+ Unknown top-level fields are preserved (``extra="allow"``) so updates to
76
+ parseable files do not drop data TaskPilot does not yet model.
77
+ """
78
+
79
+ # validate_default ensures defaulted enums (e.g. priority) are coerced to
80
+ # their string values just like supplied ones, so model_dump() never yields a
81
+ # raw enum object that breaks deterministic YAML serialization.
82
+ model_config = ConfigDict(
83
+ extra="allow", use_enum_values=True, validate_default=True
84
+ )
85
+
86
+ # Mandatory fields (canonical order first).
87
+ schema_version: int = SCHEMA_VERSION
88
+ id: NonEmptyStr
89
+ title: NonEmptyStr
90
+ priority: Priority = Priority.normal
91
+ type: ItemType
92
+ status: ItemStatus
93
+ created_at: str
94
+ updated_at: str
95
+ # Optional fields, in canonical order. Absent -> None (omitted on write).
96
+ parent_id: str | None = None
97
+ tags: list[str] | None = None
98
+ description: str | None = None
99
+ attachments: list[str] | None = None
100
+ dor: list[str] | None = None
101
+ dod: list[str] | None = None
102
+ links: ItemLinks | None = None
103
+ created_by: str | None = None
104
+ performed_by: str | None = None
105
+ external_refs: list[str] | None = None
106
+
107
+ @field_validator("created_at", "updated_at")
108
+ @classmethod
109
+ def _check_timestamp(cls, value: str, info) -> str:
110
+ if not is_canonical_iso(value):
111
+ raise ValueError(
112
+ f"{info.field_name} must be canonical UTC ISO 8601 (YYYY-MM-DDTHH:MM:SSZ): {value!r}"
113
+ )
114
+ return value
@@ -0,0 +1,151 @@
1
+ """Project metadata model and workspace initialization (task F001-T2).
2
+
3
+ This is the storage-layer primitive that lays down the canonical ``.taskpilot/``
4
+ tree and a minimal ``project.yaml``. It does not handle the CLI command, the
5
+ local project registry, or project-service orchestration (those are F003/F002).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import tempfile
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ from pydantic import BaseModel, ConfigDict, StringConstraints, field_validator
16
+ from typing_extensions import Annotated
17
+
18
+ from taskpilot.core.layout import WorkspacePaths
19
+ from taskpilot.core.timestamps import is_canonical_iso, utc_now_iso
20
+ from taskpilot.core.yaml_io import dump_yaml, load_yaml
21
+
22
+ __all__ = [
23
+ "SCHEMA_VERSION",
24
+ "ProjectMeta",
25
+ "InitResult",
26
+ "init_workspace",
27
+ "read_project",
28
+ "write_project",
29
+ ]
30
+
31
+ #: Current canonical schema version for project.yaml.
32
+ SCHEMA_VERSION = 1
33
+
34
+
35
+ class ProjectMeta(BaseModel):
36
+ """Canonical project identity, persisted as ``.taskpilot/project.yaml``.
37
+
38
+ Field declaration order is the canonical write order (see ``docs/specs/0002``
39
+ "Project Metadata").
40
+ """
41
+
42
+ model_config = ConfigDict(extra="forbid")
43
+
44
+ schema_version: int = SCHEMA_VERSION
45
+ id: Annotated[str, StringConstraints(min_length=1)]
46
+ key: Annotated[str, StringConstraints(min_length=1)]
47
+ name: Annotated[str, StringConstraints(min_length=1)]
48
+ created_at: str
49
+
50
+ @field_validator("created_at")
51
+ @classmethod
52
+ def _check_created_at(cls, value: str) -> str:
53
+ if not is_canonical_iso(value):
54
+ raise ValueError(
55
+ f"created_at must be canonical UTC ISO 8601 (YYYY-MM-DDTHH:MM:SSZ): {value!r}"
56
+ )
57
+ return value
58
+
59
+
60
+ @dataclass
61
+ class InitResult:
62
+ """Outcome of :func:`init_workspace`.
63
+
64
+ ``created`` is True only when a new ``project.yaml`` was written this call.
65
+ Paths are workspace-root-relative POSIX strings.
66
+ """
67
+
68
+ workspace_dir: Path
69
+ created: bool
70
+ created_paths: list[str] = field(default_factory=list)
71
+ existing_paths: list[str] = field(default_factory=list)
72
+
73
+
74
+ def write_project(paths: WorkspacePaths, meta: ProjectMeta) -> None:
75
+ """Write ``meta`` to the canonical ``project.yaml`` deterministically.
76
+
77
+ Serialization happens first, then the content is written to a temp file in the
78
+ same directory and ``os.replace`` publishes it atomically. A crash or error
79
+ after serialization cannot leave ``project.yaml`` truncated or empty.
80
+ """
81
+ content = dump_yaml(meta.model_dump()).encode("utf-8")
82
+ fd, tmp = tempfile.mkstemp(
83
+ dir=str(paths.workspace_dir), prefix=".project_", suffix=".tmp"
84
+ )
85
+ try:
86
+ os.write(fd, content)
87
+ except BaseException:
88
+ os.close(fd)
89
+ os.unlink(tmp)
90
+ raise
91
+ os.close(fd)
92
+ try:
93
+ os.replace(tmp, str(paths.project_file))
94
+ except BaseException:
95
+ os.unlink(tmp)
96
+ raise
97
+
98
+
99
+ def read_project(paths: WorkspacePaths) -> ProjectMeta:
100
+ """Read and validate ``project.yaml`` into a :class:`ProjectMeta`."""
101
+ data = load_yaml(paths.project_file.read_text(encoding="utf-8"))
102
+ return ProjectMeta.model_validate(data)
103
+
104
+
105
+ def init_workspace(
106
+ root: Path | str,
107
+ *,
108
+ project_id: str,
109
+ key: str,
110
+ name: str,
111
+ now: str | None = None,
112
+ ) -> InitResult:
113
+ """Create the ``.taskpilot/`` tree under ``root`` without overwriting canonical files.
114
+
115
+ Missing directories (``.taskpilot/``, ``items/``, ``comments/``) and a missing
116
+ ``project.yaml`` are created. Existing canonical files are left untouched, so
117
+ re-running init is safe and preserves project identity.
118
+ """
119
+ paths = WorkspacePaths.for_root(root)
120
+ created: list[str] = []
121
+ existing: list[str] = []
122
+
123
+ for directory in (paths.workspace_dir, paths.items_dir, paths.comments_dir):
124
+ rel = paths.relative_posix(directory)
125
+ if directory.is_dir():
126
+ existing.append(rel)
127
+ else:
128
+ directory.mkdir(parents=True, exist_ok=True)
129
+ created.append(rel)
130
+
131
+ project_rel = paths.relative_posix(paths.project_file)
132
+ project_created = False
133
+ if paths.project_file.exists():
134
+ existing.append(project_rel)
135
+ else:
136
+ meta = ProjectMeta(
137
+ id=project_id,
138
+ key=key,
139
+ name=name,
140
+ created_at=now or utc_now_iso(),
141
+ )
142
+ write_project(paths, meta)
143
+ created.append(project_rel)
144
+ project_created = True
145
+
146
+ return InitResult(
147
+ workspace_dir=paths.workspace_dir,
148
+ created=project_created,
149
+ created_paths=created,
150
+ existing_paths=existing,
151
+ )
@@ -0,0 +1,54 @@
1
+ """UTC timestamp helpers for canonical TaskPilot data.
2
+
3
+ Canonical timestamps are UTC ISO 8601 with seconds and a ``Z`` suffix, e.g.
4
+ ``2026-06-24T10:00:00Z`` (see ``docs/specs/0002``). Comment filenames use the
5
+ same instant with ``:`` replaced by ``-`` so they are filesystem-safe and sort
6
+ chronologically.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import datetime, timezone
12
+
13
+ __all__ = [
14
+ "utc_now_iso",
15
+ "is_canonical_iso",
16
+ "iso_to_filename_stamp",
17
+ "filename_stamp_to_iso",
18
+ ]
19
+
20
+ _ISO_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
21
+
22
+
23
+ def utc_now_iso() -> str:
24
+ """Return the current UTC time as ``YYYY-MM-DDTHH:MM:SSZ`` (seconds precision)."""
25
+ return datetime.now(timezone.utc).strftime(_ISO_FORMAT)
26
+
27
+
28
+ def is_canonical_iso(value: str) -> bool:
29
+ """True when ``value`` is a canonical UTC ISO 8601 timestamp (``...Z``, seconds)."""
30
+ try:
31
+ datetime.strptime(value, _ISO_FORMAT)
32
+ except (ValueError, TypeError):
33
+ return False
34
+ return True
35
+
36
+
37
+ def iso_to_filename_stamp(iso: str) -> str:
38
+ """Convert a canonical ISO timestamp to a filesystem-safe stamp.
39
+
40
+ ``2026-06-24T10:00:00Z`` -> ``2026-06-24T10-00-00Z``.
41
+ """
42
+ datetime.strptime(iso, _ISO_FORMAT) # validate shape
43
+ return iso.replace(":", "-")
44
+
45
+
46
+ def filename_stamp_to_iso(stamp: str) -> str:
47
+ """Inverse of :func:`iso_to_filename_stamp`.
48
+
49
+ ``2026-06-24T10-00-00Z`` -> ``2026-06-24T10:00:00Z``.
50
+ """
51
+ date_part, _, time_part = stamp.partition("T")
52
+ iso = f"{date_part}T{time_part.replace('-', ':')}"
53
+ datetime.strptime(iso, _ISO_FORMAT) # validate shape
54
+ return iso