@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,53 @@
|
|
|
1
|
+
"""Translate domain errors into CLI stderr output and exit codes (task F003-T1).
|
|
2
|
+
|
|
3
|
+
Domain services raise :class:`~taskpilot.services.errors.ServiceError` subtypes.
|
|
4
|
+
The CLI adapter turns those into an actionable stderr message and the
|
|
5
|
+
deterministic exit code from :mod:`taskpilot.cli.exit_codes`:
|
|
6
|
+
|
|
7
|
+
- :class:`ValidationFailed`, :class:`NotFound`, :class:`ConflictError` are
|
|
8
|
+
caller-correctable -> exit ``1`` (user error);
|
|
9
|
+
- any other :class:`ServiceError` is treated as an unexpected internal failure
|
|
10
|
+
-> exit ``2`` (system error).
|
|
11
|
+
|
|
12
|
+
JSON-mode error envelopes are intentionally out of scope for Alpha (see
|
|
13
|
+
``requirements.md`` "Out of Scope"); errors are plain text on stderr in every
|
|
14
|
+
mode so the message is never swallowed.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
from typing import Iterator
|
|
21
|
+
|
|
22
|
+
import typer
|
|
23
|
+
|
|
24
|
+
from taskpilot.cli import exit_codes
|
|
25
|
+
from taskpilot.services.errors import (
|
|
26
|
+
ConflictError,
|
|
27
|
+
NotFound,
|
|
28
|
+
ServiceError,
|
|
29
|
+
ValidationFailed,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = ["service_errors"]
|
|
33
|
+
|
|
34
|
+
#: Service errors the caller can correct -> user error exit code.
|
|
35
|
+
_USER_ERRORS = (ValidationFailed, NotFound, ConflictError)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@contextmanager
|
|
39
|
+
def service_errors() -> Iterator[None]:
|
|
40
|
+
"""Run a command body, converting service errors to stderr + ``typer.Exit``.
|
|
41
|
+
|
|
42
|
+
Wrap the service calls of a command in this context manager. Known
|
|
43
|
+
caller-correctable errors exit ``1``; any other :class:`ServiceError` exits
|
|
44
|
+
``2``. Non-``ServiceError`` exceptions propagate unchanged.
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
yield
|
|
48
|
+
except _USER_ERRORS as exc:
|
|
49
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
50
|
+
raise typer.Exit(exit_codes.EXIT_USER_ERROR) from exc
|
|
51
|
+
except ServiceError as exc:
|
|
52
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
53
|
+
raise typer.Exit(exit_codes.EXIT_SYSTEM_ERROR) from exc
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Deterministic CLI exit codes (feature F003).
|
|
2
|
+
|
|
3
|
+
The CLI contract (``docs/features/F003_cli-interface/requirements.md``) fixes
|
|
4
|
+
three exit codes so scripts and AI agents can branch on them reliably:
|
|
5
|
+
|
|
6
|
+
- ``0`` success;
|
|
7
|
+
- ``1`` user error (bad input, not found, conflict — the caller can fix it);
|
|
8
|
+
- ``2`` system error (an unexpected failure the caller cannot fix).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
__all__ = ["EXIT_OK", "EXIT_USER_ERROR", "EXIT_SYSTEM_ERROR"]
|
|
14
|
+
|
|
15
|
+
#: Command completed successfully.
|
|
16
|
+
EXIT_OK = 0
|
|
17
|
+
#: Caller-correctable error: invalid input, missing item/project, conflict.
|
|
18
|
+
EXIT_USER_ERROR = 1
|
|
19
|
+
#: Unexpected failure the caller cannot correct (I/O, internal error).
|
|
20
|
+
EXIT_SYSTEM_ERROR = 2
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Output formatting for the TaskPilot CLI (task F003-T1, requirement F003-R8).
|
|
2
|
+
|
|
3
|
+
Two output modes share one rendering seam so every command stays consistent:
|
|
4
|
+
|
|
5
|
+
- **JSON mode** (``--json``): :func:`dumps_json` serializes plain data to
|
|
6
|
+
deterministic JSON on stdout. Determinism (F003-R8) comes from preserving the
|
|
7
|
+
data's own key order — domain models are dumped in canonical field order, so
|
|
8
|
+
re-running a read command with no state change yields byte-identical bytes. We
|
|
9
|
+
never ``sort_keys``: canonical order is declared field order, not alphabetical.
|
|
10
|
+
- **Human mode** (default): helpers render tables, key/value blocks, and
|
|
11
|
+
one-line confirmations to stdout.
|
|
12
|
+
|
|
13
|
+
Errors always go to stderr (see :mod:`taskpilot.cli.errors`); these helpers
|
|
14
|
+
write only success output to stdout.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from typing import Any, TextIO
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"dumps_json",
|
|
25
|
+
"print_json",
|
|
26
|
+
"print_line",
|
|
27
|
+
"render_table",
|
|
28
|
+
"render_key_values",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def dumps_json(data: Any) -> str:
|
|
33
|
+
"""Serialize ``data`` to deterministic, human-readable JSON.
|
|
34
|
+
|
|
35
|
+
Key order is taken from ``data`` as given (no ``sort_keys``) so that callers
|
|
36
|
+
controlling field order — e.g. Pydantic ``model_dump`` in canonical order —
|
|
37
|
+
get stable output. Uses 2-space indentation and keeps non-ASCII characters
|
|
38
|
+
verbatim. No trailing newline is added; :func:`print_json` adds one.
|
|
39
|
+
"""
|
|
40
|
+
return json.dumps(data, indent=2, ensure_ascii=False)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def print_json(data: Any, *, stream: TextIO | None = None) -> None:
|
|
44
|
+
"""Write ``data`` as JSON plus a trailing newline to stdout (or ``stream``)."""
|
|
45
|
+
print(dumps_json(data), file=stream or sys.stdout)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def print_line(text: str, *, stream: TextIO | None = None) -> None:
|
|
49
|
+
"""Write a single human-readable line to stdout (or ``stream``)."""
|
|
50
|
+
print(text, file=stream or sys.stdout)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def render_table(headers: list[str], rows: list[list[str]]) -> str:
|
|
54
|
+
"""Render a fixed-width text table with left-aligned, space-padded columns.
|
|
55
|
+
|
|
56
|
+
Returns the header row, a dashed rule, and one line per row. With no rows the
|
|
57
|
+
header and rule are still returned so the column shape is visible.
|
|
58
|
+
"""
|
|
59
|
+
columns = [headers] + rows
|
|
60
|
+
widths = [max(len(str(row[i])) for row in columns) for i in range(len(headers))]
|
|
61
|
+
|
|
62
|
+
def _format(cells: list[str]) -> str:
|
|
63
|
+
return " ".join(
|
|
64
|
+
str(cell).ljust(widths[i]) for i, cell in enumerate(cells)
|
|
65
|
+
).rstrip()
|
|
66
|
+
|
|
67
|
+
lines = [_format(headers), " ".join("-" * w for w in widths).rstrip()]
|
|
68
|
+
lines.extend(_format(row) for row in rows)
|
|
69
|
+
return "\n".join(lines)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def render_key_values(pairs: list[tuple[str, str]]) -> str:
|
|
73
|
+
"""Render ``(key, value)`` pairs as right-padded ``key: value`` lines."""
|
|
74
|
+
if not pairs:
|
|
75
|
+
return ""
|
|
76
|
+
width = max(len(key) for key, _ in pairs)
|
|
77
|
+
return "\n".join(f"{key.ljust(width)} {value}" for key, value in pairs)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Locate the TaskPilot workspace for repo-scoped commands (task F003-T4).
|
|
2
|
+
|
|
3
|
+
Commands like ``item`` and ``validate`` operate on "the current project". They
|
|
4
|
+
resolve it by walking up from the current directory to the nearest ancestor that
|
|
5
|
+
contains a ``.taskpilot/`` directory — the conventional repo-root discovery used
|
|
6
|
+
by Git-like tools. ``init`` does not use this (it inspects only the given path).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from taskpilot.core.layout import WORKSPACE_DIRNAME, WorkspacePaths
|
|
14
|
+
from taskpilot.services.errors import NotFound
|
|
15
|
+
|
|
16
|
+
__all__ = ["find_workspace"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def find_workspace(start: Path | None = None) -> WorkspacePaths:
|
|
20
|
+
"""Return the workspace for the repo containing ``start`` (default: cwd).
|
|
21
|
+
|
|
22
|
+
Walks ``start`` and its parents for a ``.taskpilot/`` directory. Raises
|
|
23
|
+
:class:`~taskpilot.services.errors.NotFound` (mapped to exit code 1) when no
|
|
24
|
+
workspace is found, with a hint to run ``taskpilot init``.
|
|
25
|
+
"""
|
|
26
|
+
current = (start or Path.cwd()).resolve()
|
|
27
|
+
for directory in (current, *current.parents):
|
|
28
|
+
if (directory / WORKSPACE_DIRNAME).is_dir():
|
|
29
|
+
return WorkspacePaths.for_root(directory)
|
|
30
|
+
raise NotFound(
|
|
31
|
+
f"No TaskPilot workspace found in {current} or any parent directory; "
|
|
32
|
+
"run `taskpilot init .` in the project root"
|
|
33
|
+
)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Comment model and Markdown-with-frontmatter parsing (task F001-T5).
|
|
2
|
+
|
|
3
|
+
Comments are append-only Markdown files with a YAML frontmatter block::
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
schema_version: 1
|
|
7
|
+
created_at: 2026-06-23T10:00:00Z
|
|
8
|
+
created_by: Aleksei
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
Investigated current parser behavior.
|
|
12
|
+
|
|
13
|
+
They live under ``.taskpilot/comments/<ITEM_ID>/``; the folder name is the owning
|
|
14
|
+
item ID, so frontmatter does not duplicate it. The filename timestamp is the
|
|
15
|
+
comment identity and should match ``created_at``. Comment writing and timestamp
|
|
16
|
+
collision handling are F001-T6.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import yaml
|
|
25
|
+
from pydantic import BaseModel, ConfigDict, StringConstraints, field_validator
|
|
26
|
+
from typing_extensions import Annotated
|
|
27
|
+
|
|
28
|
+
from taskpilot.core.layout import WorkspacePaths
|
|
29
|
+
from taskpilot.core.timestamps import (
|
|
30
|
+
filename_stamp_to_iso,
|
|
31
|
+
is_canonical_iso,
|
|
32
|
+
iso_to_filename_stamp,
|
|
33
|
+
utc_now_iso,
|
|
34
|
+
)
|
|
35
|
+
from taskpilot.core.yaml_io import dump_yaml, load_yaml
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"SCHEMA_VERSION",
|
|
39
|
+
"Comment",
|
|
40
|
+
"CommentParseError",
|
|
41
|
+
"parse_comment_text",
|
|
42
|
+
"parse_comment_file",
|
|
43
|
+
"list_comments",
|
|
44
|
+
"dump_comment",
|
|
45
|
+
"write_comment",
|
|
46
|
+
"add_comment",
|
|
47
|
+
"comment_filename_timestamp",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
#: Current canonical schema version for comment frontmatter.
|
|
51
|
+
SCHEMA_VERSION = 1
|
|
52
|
+
|
|
53
|
+
_FRONTMATTER_RE = re.compile(r"\A---\n(?P<fm>.*?)\n---\n?(?P<body>.*)\Z", re.DOTALL)
|
|
54
|
+
|
|
55
|
+
NonEmptyStr = Annotated[str, StringConstraints(min_length=1)]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CommentParseError(Exception):
|
|
59
|
+
"""Raised when a comment file lacks valid YAML frontmatter or a mapping body."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, message: str, *, path: Path | None = None):
|
|
62
|
+
self.path = path
|
|
63
|
+
super().__init__(f"{path}: {message}" if path is not None else message)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Comment(BaseModel):
|
|
67
|
+
"""A single append-only comment parsed from a Markdown file."""
|
|
68
|
+
|
|
69
|
+
model_config = ConfigDict(extra="allow")
|
|
70
|
+
|
|
71
|
+
schema_version: int = SCHEMA_VERSION
|
|
72
|
+
created_at: str
|
|
73
|
+
created_by: NonEmptyStr
|
|
74
|
+
body: str = ""
|
|
75
|
+
|
|
76
|
+
@field_validator("created_at")
|
|
77
|
+
@classmethod
|
|
78
|
+
def _check_created_at(cls, value: str) -> str:
|
|
79
|
+
if not is_canonical_iso(value):
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"created_at must be canonical UTC ISO 8601 (YYYY-MM-DDTHH:MM:SSZ): {value!r}"
|
|
82
|
+
)
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_comment_text(text: str, *, path: Path | None = None) -> Comment:
|
|
87
|
+
"""Parse comment Markdown ``text`` into a :class:`Comment`.
|
|
88
|
+
|
|
89
|
+
Raises :class:`CommentParseError` for missing/invalid frontmatter and pydantic
|
|
90
|
+
``ValidationError`` for schema problems.
|
|
91
|
+
|
|
92
|
+
Line endings are normalized to ``\\n`` first, so CRLF files (e.g. produced on
|
|
93
|
+
Windows or by ``core.autocrlf``) parse identically to LF files.
|
|
94
|
+
"""
|
|
95
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
96
|
+
match = _FRONTMATTER_RE.match(text)
|
|
97
|
+
if match is None:
|
|
98
|
+
raise CommentParseError("missing or malformed YAML frontmatter", path=path)
|
|
99
|
+
try:
|
|
100
|
+
data = load_yaml(match.group("fm"))
|
|
101
|
+
except yaml.YAMLError as exc:
|
|
102
|
+
raise CommentParseError(f"invalid YAML frontmatter: {exc}", path=path) from exc
|
|
103
|
+
if not isinstance(data, dict):
|
|
104
|
+
raise CommentParseError("frontmatter is not a mapping", path=path)
|
|
105
|
+
return Comment.model_validate({**data, "body": match.group("body").strip("\n")})
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_comment_file(path: Path) -> Comment:
|
|
109
|
+
"""Read and parse the comment file at ``path``.
|
|
110
|
+
|
|
111
|
+
Note: the filename timestamp is the comment's identity and should match
|
|
112
|
+
``created_at`` (see ``docs/specs/0002``). For well-formed files they do match;
|
|
113
|
+
the parser does not *enforce* the match (a mismatch is accepted), leaving
|
|
114
|
+
detection to a future comment-validation layer. No F001 task currently owns
|
|
115
|
+
that check.
|
|
116
|
+
"""
|
|
117
|
+
return parse_comment_text(path.read_text(encoding="utf-8"), path=path)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _comment_sort_key(filename: str) -> tuple[str, int]:
|
|
121
|
+
"""Chronological sort key ``(timestamp, collision_suffix)`` for a comment filename.
|
|
122
|
+
|
|
123
|
+
A base file like ``2026-06-23T10-00-00Z.md`` sorts before its same-second
|
|
124
|
+
collision ``2026-06-23T10-00-00Z-2.md``; raw lexical order would invert them.
|
|
125
|
+
"""
|
|
126
|
+
stem = filename[:-3] if filename.endswith(".md") else filename
|
|
127
|
+
marker = stem.find("Z")
|
|
128
|
+
if marker == -1:
|
|
129
|
+
return (stem, 0)
|
|
130
|
+
stamp = stem[: marker + 1]
|
|
131
|
+
rest = stem[marker + 1 :]
|
|
132
|
+
suffix = int(rest.lstrip("-")) if rest.startswith("-") and rest[1:].isdigit() else 0
|
|
133
|
+
return (stamp, suffix)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def comment_filename_timestamp(filename: str) -> str | None:
|
|
137
|
+
"""Return the canonical ISO timestamp encoded in a comment filename, or ``None``.
|
|
138
|
+
|
|
139
|
+
Strips the ``.md`` suffix and any ``-N`` collision suffix, then converts the
|
|
140
|
+
``YYYY-MM-DDTHH-MM-SSZ`` stamp to ``YYYY-MM-DDTHH:MM:SSZ``. Returns ``None``
|
|
141
|
+
when the filename does not encode a valid timestamp stamp.
|
|
142
|
+
"""
|
|
143
|
+
stem = filename[:-3] if filename.endswith(".md") else filename
|
|
144
|
+
marker = stem.find("Z")
|
|
145
|
+
if marker == -1:
|
|
146
|
+
return None
|
|
147
|
+
try:
|
|
148
|
+
return filename_stamp_to_iso(stem[: marker + 1])
|
|
149
|
+
except ValueError:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def list_comments(paths: WorkspacePaths, item_id: str) -> list[Comment]:
|
|
154
|
+
"""Return ``item_id``'s comments parsed and ordered chronologically.
|
|
155
|
+
|
|
156
|
+
Returns an empty list when the item has no comment directory. This is a
|
|
157
|
+
strict accessor: it raises :class:`CommentParseError` (or pydantic
|
|
158
|
+
``ValidationError``) on the first malformed comment file rather than skipping
|
|
159
|
+
it. Fault-tolerant loading that surfaces invalid files as findings is a
|
|
160
|
+
validation-layer concern (F001-T7); callers needing tolerance should validate
|
|
161
|
+
first.
|
|
162
|
+
"""
|
|
163
|
+
directory = paths.item_comments_dir(item_id)
|
|
164
|
+
if not directory.is_dir():
|
|
165
|
+
return []
|
|
166
|
+
files = sorted(
|
|
167
|
+
(p for p in directory.iterdir() if p.is_file() and p.suffix == ".md"),
|
|
168
|
+
key=lambda p: _comment_sort_key(p.name),
|
|
169
|
+
)
|
|
170
|
+
return [parse_comment_file(p) for p in files]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def dump_comment(comment: Comment) -> str:
|
|
174
|
+
"""Serialize ``comment`` to Markdown-with-frontmatter, round-tripping the parser.
|
|
175
|
+
|
|
176
|
+
Frontmatter is emitted as ``schema_version``, ``created_at``, ``created_by``,
|
|
177
|
+
then preserved unknown fields in sorted key order, followed by the body.
|
|
178
|
+
"""
|
|
179
|
+
raw = comment.model_dump()
|
|
180
|
+
frontmatter: dict = {
|
|
181
|
+
"schema_version": raw["schema_version"],
|
|
182
|
+
"created_at": raw["created_at"],
|
|
183
|
+
"created_by": raw["created_by"],
|
|
184
|
+
}
|
|
185
|
+
for key in sorted(k for k in raw if k not in Comment.model_fields):
|
|
186
|
+
frontmatter[key] = raw[key]
|
|
187
|
+
|
|
188
|
+
text = f"---\n{dump_yaml(frontmatter)}---\n"
|
|
189
|
+
if comment.body:
|
|
190
|
+
text += f"\n{comment.body}\n"
|
|
191
|
+
return text
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def write_comment(paths: WorkspacePaths, item_id: str, comment: Comment) -> Path:
|
|
195
|
+
"""Write ``comment`` under ``item_id``, returning the chosen path.
|
|
196
|
+
|
|
197
|
+
The filename is derived from ``comment.created_at``; same-second collisions
|
|
198
|
+
receive ``-2``, ``-3``, ... suffixes (F001-R5). Creates the comment directory
|
|
199
|
+
if missing.
|
|
200
|
+
|
|
201
|
+
Slot acquisition uses ``open(..., "x")`` (O_EXCL) so the check-and-create is
|
|
202
|
+
atomic: two concurrent writers in the same second cannot both claim the same
|
|
203
|
+
filename.
|
|
204
|
+
"""
|
|
205
|
+
directory = paths.item_comments_dir(item_id)
|
|
206
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
|
|
208
|
+
content = dump_comment(comment)
|
|
209
|
+
base = iso_to_filename_stamp(comment.created_at)
|
|
210
|
+
target = directory / f"{base}.md"
|
|
211
|
+
suffix = 2
|
|
212
|
+
while True:
|
|
213
|
+
try:
|
|
214
|
+
with open(target, "x", encoding="utf-8") as f:
|
|
215
|
+
f.write(content)
|
|
216
|
+
return target
|
|
217
|
+
except FileExistsError:
|
|
218
|
+
if suffix > 10_000:
|
|
219
|
+
raise RuntimeError(f"Too many comment collisions for {base}")
|
|
220
|
+
target = directory / f"{base}-{suffix}.md"
|
|
221
|
+
suffix += 1
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def add_comment(
|
|
225
|
+
paths: WorkspacePaths,
|
|
226
|
+
item_id: str,
|
|
227
|
+
*,
|
|
228
|
+
body: str,
|
|
229
|
+
created_by: str,
|
|
230
|
+
now: str | None = None,
|
|
231
|
+
) -> Path:
|
|
232
|
+
"""Create and write a new comment for ``item_id``; returns the written path.
|
|
233
|
+
|
|
234
|
+
``now`` defaults to the current UTC time and becomes both the frontmatter
|
|
235
|
+
``created_at`` and the filename timestamp.
|
|
236
|
+
"""
|
|
237
|
+
comment = Comment(created_at=now or utc_now_iso(), created_by=created_by, body=body)
|
|
238
|
+
return write_comment(paths, item_id, comment)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Reading and writing canonical item YAML files.
|
|
2
|
+
|
|
3
|
+
F001-T3 implements strict parsing into the :class:`~taskpilot.core.models.Item`
|
|
4
|
+
model. YAML syntax problems and non-mapping documents raise :class:`ItemParseError`
|
|
5
|
+
with the offending path; schema problems raise pydantic ``ValidationError``.
|
|
6
|
+
Both are surfaced as actionable findings by the validation/loader layers
|
|
7
|
+
(F001-T7/T8) rather than crashing a project load.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from taskpilot.core.layout import WorkspacePaths
|
|
19
|
+
from taskpilot.core.models import Item
|
|
20
|
+
from taskpilot.core.yaml_io import dump_yaml, load_yaml
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ItemParseError",
|
|
24
|
+
"parse_item_text",
|
|
25
|
+
"parse_item_file",
|
|
26
|
+
"dump_item",
|
|
27
|
+
"write_item",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ItemParseError(Exception):
|
|
32
|
+
"""Raised when item YAML cannot be read as a mapping document.
|
|
33
|
+
|
|
34
|
+
Distinct from pydantic ``ValidationError`` (schema/field problems): this
|
|
35
|
+
covers YAML syntax errors and documents whose top level is not a mapping.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, message: str, *, path: Path | None = None):
|
|
39
|
+
self.path = path
|
|
40
|
+
super().__init__(f"{path}: {message}" if path is not None else message)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_item_text(text: str, *, path: Path | None = None) -> Item:
|
|
44
|
+
"""Parse item YAML ``text`` into an :class:`Item`.
|
|
45
|
+
|
|
46
|
+
Raises :class:`ItemParseError` for YAML syntax errors or a non-mapping
|
|
47
|
+
document, and pydantic ``ValidationError`` for schema/field problems.
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
data = load_yaml(text)
|
|
51
|
+
except yaml.YAMLError as exc:
|
|
52
|
+
raise ItemParseError(f"invalid YAML: {exc}", path=path) from exc
|
|
53
|
+
if not isinstance(data, dict):
|
|
54
|
+
raise ItemParseError(
|
|
55
|
+
f"expected a YAML mapping, got {type(data).__name__}", path=path
|
|
56
|
+
)
|
|
57
|
+
return Item.model_validate(data)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_item_file(path: Path) -> Item:
|
|
61
|
+
"""Read and parse the item file at ``path``."""
|
|
62
|
+
text = path.read_text(encoding="utf-8")
|
|
63
|
+
return parse_item_text(text, path=path)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def dump_item(item: Item) -> str:
|
|
67
|
+
"""Serialize ``item`` to canonical YAML text.
|
|
68
|
+
|
|
69
|
+
Known fields are emitted in canonical declaration order; absent optionals
|
|
70
|
+
(``None``) and empty link lists are omitted; preserved unknown fields are
|
|
71
|
+
written after known fields in sorted key order. The result is byte-stable on
|
|
72
|
+
re-serialization (F001-R3).
|
|
73
|
+
"""
|
|
74
|
+
raw = item.model_dump()
|
|
75
|
+
|
|
76
|
+
ordered: dict = {}
|
|
77
|
+
for name in Item.model_fields:
|
|
78
|
+
if name not in raw:
|
|
79
|
+
continue
|
|
80
|
+
value = raw[name]
|
|
81
|
+
if value is None:
|
|
82
|
+
continue # absent optional field is omitted
|
|
83
|
+
if name == "links":
|
|
84
|
+
non_empty = {k: v for k, v in value.items() if v}
|
|
85
|
+
if non_empty:
|
|
86
|
+
ordered[name] = non_empty
|
|
87
|
+
continue
|
|
88
|
+
ordered[name] = value
|
|
89
|
+
|
|
90
|
+
# Preserve unknown fields verbatim (including explicit nulls), after known
|
|
91
|
+
# fields, in sorted key order.
|
|
92
|
+
for key in sorted(k for k in raw if k not in Item.model_fields):
|
|
93
|
+
ordered[key] = raw[key]
|
|
94
|
+
|
|
95
|
+
return dump_yaml(ordered)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def write_item(paths: WorkspacePaths, item: Item) -> Path:
|
|
99
|
+
"""Write ``item`` to its canonical ``items/<id>.yaml`` path.
|
|
100
|
+
|
|
101
|
+
Creates the ``items/`` directory if missing, serializes the item first, then
|
|
102
|
+
publishes via a temp-file + ``os.replace`` so the target file is never left
|
|
103
|
+
truncated by a partial write.
|
|
104
|
+
"""
|
|
105
|
+
target = paths.item_file(item.id)
|
|
106
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
content = dump_item(item).encode("utf-8")
|
|
108
|
+
fd, tmp = tempfile.mkstemp(
|
|
109
|
+
dir=str(target.parent), prefix=f".{item.id}_", suffix=".tmp"
|
|
110
|
+
)
|
|
111
|
+
try:
|
|
112
|
+
os.write(fd, content)
|
|
113
|
+
except BaseException:
|
|
114
|
+
os.close(fd)
|
|
115
|
+
os.unlink(tmp)
|
|
116
|
+
raise
|
|
117
|
+
os.close(fd)
|
|
118
|
+
try:
|
|
119
|
+
os.replace(tmp, str(target))
|
|
120
|
+
except BaseException:
|
|
121
|
+
os.unlink(tmp)
|
|
122
|
+
raise
|
|
123
|
+
return target
|