@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,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
+ )
@@ -0,0 +1,5 @@
1
+ """TaskPilot core domain layer.
2
+
3
+ Business rules live here. CLI and REST adapters call into this layer directly;
4
+ filesystem and index/cache details must not leak into the domain model.
5
+ """
@@ -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