@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.
- package/LICENSE +201 -0
- package/README.md +141 -0
- package/bin/taskpilot +454 -0
- package/package.json +30 -0
- package/requirements.lock +66 -0
- package/src/taskpilot/__init__.py +1 -0
- package/src/taskpilot/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__init__.py +6 -0
- package/src/taskpilot/cli/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/app.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/app.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/context.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/context.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/errors.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/errors.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/exit_codes.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/exit_codes.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/output.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/output.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/registry.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/workspace.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/workspace.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/app.py +61 -0
- package/src/taskpilot/cli/commands/__init__.py +6 -0
- package/src/taskpilot/cli/commands/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/init.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/init.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/item.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/item.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/project.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/project.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/serve.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/serve.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/validate.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/validate.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/commands/init.py +116 -0
- package/src/taskpilot/cli/commands/item.py +305 -0
- package/src/taskpilot/cli/commands/project.py +50 -0
- package/src/taskpilot/cli/commands/serve.py +78 -0
- package/src/taskpilot/cli/commands/validate.py +61 -0
- package/src/taskpilot/cli/context.py +36 -0
- package/src/taskpilot/cli/errors.py +53 -0
- package/src/taskpilot/cli/exit_codes.py +20 -0
- package/src/taskpilot/cli/output.py +77 -0
- package/src/taskpilot/cli/workspace.py +33 -0
- package/src/taskpilot/core/__init__.py +5 -0
- package/src/taskpilot/core/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/comments.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/comments.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/item_io.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/item_io.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/layout.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/layout.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/loader.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/models.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/models.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/project.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/project.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/timestamps.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/timestamps.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/validation.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/validation.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/yaml_io.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/yaml_io.cpython-314.pyc +0 -0
- package/src/taskpilot/core/comments.py +238 -0
- package/src/taskpilot/core/item_io.py +123 -0
- package/src/taskpilot/core/layout.py +137 -0
- package/src/taskpilot/core/loader.py +102 -0
- package/src/taskpilot/core/models.py +114 -0
- package/src/taskpilot/core/project.py +151 -0
- package/src/taskpilot/core/timestamps.py +54 -0
- package/src/taskpilot/core/validation.py +385 -0
- package/src/taskpilot/core/yaml_io.py +57 -0
- package/src/taskpilot/server/__init__.py +0 -0
- package/src/taskpilot/server/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/server/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/server/__pycache__/app.cpython-311.pyc +0 -0
- package/src/taskpilot/server/__pycache__/app.cpython-314.pyc +0 -0
- package/src/taskpilot/server/__pycache__/schemas.cpython-311.pyc +0 -0
- package/src/taskpilot/server/__pycache__/schemas.cpython-314.pyc +0 -0
- package/src/taskpilot/server/app.py +134 -0
- package/src/taskpilot/server/routes/__init__.py +0 -0
- package/src/taskpilot/server/routes/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/server/routes/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/server/routes/__pycache__/projects.cpython-311.pyc +0 -0
- package/src/taskpilot/server/routes/__pycache__/projects.cpython-314.pyc +0 -0
- package/src/taskpilot/server/routes/projects.py +160 -0
- package/src/taskpilot/server/schemas.py +76 -0
- package/src/taskpilot/services/__init__.py +8 -0
- package/src/taskpilot/services/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/comment_service.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/comment_service.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/errors.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/errors.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/hierarchy.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/hierarchy.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/item_service.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/item_service.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/link_service.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/link_service.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/operation_validation.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/operation_validation.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/project_service.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/project_service.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/registry.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/registry.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/reverse_links.cpython-314.pyc +0 -0
- package/src/taskpilot/services/comment_service.py +62 -0
- package/src/taskpilot/services/errors.py +26 -0
- package/src/taskpilot/services/hierarchy.py +107 -0
- package/src/taskpilot/services/item_service.py +264 -0
- package/src/taskpilot/services/link_service.py +97 -0
- package/src/taskpilot/services/operation_validation.py +52 -0
- package/src/taskpilot/services/project_service.py +111 -0
- package/src/taskpilot/services/registry.py +194 -0
- 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
|