@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,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": []})