@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,194 @@
|
|
|
1
|
+
"""Local system registry of known TaskPilot projects (task F003-T2).
|
|
2
|
+
|
|
3
|
+
The registry is *machine-specific* state (spec ``0002`` "Repository and Registry
|
|
4
|
+
Model"): a YAML file in the OS application-data directory that lists every
|
|
5
|
+
project root known on this machine, with an ``active`` flag and cached ``key`` /
|
|
6
|
+
``name`` for display. It is never committed to a project repository and is not
|
|
7
|
+
canonical product data.
|
|
8
|
+
|
|
9
|
+
Test seam: the directory is resolved by :func:`default_registry_dir`, which
|
|
10
|
+
honors the ``TASKPILOT_HOME`` environment variable so tests never touch a real
|
|
11
|
+
home directory.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from pydantic import BaseModel, ConfigDict, StringConstraints, field_validator
|
|
22
|
+
from typing_extensions import Annotated
|
|
23
|
+
|
|
24
|
+
from taskpilot.core.timestamps import is_canonical_iso, utc_now_iso
|
|
25
|
+
from taskpilot.core.yaml_io import dump_yaml, load_yaml
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"SCHEMA_VERSION",
|
|
29
|
+
"REGISTRY_FILENAME",
|
|
30
|
+
"RegistryEntry",
|
|
31
|
+
"Registry",
|
|
32
|
+
"default_registry_dir",
|
|
33
|
+
"registry_file",
|
|
34
|
+
"load_registry",
|
|
35
|
+
"save_registry",
|
|
36
|
+
"register_project",
|
|
37
|
+
"list_projects",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
#: Current registry schema version.
|
|
41
|
+
SCHEMA_VERSION = 1
|
|
42
|
+
#: Registry filename inside the system directory.
|
|
43
|
+
REGISTRY_FILENAME = "registry.yaml"
|
|
44
|
+
|
|
45
|
+
_NonEmptyStr = Annotated[str, StringConstraints(min_length=1)]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RegistryEntry(BaseModel):
|
|
49
|
+
"""One registered project root on this machine.
|
|
50
|
+
|
|
51
|
+
``path`` is an absolute, normalized filesystem path; ``key``/``name`` are
|
|
52
|
+
cached from the project's ``project.yaml`` for display without opening it.
|
|
53
|
+
Field order is the canonical write order (matches spec ``0002``).
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
model_config = ConfigDict(extra="forbid")
|
|
57
|
+
|
|
58
|
+
id: _NonEmptyStr
|
|
59
|
+
key: _NonEmptyStr
|
|
60
|
+
name: _NonEmptyStr
|
|
61
|
+
path: _NonEmptyStr
|
|
62
|
+
active: bool = True
|
|
63
|
+
registered_at: str
|
|
64
|
+
|
|
65
|
+
@field_validator("registered_at")
|
|
66
|
+
@classmethod
|
|
67
|
+
def _check_registered_at(cls, value: str) -> str:
|
|
68
|
+
if not is_canonical_iso(value):
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"registered_at must be canonical UTC ISO 8601 (YYYY-MM-DDTHH:MM:SSZ): {value!r}"
|
|
71
|
+
)
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Registry(BaseModel):
|
|
76
|
+
"""The whole registry document: a schema version and a list of entries."""
|
|
77
|
+
|
|
78
|
+
model_config = ConfigDict(extra="forbid")
|
|
79
|
+
|
|
80
|
+
schema_version: int = SCHEMA_VERSION
|
|
81
|
+
projects: list[RegistryEntry] = []
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def default_registry_dir() -> Path:
|
|
85
|
+
"""Resolve the OS application-data directory that holds the registry.
|
|
86
|
+
|
|
87
|
+
``TASKPILOT_HOME`` overrides everything (used by tests and power users).
|
|
88
|
+
Otherwise: macOS ``~/Library/Application Support/TaskPilot``, Windows
|
|
89
|
+
``%APPDATA%/TaskPilot``, else ``$XDG_DATA_HOME`` or
|
|
90
|
+
``~/.local/share/TaskPilot``.
|
|
91
|
+
"""
|
|
92
|
+
override = os.environ.get("TASKPILOT_HOME")
|
|
93
|
+
if override:
|
|
94
|
+
return Path(override)
|
|
95
|
+
if sys.platform == "darwin":
|
|
96
|
+
return Path.home() / "Library" / "Application Support" / "TaskPilot"
|
|
97
|
+
if os.name == "nt":
|
|
98
|
+
appdata = os.environ.get("APPDATA")
|
|
99
|
+
return (Path(appdata) if appdata else Path.home()) / "TaskPilot"
|
|
100
|
+
xdg = os.environ.get("XDG_DATA_HOME")
|
|
101
|
+
return (Path(xdg) if xdg else Path.home() / ".local" / "share") / "TaskPilot"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def registry_file(registry_dir: Path) -> Path:
|
|
105
|
+
"""Path to the registry YAML file inside ``registry_dir``."""
|
|
106
|
+
return registry_dir / REGISTRY_FILENAME
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_registry(registry_dir: Path) -> Registry:
|
|
110
|
+
"""Load the registry, returning an empty one when the file is absent."""
|
|
111
|
+
path = registry_file(registry_dir)
|
|
112
|
+
if not path.is_file():
|
|
113
|
+
return Registry()
|
|
114
|
+
data = load_yaml(path.read_text(encoding="utf-8"))
|
|
115
|
+
if data is None:
|
|
116
|
+
return Registry()
|
|
117
|
+
return Registry.model_validate(data)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def save_registry(registry_dir: Path, registry: Registry) -> None:
|
|
121
|
+
"""Write ``registry`` to disk atomically, creating the directory if needed.
|
|
122
|
+
|
|
123
|
+
Serializes first, then publishes via ``os.replace`` so a crash mid-write
|
|
124
|
+
cannot leave a truncated registry.
|
|
125
|
+
"""
|
|
126
|
+
registry_dir.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
content = dump_yaml(registry.model_dump()).encode("utf-8")
|
|
128
|
+
fd, tmp = tempfile.mkstemp(
|
|
129
|
+
dir=str(registry_dir), prefix=".registry_", suffix=".tmp"
|
|
130
|
+
)
|
|
131
|
+
try:
|
|
132
|
+
os.write(fd, content)
|
|
133
|
+
except BaseException:
|
|
134
|
+
os.close(fd)
|
|
135
|
+
os.unlink(tmp)
|
|
136
|
+
raise
|
|
137
|
+
os.close(fd)
|
|
138
|
+
try:
|
|
139
|
+
os.replace(tmp, str(registry_file(registry_dir)))
|
|
140
|
+
except BaseException:
|
|
141
|
+
os.unlink(tmp)
|
|
142
|
+
raise
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def register_project(
|
|
146
|
+
registry_dir: Path,
|
|
147
|
+
*,
|
|
148
|
+
id: str,
|
|
149
|
+
key: str,
|
|
150
|
+
name: str,
|
|
151
|
+
path: str,
|
|
152
|
+
now: str | None = None,
|
|
153
|
+
) -> RegistryEntry:
|
|
154
|
+
"""Add the project to the registry, or re-enable/refresh an existing entry.
|
|
155
|
+
|
|
156
|
+
Alpha allows only one registered path per ``project.id`` (spec ``0002``): if
|
|
157
|
+
an entry with the same ``id`` exists it is updated in place — ``path``,
|
|
158
|
+
``key``, ``name`` refreshed and ``active`` set true — preserving the original
|
|
159
|
+
``registered_at``. Otherwise a new active entry is appended. The stored
|
|
160
|
+
``path`` is absolute and normalized.
|
|
161
|
+
"""
|
|
162
|
+
normalized_path = str(Path(path).resolve())
|
|
163
|
+
registry = load_registry(registry_dir)
|
|
164
|
+
|
|
165
|
+
for existing in registry.projects:
|
|
166
|
+
if existing.id == id:
|
|
167
|
+
existing.key = key
|
|
168
|
+
existing.name = name
|
|
169
|
+
existing.path = normalized_path
|
|
170
|
+
existing.active = True
|
|
171
|
+
save_registry(registry_dir, registry)
|
|
172
|
+
return existing
|
|
173
|
+
|
|
174
|
+
entry = RegistryEntry(
|
|
175
|
+
id=id,
|
|
176
|
+
key=key,
|
|
177
|
+
name=name,
|
|
178
|
+
path=normalized_path,
|
|
179
|
+
active=True,
|
|
180
|
+
registered_at=now or utc_now_iso(),
|
|
181
|
+
)
|
|
182
|
+
registry.projects.append(entry)
|
|
183
|
+
save_registry(registry_dir, registry)
|
|
184
|
+
return entry
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def list_projects(registry_dir: Path) -> list[RegistryEntry]:
|
|
188
|
+
"""Return registered projects sorted by name (spec ``0002``: ``project list``
|
|
189
|
+
"sorts by project name").
|
|
190
|
+
|
|
191
|
+
``id`` is a secondary key so the order is total and deterministic (F003-R8)
|
|
192
|
+
even when two projects share a display name.
|
|
193
|
+
"""
|
|
194
|
+
return sorted(load_registry(registry_dir).projects, key=lambda e: (e.name, e.id))
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Reverse link derivation (task F002-T5, requirement F002-R5).
|
|
2
|
+
|
|
3
|
+
Reverse relationships are computed from stored forward links at query time and
|
|
4
|
+
never persisted (the source item is the single place a link is recorded):
|
|
5
|
+
|
|
6
|
+
forward ``blocks`` -> reverse ``blocked_by``
|
|
7
|
+
forward ``relates_to`` -> reverse ``related_to``
|
|
8
|
+
|
|
9
|
+
If A ``blocks`` B then B is ``blocked_by`` A. Target lists are sorted by numeric
|
|
10
|
+
id for deterministic output.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections import defaultdict
|
|
16
|
+
|
|
17
|
+
from taskpilot.core.layout import WorkspacePaths
|
|
18
|
+
from taskpilot.services import item_service
|
|
19
|
+
|
|
20
|
+
__all__ = ["derive_reverse_links", "reverse_links_for"]
|
|
21
|
+
|
|
22
|
+
#: Forward link type -> reverse link name.
|
|
23
|
+
_REVERSE_OF = {"blocks": "blocked_by", "relates_to": "related_to"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _numeric_id_key(item_id: str) -> tuple[int, str]:
|
|
27
|
+
_, _, suffix = item_id.rpartition("-")
|
|
28
|
+
return (int(suffix), item_id) if suffix.isdigit() else (-1, item_id)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def derive_reverse_links(paths: WorkspacePaths) -> dict[str, dict[str, list[str]]]:
|
|
32
|
+
"""Derive reverse links for every item that has incoming links (F002-R5).
|
|
33
|
+
|
|
34
|
+
Returns ``{target_id: {"blocked_by": [...], "related_to": [...]}}``. Items
|
|
35
|
+
with no incoming links are omitted. Considers all items (including deleted)
|
|
36
|
+
so a stored link is always reflected; sorting is numeric for determinism.
|
|
37
|
+
"""
|
|
38
|
+
reverse: dict[str, dict[str, list[str]]] = defaultdict(
|
|
39
|
+
lambda: {"blocked_by": [], "related_to": []}
|
|
40
|
+
)
|
|
41
|
+
for item in item_service.list_items(paths, include_deleted=True):
|
|
42
|
+
if item.links is None:
|
|
43
|
+
continue
|
|
44
|
+
for forward, reverse_name in _REVERSE_OF.items():
|
|
45
|
+
for target in getattr(item.links, forward):
|
|
46
|
+
reverse[target][reverse_name].append(item.id)
|
|
47
|
+
|
|
48
|
+
for entry in reverse.values():
|
|
49
|
+
for name in ("blocked_by", "related_to"):
|
|
50
|
+
entry[name].sort(key=_numeric_id_key)
|
|
51
|
+
return dict(reverse)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def reverse_links_for(paths: WorkspacePaths, item_id: str) -> dict[str, list[str]]:
|
|
55
|
+
"""Return derived reverse links for a single item (F002-R5).
|
|
56
|
+
|
|
57
|
+
Always returns both keys; empty lists when the item has no incoming links.
|
|
58
|
+
"""
|
|
59
|
+
full = derive_reverse_links(paths)
|
|
60
|
+
return full.get(item_id, {"blocked_by": [], "related_to": []})
|