update: trellis

This commit is contained in:
YBF
2026-09-04 00:59:05 +08:00
parent 94aaad93e6
commit d674895640
27 changed files with 3354 additions and 616 deletions
+3 -3
View File
@@ -34,10 +34,10 @@ Use this skill only after task-creation consent has been given and the user is r
If no task exists yet, create one:
```bash
TASK_DIR=$(python3 ./.trellis/scripts/task.py create "<short task title>" --slug <slug>)
TASK_DIR=$(python3 ./.trellis/scripts/task.py create "<short task title>" --description "<one-line summary>" --slug <slug>)
```
Use a concise title from the user's request. Use a slug without a date prefix. `task.py create` adds the `MM-DD-` directory prefix automatically.
Use a concise title from the user's request. Both the title and `--description` must be non-empty — `create` rejects blanks, and a record with either one empty is refused at archive. Use a slug without a date prefix. `task.py create` adds the `MM-DD-` directory prefix automatically.
`task.py create` creates the default `prd.md`. Update that file with the current understanding before asking follow-up questions.
@@ -167,7 +167,7 @@ The final planning summary must show Goal, In Scope, Out of Scope, Acceptance Cr
Lightweight tasks may have only `prd.md`. Complex tasks must have `prd.md`, `design.md`, and `implement.md` before `task.py start`.
`implement.md` is not a replacement for `implement.jsonl`. On sub-agent-dispatch workflows, `implement.jsonl` and `check.jsonl` must each contain at least one real spec/research entry before `task.py start`; the seed `_example` row does not count. Inline workflows skip this JSONL gate because Phase 2 loads context through `trellis-before-dev`.
`implement.md` is not a replacement for `implement.jsonl`. On sub-agent-dispatch workflows, `implement.jsonl` and `check.jsonl` must each contain at least one real spec/research entry before `task.py start`; an empty manifest, or one holding only a legacy `_example` placeholder row, does not count. Inline workflows skip this JSONL gate because Phase 2 loads context through `trellis-before-dev`.
## PRD Convergence Pass
+1 -1
View File
@@ -31,7 +31,7 @@ Shows the Phase Index (Plan / Execute / Finish) with routing + skill mapping.
- `status=planning` + no `prd.md`**1.1** (load `trellis-brainstorm`)
- `status=planning` + `prd.md` only → decide whether the task is lightweight or complex. Lightweight can move to **1.4** review; complex returns to **1.1** to add `design.md` + `implement.md`.
- `status=planning` + complex artifacts complete + sub-agent jsonl not curated (only the seed `_example` row) → **1.3**
- `status=planning` + complex artifacts complete + sub-agent jsonl not curated (empty, or only a legacy `_example` placeholder row) → **1.3**
- `status=planning` + required artifacts complete + required jsonl curated or inline mode → **1.4** (ask for start review; only run `task.py start` after user confirms)
- `status=in_progress` + implementation not started → **2.1**
- `status=in_progress` + implementation done, not yet checked → **2.2**
@@ -47,7 +47,7 @@ In both modes, JSONL files in the task directory are the manifest for spec/resea
{"file": ".trellis/spec/backend/index.md", "reason": "Backend rules"}
```
Readers should skip seed rows without a `file` field. When configuring JSONL, the AI should include only spec/research files, not pre-register code files that will be modified.
Readers should skip rows without a `file` field (e.g. legacy `_example` placeholders). When configuring JSONL, the AI should include only spec/research files, not pre-register code files that will be modified.
## Active Task And Context Key
@@ -59,7 +59,7 @@ Use child tasks for deliverables that can move through planning, implementation,
Create new children with:
```bash
python3 ./.trellis/scripts/task.py create "<child title>" --slug <child-slug> --parent <parent-dir>
python3 ./.trellis/scripts/task.py create "<child title>" --description "<one-line summary>" --slug <child-slug> --parent <parent-dir>
```
Link or unlink existing tasks with:
@@ -101,12 +101,12 @@ Rules:
- Include spec and research files.
- Do not include code files that are about to be modified.
- Do not treat temporary conclusions in chat as the only context.
- Seed rows have no `file` field; they only prompt the AI to fill in real entries.
- Rows without a `file` field are skipped by readers. Legacy `{"_example": ...}` placeholder rows are rejected by `task.py validate` — delete them.
## Common Commands
```bash
python3 ./.trellis/scripts/task.py create "<title>" --slug <slug>
python3 ./.trellis/scripts/task.py create "<title>" --description "<one-line summary>" --slug <slug>
python3 ./.trellis/scripts/task.py start <task>
python3 ./.trellis/scripts/task.py current --source
python3 ./.trellis/scripts/task.py add-context <task> implement <file> <reason>
+47 -9
View File
@@ -248,9 +248,33 @@ class _Budget:
self.used += size
def _real_path_contained(base_real: str, target_real: str) -> bool:
"""Whether an already-realpath'd target sits under an already-realpath'd base.
ValueError on Windows when the two sit on different drives; that is
outside the base by definition, so it fails closed.
"""
try:
return os.path.commonpath([base_real, target_real]) == base_real
except ValueError:
return False
def _read_file_bytes(base_path: str, file_path: str) -> bytes | None:
"""Read raw file bytes, return None if file doesn't exist."""
full_path = os.path.join(base_path, file_path)
try:
root_real = os.path.realpath(base_path)
# `.trellis` may itself be a symlink into a store outside the repo
# (#567); its real location is a second legitimate containment base.
workflow_real = os.path.realpath(os.path.join(base_path, ".trellis"))
full_real = os.path.realpath(full_path)
if not _real_path_contained(root_real, full_real) and not (
_real_path_contained(workflow_real, full_real)
):
return None
except OSError:
return None
if os.path.exists(full_path) and os.path.isfile(full_path):
try:
with open(full_path, "rb") as f:
@@ -375,12 +399,12 @@ def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[dict]:
Schema:
{"file": "path/to/file.md", "reason": "..."}
{"file": "path/to/dir/", "type": "directory", "reason": "..."}
{"_example": "..."} # seed row — skipped (no `file` field)
{"_example": "..."} # legacy placeholder — skipped (no `file` field)
Rows without a ``file`` field (e.g. the self-describing seed line written
by ``task.py create`` before the agent has curated entries) are skipped
silently. If the resulting entry list is empty, a stderr warning is
emitted so the operator can debug missing context.
Rows without a ``file`` field (e.g. the placeholder line older Trellis
versions wrote at ``task.py create`` time) are skipped silently. If the
resulting entry list is empty, a stderr warning is emitted so the operator
can debug missing context.
Returns:
[{"file": path, "type": "file" | "directory", "reason": reason}, ...]
@@ -469,6 +493,17 @@ def get_agent_context(
"""
agent_jsonl = f"{task_dir}/{agent_type}.jsonl"
blocks = _materialize_jsonl_entries(repo_root, agent_jsonl, limits, budget)
if not blocks:
# Zero curated context reaches the model silently otherwise — the
# stderr WARN above never enters any session (#573). Put the fact in
# the prompt itself so the sub-agent compensates instead of assuming
# the spec context was complete.
return (
f"[Trellis] {agent_jsonl} has no curated entries, so no spec/research "
"context was injected. Before working, read the guidelines relevant "
"to the code you will touch under .trellis/spec/, and treat the task "
"artifacts below as the only prepared context."
)
return "\n\n".join(blocks)
@@ -1110,12 +1145,15 @@ def main():
# task's prd.md/design.md reach the model prompt, so it checks again.
try:
root_real = os.path.realpath(repo_root)
# `.trellis` may itself be a symlink into a store outside the
# repo (#567); its real location is a second legitimate base.
workflow_real = os.path.realpath(os.path.join(repo_root, ".trellis"))
task_dir_full = os.path.realpath(os.path.join(repo_root, task_dir))
# ValueError on Windows when the two sit on different drives; that
# is outside the repo by definition, so it fails closed below.
if os.path.commonpath([root_real, task_dir_full]) != root_real:
if not _real_path_contained(root_real, task_dir_full) and not (
_real_path_contained(workflow_real, task_dir_full)
):
sys.exit(0)
except (OSError, ValueError):
except OSError:
sys.exit(0)
if not os.path.exists(task_dir_full):
sys.exit(0)
+20 -7
View File
@@ -27,9 +27,12 @@ custom agent's ``hooks.userPromptSubmit`` and the IDE ``.kiro.hook``
``promptSubmit`` event; its output branch emits a plain-text breadcrumb
(Kiro adds hook stdout directly to the conversation context).
Silent exit 0 cases (no output):
Silent exit 0 case (no output):
- No .trellis/ directory found (not a Trellis project)
- task.json malformed or missing status
When a session points at a task directory whose task.json is missing, malformed,
or missing a usable status, the hook emits a task_error breadcrumb instead of
misreporting the session as having no active task.
"""
from __future__ import annotations
@@ -155,8 +158,16 @@ def _resolve_active_task(root: Path, input_data: dict):
return resolve_active_task(root, input_data, platform=_detect_platform(input_data))
def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, str]]:
"""Return (task_id, status, source) from the current active task."""
def get_active_task(
root: Path, input_data: dict
) -> tuple[str, str, str] | None:
"""Return active task data, a task-record error, or no task pointer.
``(task_id, "task_error", source)`` is distinct from ``None``: a session
pointer can exist even when its task record is missing or unreadable, and
that state needs a diagnostic breadcrumb rather than the normal ``no_task``
prompt.
"""
active = _resolve_active_task(root, input_data)
if not active.task_path:
return None
@@ -169,16 +180,18 @@ def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, st
task_json = task_dir / "task.json"
if not task_json.is_file():
return None
return task_dir.name, "task_error", active.source
try:
data = json.loads(task_json.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return None
return task_dir.name, "task_error", active.source
if not isinstance(data, dict):
return task_dir.name, "task_error", active.source
task_id = data.get("id") or task_dir.name
status = data.get("status", "")
if not isinstance(status, str) or not status:
return None
return task_dir.name, "task_error", active.source
return task_id, status, active.source
Regular → Executable
+4 -3
View File
@@ -128,9 +128,10 @@ def configure_project_encoding(project_dir: Path) -> None:
def _has_curated_jsonl_entry(jsonl_path: Path) -> bool:
"""Return True iff jsonl has at least one row with a ``file`` field.
A freshly seeded jsonl only contains a ``{"_example": ...}`` row (no
``file`` key) — that is NOT "ready". Readiness requires at least one
curated entry. Matches the contract used by ``inject-subagent-context.py``.
A newly created jsonl is empty, and older tasks may still carry a
``{"_example": ...}`` placeholder row (no ``file`` key) — neither is
"ready". Readiness requires at least one curated entry. Matches the
contract used by ``inject-subagent-context.py``.
"""
try:
for line in jsonl_path.read_text(encoding="utf-8").splitlines():
+29 -29
View File
@@ -1,11 +1,11 @@
{
"__version": 2,
"hashes": {
".agents/skills/trellis-continue/SKILL.md": "7723ccf49fbf19d8f086cacc7a080bd8be8db6fc70a32908b80f68efa318d7bf",
".agents/skills/trellis-continue/SKILL.md": "2b948434883636a1f1e2a4ad5b8a3d7dc2edc93ed05da6bca82882e8c024e22b",
".agents/skills/trellis-finish-work/SKILL.md": "161060fbcd44f787440d3a5c297a9f5223ea7774bb3021a50e376875a9ac5b2d",
".agents/skills/trellis-start/SKILL.md": "79a5ba7a2aff3c72e06d7f4cd6942dc4f4f4092dd40f9c8e94f1838024a81e4d",
".agents/skills/trellis-before-dev/SKILL.md": "00c9d1c83bc318e91b27a019bc954c2957cfad907ff20d854b596f16cad2c0f3",
".agents/skills/trellis-brainstorm/SKILL.md": "a0f226ddcb8a3e846acd2a35d121996e9ca55165ce76202095d0b65e2b48a5e8",
".agents/skills/trellis-brainstorm/SKILL.md": "17d9bf209730c14f584eb97f38198d0683ad3f8da982d9a6e6d4e84c853f9a94",
".agents/skills/trellis-break-loop/SKILL.md": "f5a93699832f29dee443b53c135a7459519b371f689af191d15c29e8ee5c7bde",
".agents/skills/trellis-check/SKILL.md": "dfb0600e95c19c7a83200465b6a92a5514ec9778ba4083efa881bed541cc74a8",
".agents/skills/trellis-update-spec/SKILL.md": "003ce08a3404aeb50998029392c4d4e57b626edf526d3ebd585032bb92dcbb96",
@@ -25,12 +25,12 @@
".agents/skills/trellis-meta/references/customize-local/change-workflow.md": "43fa780a2ca580de121b10893d49b99f978873deebbf45008c466e5ac6651519",
".agents/skills/trellis-meta/references/customize-local/overview.md": "ce8f09e9f93ce9a48500763fb3a4db2b3908a5fbf4f985ab71dacebb404cf8f4",
".agents/skills/trellis-meta/references/local-architecture/bundled-skills.md": "aa6a0bf83060205ee4ea621c467fb900a7db06b4476a4ad472cc4e248c887389",
".agents/skills/trellis-meta/references/local-architecture/context-injection.md": "8497289bf333b3aa456f317039d1239b7ece79254aa0eb62cfc647714c866084",
".agents/skills/trellis-meta/references/local-architecture/context-injection.md": "b1ebaac467bf0195e2a21f2f8492a8b7c91ff3e0f9312f853468cda0255df1c7",
".agents/skills/trellis-meta/references/local-architecture/generated-files.md": "7eb2d452eddb4f4226f7578c2ec6d5ee0434ed172ba4c36107cc8bdff7554dc6",
".agents/skills/trellis-meta/references/local-architecture/multi-agent-channel.md": "56e5070474aeca872e2d70c46feea5aaafecd3d3ec052c3f7b1877358dca62e9",
".agents/skills/trellis-meta/references/local-architecture/overview.md": "50638fd9eaaba2e0edf2f2a84d920578d5b2fb7934031b174e417d3dc510b2a6",
".agents/skills/trellis-meta/references/local-architecture/spec-system.md": "b8d8a6a0888b44a232c8f50161b9e20e903cf621ad7be4021715ab6fab226f47",
".agents/skills/trellis-meta/references/local-architecture/task-system.md": "2b561d49c390f7d0db5391912946133be4bf73189231e2b8cc9afa1c5ac6165a",
".agents/skills/trellis-meta/references/local-architecture/task-system.md": "b154568e74f1c738a51633df9f2503f416b3afa5f309325224422162e0b707e4",
".agents/skills/trellis-meta/references/local-architecture/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6",
".agents/skills/trellis-meta/references/local-architecture/workspace-memory.md": "e6427b46aba744563c2444b30df4043cd856561b7709ec2dece26095416421fd",
".agents/skills/trellis-meta/references/platform-files/agents.md": "b806b1f0de6dfc74720014aee15ede767a58925b2b3b964f2e557ed39a65bbc0",
@@ -47,46 +47,46 @@
".agents/skills/trellis-spec-bootstrap/references/spec-task-planning.md": "ef493d028c3b0807a8a534bb71fb92a68129f273db763ad27ceb464a522e799d",
".agents/skills/trellis-spec-bootstrap/references/spec-writing.md": "e9800fe9ed4a4cd87062ea1829cf2caa8d170ec15e141678a6a30e74c497f47d",
".agents/skills/trellis-spec-bootstrap/SKILL.md": "97bfa68c06cebb558eb4464bc1b81f7d2d56040d75baa8de1ee5ad90cca0196a",
".codex/agents/trellis-check.toml": "206fd96a8aa17e95ed344cc435661d597093ece571671f298ca64490422fffe0",
".codex/agents/trellis-implement.toml": "388fb8f39797e0ee6cf4db447c859c79b4ac15f531f7e1e3c68c1d1d71a1c188",
".codex/agents/trellis-research.toml": "4435ce73197ba1d29d40359a3279b6423f7e4f559a449f934c016808090066c4",
".codex/hooks/session-start.py": "14de3be1cf6eb9c9feba348d8998b407f3837d6c0756b74210c9200543440677",
".codex/hooks/inject-subagent-context.py": "7c2c5640445ea0d98ce6a13126ea105ec4b02ca661ef4a7711240b595efefbbf",
".codex/hooks/inject-workflow-state.py": "89cef2b197dd61a731b946114207d2601b240058941ccf28622ee211d9c62eb3",
".codex/agents/trellis-check.toml": "df5f89f75910d838249b53648d552801a9fa48e10ebcbd08c4f3d454295d0b20",
".codex/agents/trellis-implement.toml": "3a8374e8fb1202ef46d4e51fbcababd6ffbb5cdc197467228c431df4f08af909",
".codex/agents/trellis-research.toml": "0a9abbaead04f4490dfdc69736969ca274ba49cad24d845729974230d7a54ebe",
".codex/hooks/session-start.py": "efdd015c7c4687227ce34a84ac6249d5554c704f737e00d4844c9284ddcf696f",
".codex/hooks/inject-subagent-context.py": "9b68be8d08f85e824e39d901a29f14d38346dbeeda15880e4cba03076f2a8caa",
".codex/hooks/inject-workflow-state.py": "71ef1aa1ed1051a697cae3c9ebffa518958a3bbd4d147434f382348829915f8c",
".codex/hooks.json": "85a58ba7cdf1e19e7f75ddcc64e5680180c487ca266a74bd5005f31abeee2e02",
".codex/config.toml": "9f2d20e28f0bc9c886312eca3ad3bba41533ef4615aaaafe25e98152302267bb",
"AGENTS.md": "6cacfe99748b435d0660c2463c697bc323d53798aecf3492283ca8eac1b29682",
"AGENTS.md": "9f34b3b9f25fe9077061aadedab1350160140bc8c8c8ba6a8ddcd99c0579dd25",
".trellis/agents/check.md": "edb4f57361407249a53bf5998ebf91c40d2b969e826a2c5e1b4e813a08bcb175",
".trellis/agents/implement.md": "66e25ad046c94869442834bc3cdfbd5a9a7412d3ff54561d64d2886552c27e87",
".trellis/config.yaml": "937031c9e4a9b35cb16c1ab84a6be18b7e4be7c1bf31bf1d19cfbf1e4bf73322",
".trellis/config.yaml": "a966e6d374e9e6ff283cf761ccd99631323ee1754856cef51ad154ca0afb9dfa",
".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c",
".trellis/scripts/add_session.py": "876dad478edf70db59acccaae9cb4db646a155681f730bd99af48de72ddc9881",
".trellis/scripts/common/__init__.py": "3d5e9347141f0296319a5beb29d69ae714c5a474b9078caeb3edd7c5f6562e22",
".trellis/scripts/common/active_task.py": "bd15c5ee7810814dad915d898889323b3ea97fde92777b053b7ae734d56768bc",
".trellis/scripts/add_session.py": "f61a452ec5f7eece8d7e0f0dd56ce5851166cbc83ca4822e30acbb36eeb5896f",
".trellis/scripts/common/__init__.py": "f258cd873681dc5a4d71db0dd71d10fd78656de55bec96418d6702503c16e87d",
".trellis/scripts/common/active_task.py": "f52715bbcc0677607055903e799674d7d50d6fcbab413a8f453c4e4efc954726",
".trellis/scripts/common/cli_adapter.py": "5d6bd9d6f5c631e7e792db7dd343351317f9643bc87b73a9a98abd51cefb4307",
".trellis/scripts/common/config.py": "8d2e5f8ccfcd5f622cd2af002aa761f3d3ffcc653182fefb2268afd102e77bca",
".trellis/scripts/common/developer.py": "f5f833123abe68890171b4da825a324216d24913f6b5ad9245afc556424ffd7b",
".trellis/scripts/common/git.py": "6fc5845d0104dd506ebd8b366a24cb4b1e3d8777e4e6acc12ea15c9d8e2662f2",
".trellis/scripts/common/config.py": "9a042d6f0e25a33a567510760e87c1e389af14a41faf1daeb1190a00f3df7f5e",
".trellis/scripts/common/developer.py": "a0f61bb58063e2f4cb357bcb2c22fd5a6f1241286398beaa7130b2a8994a4afa",
".trellis/scripts/common/git.py": "5605b71662e524b47632030d9db9245121a5cba85b5521eeb524ed464d840ff5",
".trellis/scripts/common/git_context.py": "fa30ced454f1a91ffc9f8b2abeb32225e3447cbdc90bad783797374eba07265d",
".trellis/scripts/common/io.py": "75648caae03d5b1107d7aeccaa785d133b25762266e54a520d90ca8c76b43bdb",
".trellis/scripts/common/io.py": "5dd8b9e297125ff58543a9bd4790c03ae1aca1d37b9e2ca60c4ffb47601c1cbb",
".trellis/scripts/common/log.py": "471df6895cfac80f995edebbf9974f6b7440634b7a688f28b8331c868bc0f3cf",
".trellis/scripts/common/packages_context.py": "efe158d7c99c2268851d0216fbb08de22836e418a8dbeb73575b8cc249eed7b7",
".trellis/scripts/common/paths.py": "c26b75bc211b290e7c21ec96fb1db36f282b3ecbb7d2c81475d258a48ea55fb8",
".trellis/scripts/common/safe_commit.py": "baa5c82324eb62154374ec63394ecdc8609bb37d93892e3bcb88f452bb7d6446",
".trellis/scripts/common/paths.py": "d8586d4d3f9bb2dd46b7f11b0f038b5340d283f41b6fa2c76bb8235c13bbb0b7",
".trellis/scripts/common/safe_commit.py": "262ad740552279fe6ec6a2a9865b2965ce2448577cf973a4d702dfe2f5f91f04",
".trellis/scripts/common/session_context.py": "3379ef1766e4e5ca77cbb7c040dbba3883fcca2548580299e3b38dbf22f4f7d5",
".trellis/scripts/common/task_context.py": "6fc3abb9e483043bc8cc3477ae48e5503399402009fbfc58db80809472c5690b",
".trellis/scripts/common/task_context.py": "245e654713e6030f9e7b5cee474efc06343ecc0d7f66f6455098874239355ce3",
".trellis/scripts/common/task_queue.py": "0be61f713462b1fe4574927c82fc4704e678afe72dcb9813543aedf2f9e9e0c5",
".trellis/scripts/common/task_store.py": "9b05a40113439e2841271f2fc1e616fbef0b6929e7c064dd41350658e4ed203e",
".trellis/scripts/common/task_utils.py": "07a599c028b2f7aa4014f56702374aade98c2d1a6ea87b314098e5e86cacf7fe",
".trellis/scripts/common/tasks.py": "4436a8b0b53c270a35989e26d9dbd92669408c6562d88c02083a404562da85fe",
".trellis/scripts/common/trellis_config.py": "e282e897183e3ec2f4e6e56349431946e5f98c1c31d3eca4de7fc44e1383a7bf",
".trellis/scripts/common/task_store.py": "5712b0405e1fd0c2b7be5e5fbd420fa6eec2c49c8e4cf2b8fc69b7d2e4aeb016",
".trellis/scripts/common/task_utils.py": "35f544317a95e98d05fe089794071482175739e8c1f3b3b0228dae48602ffe90",
".trellis/scripts/common/tasks.py": "87aca6b73ec4524450cfff8e2ae561b1f2e53bd62a185f036a7a2d484d90c8a2",
".trellis/scripts/common/trellis_config.py": "f1fc705ec463b0b9c163672dc9e50f10d29da967775a1900429205f7c4fcb51c",
".trellis/scripts/common/types.py": "9962081cc2608fb9d1deb32c6880e336f62cdca6b338e7ae813304701e155ee9",
".trellis/scripts/common/workflow_phase.py": "79ee522de20246acf1e2c222e8ad180ad25aaec7fec98214a93d9e81b350d9a8",
".trellis/scripts/common/workflow_phase.py": "146b69c1ec0e628aeb720d3e34d8333f16050225037fe4233da9bbc4d4ac99c1",
".trellis/scripts/get_context.py": "ca5bf9e90bdb1d75d3de182b95f820f9d108ab28793d29097b24fd71315adcf5",
".trellis/scripts/get_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f",
".trellis/scripts/hooks/linear_sync.py": "e09cc4ce4699aada908808718698f33f705a3edf55c4dcf8f777ad892f80ca79",
".trellis/scripts/init_developer.py": "f9e6c0d882406e81c8cd6b1c5abb204b0befc0069ff89cf650cd536a80f8c60e",
".trellis/scripts/task.py": "152d7298db25b86756583faaba763eacd8611c0070e8f1c176b8257d687102b9",
".trellis/workflow.md": "c694bb7901d48f224ccad940178cff81e28dda46e6e5b24bad67c83970d67808"
".trellis/scripts/task.py": "b415e0a2dbc9e8cba1ada0c7997999aaaaea0eec96d3419db640830e88e84cfe",
".trellis/workflow.md": "eb4508a4f6792f3d53705c1f9e7d21807dabd774543bd7b01f56372a704f6ad0"
}
}
+1 -1
View File
@@ -1 +1 @@
0.6.15
0.6.16
File diff suppressed because it is too large Load Diff
+2
View File
@@ -66,6 +66,8 @@ from .paths import (
FILE_CURRENT_TASK,
FILE_TASK_JSON,
FILE_JOURNAL_PREFIX,
ENV_DEVELOPER,
DEVELOPER_HINT,
get_repo_root,
get_developer,
check_developer,
+106 -14
View File
@@ -9,7 +9,6 @@ session key there is no active task.
from __future__ import annotations
import hashlib
import json
import os
import re
import sys
@@ -19,6 +18,8 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from .io import read_json as _io_read_json, write_json as _io_write_json
DIR_WORKFLOW = ".trellis"
DIR_TASKS = "tasks"
DIR_RUNTIME = ".runtime"
@@ -209,29 +210,44 @@ def resolve_task_ref(task_ref: str, repo_root: Path) -> Path | None:
if not normalized:
return None
try:
root = repo_root.resolve()
except OSError:
return None
path_obj = Path(normalized)
if path_obj.is_absolute():
candidate = path_obj
elif normalized.startswith(f"{DIR_WORKFLOW}/"):
candidate = repo_root / path_obj
candidate = root / path_obj
else:
candidate = repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj
candidate = root / DIR_WORKFLOW / DIR_TASKS / path_obj
# Both sides are resolved because repo_root itself may sit behind a
# symlink (/tmp on macOS does), and resolve() is what collapses `..`
# instead of leaving it for a lexical relative_to() to wave through.
try:
resolved = candidate.resolve()
root = repo_root.resolve()
workflow_real = (root / DIR_WORKFLOW).resolve()
except OSError:
return None
try:
resolved.relative_to(root)
return resolved
except ValueError:
pass
# `.trellis` may itself be a symlink into a store outside the repo (#567).
# The workflow dir's own real location is then a second legitimate
# containment base; a ref that escapes BOTH bases is still refused. Map
# back to the in-repo (lexical) form so callers store a repo-relative ref.
try:
rel = resolved.relative_to(workflow_real)
except ValueError:
return None
return resolved
return root / DIR_WORKFLOW / rel
def _runtime_sessions_dir(repo_root: Path) -> Path:
@@ -540,23 +556,24 @@ def resolve_context_key(
def _read_json(path: Path) -> dict[str, Any] | None:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
"""Tolerant read of a session runtime file, non-objects included."""
data = _io_read_json(path)
return data if isinstance(data, dict) else None
def _write_json(path: Path, data: dict[str, Any]) -> bool:
"""Write a session runtime file atomically, creating the runtime dir.
Routes through io.write_json so session pointers get the same
temp-file-then-rename treatment as task.json (#429). A plain write_text
truncates the target first, so a crash mid-write would leave a session
file that reads back as no active task.
"""
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
return True
except OSError:
return False
return _io_write_json(path, data)
def _canonical_task_ref(task_path: str, repo_root: Path) -> str | None:
@@ -576,6 +593,39 @@ def _canonical_task_ref(task_path: str, repo_root: Path) -> str | None:
return None
def _relative_task_ref(task_path: str, repo_root: Path) -> str:
"""Repo-relative posix ref for a task path that need not exist.
`_canonical_task_ref` resolves through the filesystem and so refuses a task
directory that has been moved away. Rename needs to name both sides of the
move, one of which is always absent.
"""
normalized = normalize_task_ref(task_path)
if not normalized:
return ""
candidate = Path(normalized)
if not candidate.is_absolute():
return normalized
try:
resolved = candidate.resolve()
root = repo_root.resolve()
workflow_real = (root / DIR_WORKFLOW).resolve()
except OSError:
return ""
try:
return resolved.relative_to(root).as_posix()
except ValueError:
pass
# Same dual-base containment as resolve_task_ref: a path through a
# symlinked `.trellis` (#567) maps back to its in-repo form; anything
# outside both bases is refused rather than stored as an absolute pointer.
try:
rel = resolved.relative_to(workflow_real)
except ValueError:
return ""
return (Path(DIR_WORKFLOW) / rel).as_posix()
def _active_from_ref(
task_ref: str | None,
repo_root: Path,
@@ -755,6 +805,48 @@ def clear_task_from_sessions(task_path: str, repo_root: Path) -> int:
return cleared
def repoint_task_in_sessions(old_path: str, new_path: str, repo_root: Path) -> int:
"""Move every session pointer from `old_path` to `new_path`.
Rename is the one lifecycle step where the task survives under a different
name, so clearing the pointers (what archive does) would be wrong: the user
would silently lose their active task and have to run `task.py start`
again to get context injection back. Repointing keeps the session valid
across the rename.
"""
# Not `_canonical_task_ref`: the caller repoints *after* moving the
# directory, so `old_path` no longer exists and canonicalization — which
# requires an existing directory — would return None for exactly the ref we
# need to match.
target = _relative_task_ref(old_path, repo_root)
replacement = _relative_task_ref(new_path, repo_root)
if not target or not replacement:
return 0
moved = 0
sessions_dir = _runtime_sessions_dir(repo_root)
if not sessions_dir.is_dir():
return moved
for session_path in sorted(sessions_dir.glob("*.json")):
context = _read_json(session_path)
if not context:
continue
current = _string_value(context.get("current_task"))
if not current:
continue
current_ref = _canonical_task_ref(current, repo_root) or _relative_task_ref(
current, repo_root
)
if current_ref != target:
continue
context["current_task"] = replacement
if _write_json(session_path, context):
moved += 1
return moved
def get_current_task_source(
repo_root: Path,
platform_input: dict[str, Any] | None = None,
+82 -169
View File
@@ -11,157 +11,11 @@ import sys
from pathlib import Path
from .paths import DIR_WORKFLOW, get_repo_root
from .trellis_config import parse_simple_yaml
# =============================================================================
# YAML Simple Parser (no dependencies)
# =============================================================================
def _unquote(s: str) -> str:
"""Remove exactly one layer of matching surrounding quotes.
Unlike str.strip('"'), this only removes the outermost pair,
preserving any nested quotes inside the value.
Examples:
_unquote('"hello"') -> 'hello'
_unquote("'hello'") -> 'hello'
_unquote('"echo \\'hi\\'"') -> "echo 'hi'"
_unquote('hello') -> 'hello'
_unquote('"hello\\'') -> '"hello\\'' (mismatched, unchanged)
"""
if len(s) >= 2 and s[0] == s[-1] and s[0] in ('"', "'"):
return s[1:-1]
return s
def _strip_inline_comment(value: str) -> str:
"""Strip ` # …` inline comments while preserving `#` inside quoted strings.
YAML treats ` #` (space-hash) as a comment opener; bare `#` inside a token
is part of the value. Quoted strings are immune.
Mirrors :func:`common.trellis_config._strip_inline_comment` so both
parsers handle ``key: value # comment`` identically.
"""
in_quote: str | None = None
for idx, ch in enumerate(value):
if in_quote:
if ch == in_quote:
in_quote = None
continue
if ch in ('"', "'"):
in_quote = ch
continue
if ch == "#" and (idx == 0 or value[idx - 1].isspace()):
return value[:idx]
return value
def parse_simple_yaml(content: str) -> dict:
"""Parse simple YAML with nested dict support (no dependencies).
Supports:
- key: value (string)
- key: (followed by list items)
- item1
- item2
- key: (followed by nested dict)
nested_key: value
nested_key2:
- item
Uses indentation to detect nesting (2+ spaces deeper = child).
Args:
content: YAML content string.
Returns:
Parsed dict (values can be str, list[str], or dict).
"""
lines = content.splitlines()
result: dict = {}
_parse_yaml_block(lines, 0, 0, result)
return result
def _parse_yaml_block(
lines: list[str], start: int, min_indent: int, target: dict
) -> int:
"""Parse a YAML block into target dict, returning next line index."""
i = start
current_list: list | None = None
while i < len(lines):
line = lines[i]
stripped = line.strip()
# Skip empty lines and comments
if not stripped or stripped.startswith("#"):
i += 1
continue
# Calculate indentation
indent = len(line) - len(line.lstrip())
# If dedented past our block, we're done
if indent < min_indent:
break
if stripped.startswith("- "):
if current_list is not None:
current_list.append(_unquote(stripped[2:].strip()))
i += 1
elif ":" in stripped:
key, _, value = stripped.partition(":")
key = key.strip()
value = _strip_inline_comment(value).strip()
was_quoted = len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'")
value = _unquote(value)
current_list = None
if value or was_quoted:
# key: value (an explicit quoted "" is a value, not "no value")
target[key] = value
i += 1
else:
# key: (no value) — peek ahead to determine list vs nested dict
next_i, next_line = _next_content_line(lines, i + 1)
if next_i >= len(lines):
target[key] = {}
i = next_i
elif next_line.strip().startswith("- "):
# It's a list
current_list = []
target[key] = current_list
i += 1
else:
next_indent = len(next_line) - len(next_line.lstrip())
if next_indent > indent:
# It's a nested dict
nested: dict = {}
target[key] = nested
i = _parse_yaml_block(lines, i + 1, next_indent, nested)
else:
# Empty value, same or less indent follows
target[key] = {}
i += 1
else:
i += 1
return i
def _next_content_line(lines: list[str], start: int) -> tuple[int, str]:
"""Find the next non-empty, non-comment line."""
i = start
while i < len(lines):
stripped = lines[i].strip()
if stripped and not stripped.startswith("#"):
return i, lines[i]
i += 1
return i, ""
# The YAML subset parser lives in trellis_config.py — it imports nothing from
# this package, so hooks can load it as a single standalone file. Two byte-
# equivalent copies is a drift hazard, not a feature.
# Defaults
@@ -173,13 +27,46 @@ DEFAULT_CODEX_DISPATCH_MODE = "auto"
CONFIG_FILE = "config.yaml"
def _is_true_config_value(value: object) -> bool:
"""Return True when a config value represents an enabled flag."""
TRUE_CONFIG_VALUES = ("true", "yes", "1", "on")
FALSE_CONFIG_VALUES = ("false", "no", "0", "off")
def coerce_config_bool(
value: object,
default: bool,
label: str,
) -> bool:
"""Coerce a config value to a bool, warning on anything unrecognized.
The parser stores every value as a string, so ``git: yes`` arrives as
``"yes"``. Every boolean config key goes through this one helper: an
accepted-here/rejected-there split means a user writing a perfectly
reasonable YAML boolean silently gets the opposite branch.
Args:
value: Raw value from the parsed config.
default: Returned when the value is unrecognized.
label: Config key name, used in the warning.
"""
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() == "true"
return False
s = str(value).strip().lower()
if s in TRUE_CONFIG_VALUES:
return True
if s in FALSE_CONFIG_VALUES:
return False
print(
f"[WARN] invalid {label} value: {value!r}; using {str(default).lower()} (default)",
file=sys.stderr,
)
return default
def _is_true_config_value(value: object, label: str = "config flag") -> bool:
"""Return True when a config value represents an enabled flag."""
if value is None:
return False
return coerce_config_bool(value, False, label)
def _get_config_path(repo_root: Path | None = None) -> Path:
@@ -189,13 +76,27 @@ def _get_config_path(repo_root: Path | None = None) -> Path:
def _load_config(repo_root: Path | None = None) -> dict:
"""Load and parse config.yaml. Returns empty dict on any error."""
"""Load and parse config.yaml. Returns empty dict on any error.
Fail-open, matching ``trellis_config.read_trellis_config``: a malformed
config must not take down ``task.py create``. A parse failure is reported
once on stderr so it is not invisible.
"""
config_file = _get_config_path(repo_root)
try:
content = config_file.read_text(encoding="utf-8")
return parse_simple_yaml(content)
except (OSError, IOError):
return {}
try:
parsed = parse_simple_yaml(content, source=str(config_file))
except Exception as e:
print(
f"[WARN] could not parse {config_file}: {type(e).__name__}: {e}; "
"using defaults",
file=sys.stderr,
)
return {}
return parsed if isinstance(parsed, dict) else {}
def get_session_commit_message(repo_root: Path | None = None) -> str:
@@ -231,18 +132,9 @@ def get_session_auto_commit(repo_root: Path | None = None) -> bool:
"""
config = _load_config(repo_root)
raw = config.get("session_auto_commit", DEFAULT_SESSION_AUTO_COMMIT)
if isinstance(raw, bool):
return raw
s = str(raw).strip().lower()
if s in ("true", "yes", "1", "on"):
return True
if s in ("false", "no", "0", "off"):
return False
print(
f"[WARN] invalid session_auto_commit value: {raw!r}; using true (default)",
file=sys.stderr,
return coerce_config_bool(
raw, DEFAULT_SESSION_AUTO_COMMIT, "session_auto_commit"
)
return DEFAULT_SESSION_AUTO_COMMIT
def get_codex_dispatch_mode(repo_root: Path | None = None) -> str:
@@ -374,16 +266,37 @@ def get_hooks(event: str, repo_root: Path | None = None) -> list[str]:
event: Event name (e.g. "after_create", "after_archive").
repo_root: Repository root path.
A hook the user believes is installed and which silently never runs is the
worst outcome for this feature, so a declared-but-unusable shape warns
instead of returning an empty list quietly.
Returns:
List of shell commands to execute, empty if none configured.
"""
config = _load_config(repo_root)
hooks = config.get("hooks")
if hooks is None:
return []
if not isinstance(hooks, dict):
print(
f"[WARN] ignoring `hooks` in config.yaml: expected a mapping of "
f"event -> list of commands, got {hooks!r}",
file=sys.stderr,
)
return []
commands = hooks.get(event)
if commands is None:
return []
if isinstance(commands, list):
return [str(c) for c in commands]
# `after_create: echo hi` instead of a `- ` list — parses fine, registers
# nothing.
print(
f"[WARN] ignoring hook `{event}` in config.yaml: expected a list of "
f"commands, got {commands!r}. Write it as:\n"
f" hooks:\n {event}:\n - {commands}",
file=sys.stderr,
)
return []
@@ -471,7 +384,7 @@ def get_git_packages(repo_root: Path | None = None) -> dict[str, str]:
return {
name: cfg.get("path", name)
for name, cfg in packages.items()
if _is_true_config_value(cfg.get("git"))
if _is_true_config_value(cfg.get("git"), f"packages.{name}.git")
}
+2
View File
@@ -15,6 +15,7 @@ from datetime import datetime
from pathlib import Path
from .paths import (
DEVELOPER_HINT,
DIR_WORKFLOW,
DIR_WORKSPACE,
DIR_TASKS,
@@ -160,6 +161,7 @@ def ensure_developer(repo_root: Path | None = None) -> None:
if not check_developer(repo_root):
print("Error: Developer not initialized.", file=sys.stderr)
print(f"Run: python3 ./{DIR_WORKFLOW}/scripts/init_developer.py <your-name>", file=sys.stderr)
print(DEVELOPER_HINT, file=sys.stderr)
sys.exit(1)
+129
View File
@@ -7,9 +7,25 @@ Single source of truth for running git commands across all Trellis scripts.
from __future__ import annotations
import subprocess
import time
from pathlib import Path
# Bounded retry for transient `.git/index.lock` contention. Another process
# (IDE git integration, status daemon, a concurrent Trellis session) can hold
# the lock for a fraction of a second; three attempts spread over ~1.5s ride
# that out without making a genuinely stuck lock hang the command. One sleep
# per retry, so the attempt count follows the backoff tuple.
INDEX_LOCK_RETRY_BACKOFF = (0.5, 1.0)
INDEX_LOCK_RETRY_ATTEMPTS = len(INDEX_LOCK_RETRY_BACKOFF) + 1
# Whether a checkout is a linked worktree cannot change while a script runs, and
# the answer costs two subprocesses. Developer-identity resolution asks several
# times per command whenever the local `.developer` file is absent, so memoize.
_CACHE_MISS = object()
_MAIN_WORKTREE_CACHE: dict[Path, Path | None] = {}
def run_git(
args: list[str],
cwd: Path | None = None,
@@ -38,6 +54,49 @@ def run_git(
return 1, "", str(e)
def stderr_indicates_index_lock(stderr: str) -> bool:
"""git failed because another process holds `.git/index.lock`."""
if not stderr:
return False
return "index.lock" in stderr.lower()
def run_git_retry_index_lock(
args: list[str],
cwd: Path | None = None,
timeout: float | None = None,
) -> tuple[int, str, str]:
"""Run a git command, retrying only while `.git/index.lock` is held.
Any other non-zero exit returns immediately — a retry loop around real
failures (bad path, nothing to commit, hook rejection) just delays the
error. Returns the last (returncode, stdout, stderr).
"""
rc, out, err = run_git(args, cwd=cwd, timeout=timeout)
attempt = 1
while (
rc != 0
and attempt < INDEX_LOCK_RETRY_ATTEMPTS
and stderr_indicates_index_lock(err)
):
time.sleep(INDEX_LOCK_RETRY_BACKOFF[attempt - 1])
attempt += 1
rc, out, err = run_git(args, cwd=cwd, timeout=timeout)
return rc, out, err
def index_lock_path(repo_root: Path) -> str:
"""Path of the lock file git is contending on, for diagnostics.
Asks git so worktrees and `GIT_DIR` setups name the real file rather
than a `.git/` guess that does not exist there.
"""
rc, out, _ = run_git(["rev-parse", "--git-path", "index.lock"], cwd=repo_root)
if rc == 0 and out.strip():
return out.strip()
return ".git/index.lock"
def resolve_default_branch(repo_root: Path) -> str | None:
"""Resolve the repository's default branch (origin/HEAD target).
@@ -63,6 +122,76 @@ def resolve_default_branch(repo_root: Path) -> str | None:
return None
def current_branch_name(repo_root: Path) -> str | None:
"""Return the checked-out branch name, or None when there isn't one.
Empty output covers detached HEAD and "not a git repository" alike, and
callers treat both the same way: there is no branch worth recording.
"""
rc, out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
if rc != 0:
return None
return out.strip() or None
def has_git_remote(repo_root: Path) -> bool:
"""Whether the repository has at least one configured remote."""
rc, out, _ = run_git(["remote"], cwd=repo_root)
return rc == 0 and bool(out.strip())
def main_worktree_root(repo_root: Path) -> Path | None:
"""Root of the main working tree when `repo_root` is a linked worktree.
Returns None in the main working tree itself, outside a git repository, and
for a linked worktree of a bare repository (no main checkout to point at).
`git worktree list --porcelain` reports the main working tree as its first
record, so git identifies it rather than this code deriving it from the
`.git` layout. Deriving it — taking the parent of `--git-common-dir` — is
wrong for a bare repository that happens to sit inside an unrelated
checkout (`~/repos/project.git` under a `~/repos` that is itself a repo):
the parent is a real checkout with a real `.developer`, so the guess is
indistinguishable from a hit and identity leaks across repositories.
"""
cached = _MAIN_WORKTREE_CACHE.get(repo_root, _CACHE_MISS)
if cached is not _CACHE_MISS:
return cached # type: ignore[return-value]
result = _probe_main_worktree_root(repo_root)
_MAIN_WORKTREE_CACHE[repo_root] = result
return result
def _probe_main_worktree_root(repo_root: Path) -> Path | None:
rc_list, listing, _ = run_git(["worktree", "list", "--porcelain"], cwd=repo_root)
rc_top, toplevel, _ = run_git(["rev-parse", "--show-toplevel"], cwd=repo_root)
if rc_list != 0 or rc_top != 0:
return None
lines = listing.splitlines()
if not lines or not lines[0].startswith("worktree "):
return None
# Records are blank-line separated; a `bare` attribute on the first one
# means the "main working tree" is a bare repo with nothing to inherit.
for line in lines[1:]:
if not line.strip():
break
if line.strip() == "bare":
return None
try:
main_root = Path(lines[0][len("worktree ") :].strip()).resolve()
current_root = Path(toplevel.strip()).resolve()
except (OSError, ValueError):
return None
if main_root == current_root:
return None
return main_root
def branch_exists_locally(branch: str, repo_root: Path) -> bool:
"""Check whether a local branch ref exists in the repository."""
if not branch:
+105 -6
View File
@@ -1,8 +1,9 @@
"""
JSON file I/O utilities.
File I/O utilities.
Provides read_json and write_json as the single source of truth
for JSON file operations across all Trellis scripts.
Provides read_json / write_json as the single source of truth for JSON file
operations, plus write_text_atomic for the Markdown state files (journal,
index.md) that carry durable session state.
"""
from __future__ import annotations
@@ -13,17 +14,96 @@ import tempfile
from pathlib import Path
JSON_READ_MISSING = "missing"
JSON_READ_INVALID = "invalid"
JSON_READ_UNREADABLE = "unreadable"
JSON_READ_NOT_OBJECT = "not-object"
JSON_READ_EMPTY = "empty"
JSON_READ_UNDECODABLE = "undecodable"
def read_json(path: Path) -> dict | None:
"""Read and parse a JSON file.
Returns None if the file doesn't exist, is invalid JSON, or can't be read.
Use this for optional reads only — a caller that is about to overwrite the
file, or that must tell a parse error from a permissions error, wants
read_json_checked instead.
"""
try:
return json.loads(path.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
except (FileNotFoundError, json.JSONDecodeError, OSError, UnicodeDecodeError):
# UnicodeDecodeError is not an OSError. Without it here a non-UTF-8
# session file raises out of a tolerant read, so the hook path fails
# instead of degrading to "no active task".
return None
def read_json_checked(path: Path) -> tuple[dict | None, str | None]:
"""Read a JSON object, keeping the ways it can fail distinguishable.
Returns ``(data, None)`` on success, or ``(None, reason)`` where reason is
one of the ``JSON_READ_*`` constants. An empty object counts as a failure:
a state file that parses to ``{}`` carries none of the fields callers read,
and treating it as success would silently rebuild it from defaults.
"""
try:
text = path.read_text(encoding="utf-8")
except FileNotFoundError:
return None, JSON_READ_MISSING
except UnicodeDecodeError:
# Not an OSError, so it escaped both handlers and surfaced as a
# traceback. The point of this reader is that every failure mode stays
# nameable, and "not valid UTF-8" is a different repair from
# "not valid JSON".
return None, JSON_READ_UNDECODABLE
except OSError:
return None, JSON_READ_UNREADABLE
try:
data = json.loads(text)
except json.JSONDecodeError:
return None, JSON_READ_INVALID
if not isinstance(data, dict):
return None, JSON_READ_NOT_OBJECT
if not data:
return None, JSON_READ_EMPTY
return data, None
def describe_json_read_failure(path: Path, reason: str | None) -> tuple[str, str]:
"""Return ``(what happened, what to do)`` for a read_json_checked reason."""
if reason == JSON_READ_MISSING:
return (f"{path}: file not found", "Pass an existing task directory, or create the task first.")
if reason == JSON_READ_UNREADABLE:
return (
f"{path}: could not be read (permission denied or I/O error)",
"Check the file and directory permissions, then retry.",
)
if reason == JSON_READ_INVALID:
return (
f"{path}: not valid JSON",
f"Fix the syntax (e.g. `python3 -m json.tool {path}`), then retry.",
)
if reason == JSON_READ_NOT_OBJECT:
return (
f"{path}: top level is not a JSON object",
"Restore the file to a JSON object ({ ... }), then retry.",
)
if reason == JSON_READ_EMPTY:
return (
f"{path}: contains an empty JSON object",
"Restore the task fields (or recreate the task), then retry.",
)
if reason == JSON_READ_UNDECODABLE:
return (
f"{path}: not valid UTF-8 text",
"Re-save the file as UTF-8 (or restore it from git), then retry.",
)
return (f"{path}: could not be loaded", "Inspect the file, then retry.")
def write_json(path: Path, data: dict) -> bool:
"""Write dict to JSON file with pretty formatting.
@@ -34,7 +114,19 @@ def write_json(path: Path, data: dict) -> bool:
Returns True on success, False on error.
"""
payload = json.dumps(data, indent=2, ensure_ascii=False)
return write_text_atomic(path, json.dumps(data, indent=2, ensure_ascii=False))
def write_text_atomic(path: Path, text: str) -> bool:
"""Write text to a file atomically (temp in same dir, then replace).
The same never-truncate-in-place guarantee as :func:`write_json`, for the
Markdown state files that hold durable session state (journal files,
index.md). A crash or Ctrl-C mid-write leaves the previous content intact
instead of a half-written record that no retry can classify.
Returns True on success, False on error.
"""
try:
fd, tmp = tempfile.mkstemp(
dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp"
@@ -50,7 +142,7 @@ def write_json(path: Path, data: dict) -> bool:
os.close(fd)
raise
with f:
f.write(payload)
f.write(text)
os.replace(tmp, path)
return True
except OSError:
@@ -59,3 +151,10 @@ def write_json(path: Path, data: dict) -> bool:
except OSError:
pass
return False
except BaseException:
# Ctrl-C mid-write: drop the temp file, then let the interrupt through.
try:
os.unlink(tmp)
except OSError:
pass
raise
+79 -16
View File
@@ -12,10 +12,13 @@ Provides:
from __future__ import annotations
import os
import re
from datetime import datetime
from pathlib import Path
from .git import main_worktree_root
# =============================================================================
# Path Constants (change here to rename directories)
@@ -35,6 +38,17 @@ FILE_CURRENT_TASK = ".current-task"
FILE_TASK_JSON = "task.json"
FILE_JOURNAL_PREFIX = "journal-"
# Environment override for the developer identity, ahead of the .developer file.
ENV_DEVELOPER = "TRELLIS_DEVELOPER"
# Appended to every "no developer set" error so the two non-obvious sources are
# discoverable from the failure itself.
DEVELOPER_HINT = (
f" Or set {ENV_DEVELOPER}=<your-name> in the environment.\n"
f" A linked git worktree inherits {DIR_WORKFLOW}/{FILE_DEVELOPER} from its "
f"main checkout — run init_developer.py there to cover every worktree."
)
# =============================================================================
# Repository Root
@@ -66,8 +80,40 @@ def get_repo_root(start_path: Path | None = None) -> Path:
# Developer
# =============================================================================
def _read_developer_file(dev_file: Path) -> str | None:
"""Read the `name=` field out of a .developer file, or None."""
if not dev_file.is_file():
return None
try:
content = dev_file.read_text(encoding="utf-8")
except (OSError, IOError):
return None
for line in content.splitlines():
if line.startswith("name="):
return line.split("=", 1)[1].strip() or None
return None
def get_developer(repo_root: Path | None = None) -> str | None:
"""Get developer name from .developer file.
"""Get the developer name for this checkout.
Resolution order, first hit wins (a CLI `--assignee` flag overrides all of
it, before this function is ever called):
1. The ``TRELLIS_DEVELOPER`` environment variable.
2. ``.trellis/.developer`` in this checkout.
3. ``.trellis/.developer`` in the main checkout, when this checkout is a
linked git worktree.
Step 3 exists because `.developer` is gitignored on purpose — it carries a
personal identity and no tracked file should. A fresh `git worktree add`
therefore starts with no identity file of its own, which used to make every
task.py command fail until init_developer.py was re-run per worktree. The
main checkout's file is read, never copied: a copy would go stale and shadow
later changes made in the main checkout.
Args:
repo_root: Repository root path. Defaults to auto-detected.
@@ -75,23 +121,22 @@ def get_developer(repo_root: Path | None = None) -> str | None:
Returns:
Developer name or None if not initialized.
"""
env_name = os.environ.get(ENV_DEVELOPER, "").strip()
if env_name:
return env_name
if repo_root is None:
repo_root = get_repo_root()
dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
local = _read_developer_file(repo_root / DIR_WORKFLOW / FILE_DEVELOPER)
if local:
return local
if not dev_file.is_file():
main_root = main_worktree_root(repo_root)
if main_root is None:
return None
try:
content = dev_file.read_text(encoding="utf-8")
for line in content.splitlines():
if line.startswith("name="):
return line.split("=", 1)[1].strip()
except (OSError, IOError):
pass
return None
return _read_developer_file(main_root / DIR_WORKFLOW / FILE_DEVELOPER)
def check_developer(repo_root: Path | None = None) -> bool:
@@ -259,29 +304,47 @@ def resolve_task_ref(task_ref: str, repo_root: Path | None = None) -> Path | Non
if not normalized:
return None
try:
root = repo_root.resolve()
except OSError:
return None
path_obj = Path(normalized)
if path_obj.is_absolute():
candidate = path_obj
elif normalized.startswith(f"{DIR_WORKFLOW}/"):
candidate = repo_root / path_obj
candidate = root / path_obj
else:
candidate = repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj
candidate = root / DIR_WORKFLOW / DIR_TASKS / path_obj
# resolve() collapses `..` and follows symlinks, so a task directory that
# links outside the repo is refused too. Both sides are resolved because
# repo_root itself may sit behind a symlink (/tmp on macOS does).
try:
resolved = candidate.resolve()
root = repo_root.resolve()
workflow_real = (root / DIR_WORKFLOW).resolve()
except OSError:
return None
try:
resolved.relative_to(root)
return resolved
except ValueError:
pass
# `.trellis` may itself be a symlink into a store outside the repo (#567).
# The workflow dir's own real location is then a second legitimate
# containment base: a ref through that link never left the workflow tree.
# A ref that escapes BOTH bases (traversal, absolute path elsewhere, a
# task dir symlinked out of the tree) is still refused.
try:
rel = resolved.relative_to(workflow_real)
except ValueError:
return None
return resolved
# Map back to the in-repo (lexical) form so callers store the same
# repo-relative ref as in the non-symlinked layout.
return root / DIR_WORKFLOW / rel
def get_current_task(
+10 -3
View File
@@ -35,7 +35,7 @@ from __future__ import annotations
import sys
from pathlib import Path
from .git import run_git
from .git import run_git, run_git_retry_index_lock
from .paths import (
DIR_ARCHIVE,
DIR_TASKS,
@@ -208,7 +208,7 @@ def _stderr_indicates_ignored(stderr: str) -> bool:
def safe_git_add(
paths: list[str], repo_root: Path
paths: list[str], repo_root: Path, retry_on_index_lock: bool = False
) -> tuple[bool, bool, str]:
"""Run `git add` on specific paths; never retry with -f.
@@ -222,11 +222,18 @@ def safe_git_add(
- Plain fails (any reason — ignored or otherwise) → return failure with
the stderr. Callers should inspect the stderr (see
:func:`print_gitignore_warning`) and skip the auto-commit.
``retry_on_index_lock`` opts into the bounded backoff-retry for a held
``.git/index.lock`` (see :func:`~.git.run_git_retry_index_lock`). It is
off by default: only the archive path, which has already moved the task
directory on disk by the time it stages, needs to wait out a transient
lock rather than fail.
"""
if not paths:
return True, False, ""
rc, _, err = run_git(["add", "--", *paths], cwd=repo_root)
runner = run_git_retry_index_lock if retry_on_index_lock else run_git
rc, _, err = runner(["add", "--", *paths], cwd=repo_root)
if rc == 0:
return True, False, ""
return False, False, err
+114 -16
View File
@@ -9,10 +9,14 @@ Provides:
Note:
``cmd_init_context`` was removed in v0.5.0-beta.12. JSONL context files
are now seeded at ``task.py create`` time with a self-describing
``_example`` line; the AI agent curates real entries during planning when
the task needs sub-agent/spec context. See ``.trellis/workflow.md`` for the
current planning artifact contract.
are created empty at ``task.py create`` time; the AI agent curates real
entries during planning when the task needs sub-agent/spec context. See
``.trellis/workflow.md`` for the current planning artifact contract.
Older Trellis versions seeded those files with a ``{"_example": ...}``
placeholder row. ``cmd_validate`` now rejects that row so a task cannot
validate locally and then fail PR preflight, which treats it as
unresolved scaffolding.
"""
from __future__ import annotations
@@ -60,6 +64,8 @@ def cmd_add_context(args: argparse.Namespace) -> int:
"""Add entry to JSONL context file."""
repo_root = get_repo_root()
target_dir = resolve_task_dir(args.dir, repo_root)
if target_dir is None:
return 1
jsonl_name = args.file
path = args.path
@@ -69,6 +75,15 @@ def cmd_add_context(args: argparse.Namespace) -> int:
print(colored(f"Error: Directory not found: {target_dir}", Colors.RED))
return 1
# The JSONL name is user input joined onto the task dir — keep it a plain
# filename so it cannot create files elsewhere.
if "/" in jsonl_name or "\\" in jsonl_name or jsonl_name in (".", ".."):
print(colored(
f"Error: context file must be a plain name (e.g. implement, check): {jsonl_name}",
Colors.RED,
))
return 1
# Support shorthand
if not jsonl_name.endswith(".jsonl"):
jsonl_name = f"{jsonl_name}.jsonl"
@@ -110,12 +125,41 @@ def cmd_add_context(args: argparse.Namespace) -> int:
# Command: validate
# =============================================================================
def curated_entry_count(jsonl_file: Path) -> int | None:
"""Count curated entries in a jsonl context manifest.
Returns None when the file does not exist — `task.py create` seeds the
manifests only on sub-agent-capable platforms, so an absent file means no
sub-agent will ever read it and callers should not gate on it. A curated
entry is a JSON object row carrying a truthy ``file`` (or legacy ``path``)
value: the same rows the sub-agent injection hook materializes.
"""
if not jsonl_file.is_file():
return None
try:
lines = jsonl_file.read_text(encoding="utf-8").splitlines()
except OSError:
return 0
count = 0
for line in lines:
line = line.strip()
if not line:
continue
try:
data = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(data, dict) and (data.get("file") or data.get("path")):
count += 1
return count
def cmd_validate(args: argparse.Namespace) -> int:
"""Validate JSONL context files."""
repo_root = get_repo_root()
target_dir = resolve_task_dir(args.dir, repo_root)
if not target_dir or not target_dir.is_dir():
if target_dir is None or not target_dir.is_dir():
print(colored("Error: task directory required", Colors.RED))
return 1
@@ -226,8 +270,10 @@ def _resolve_context_entry_path(
def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = None) -> int:
"""Validate a single JSONL file.
Seed rows (no ``file`` field — typically ``{"_example": "..."}``) are
skipped silently; they are self-describing comments, not real entries.
``{"_example": ...}`` placeholder rows written by older Trellis versions
are hard errors: PR preflight rejects them as unresolved scaffolding, so
accepting them here would pass locally and fail later. Other rows without
a ``file`` field are skipped silently, matching what consumers do.
Beyond hard errors (missing file/dir, invalid JSON), this also prints
non-blocking hygiene warnings (never counted in ``errors``, never change
@@ -265,11 +311,38 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N
errors += 1
continue
if not isinstance(data, dict):
print(
f" {colored(f'{file_name}:{line_num}: Expected a JSON object', Colors.RED)}"
)
errors += 1
continue
if "_example" in data:
error_message = (
f"{file_name}:{line_num}: Placeholder `_example` row left by an older "
"task.py create — delete this line, or replace it with "
'{"file": "<path>", "reason": "<why>"}'
)
print(f" {colored(error_message, Colors.RED)}")
errors += 1
continue
file_path = data.get("file")
entry_type = data.get("type", "file")
if not file_path:
# Seed / comment row — skip silently
# Comment / unknown row without a path — skip silently
continue
if not isinstance(file_path, str):
# A truthy non-string (e.g. {"file": 1}) reached path joining and
# raised TypeError, so validation crashed on the row it exists to
# report.
print(
f" {colored(f'{file_name}:{line_num}: `file` must be a string path', Colors.RED)}"
)
errors += 1
continue
real_entries += 1
@@ -297,8 +370,14 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N
print(f" {colored(warning_message, Colors.YELLOW)}")
if max_file_bytes:
size = full_path.stat().st_size
if size > max_file_bytes:
# Advisory hygiene warning, so it must never be what fails
# `validate`. `stat()` can still raise after the `is_file()` check
# above — a permission change, or the file removed in between.
try:
size: int | None = full_path.stat().st_size
except OSError:
size = None
if size is not None and size > max_file_bytes:
warning_message = (
f"{file_name}:{line_num}: Warning: {file_path} is {size} bytes, "
f"exceeds context_injection.max_file_bytes ({max_file_bytes}); "
@@ -306,6 +385,22 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N
)
print(f" {colored(warning_message, Colors.YELLOW)}")
if errors == 0 and real_entries == 0:
# Seed-only / empty manifest: sub-agents dispatched for this task
# would run with zero spec context (#573). Silent-green here is how
# more than half the tasks in the report ended up uncurated.
action = file_name.split(".", 1)[0]
print(
f" {colored(f'{file_name}: ✗ (0 curated entries — sub-agents would get zero spec context)', Colors.RED)}"
)
print(
f" Curate it: python3 .trellis/scripts/task.py add-context <task> {action} <path> \"<why>\""
)
print(
" Intentionally empty? Bypass at start: task.py start <task> --allow-empty-context"
)
return 1
if errors == 0:
print(f" {colored(f'{file_name}: ✓ ({real_entries} entries)', Colors.GREEN)}")
else:
@@ -323,7 +418,7 @@ def cmd_list_context(args: argparse.Namespace) -> int:
repo_root = get_repo_root()
target_dir = resolve_task_dir(args.dir, repo_root)
if not target_dir or not target_dir.is_dir():
if target_dir is None or not target_dir.is_dir():
print(colored("Error: task directory required", Colors.RED))
return 1
@@ -338,7 +433,7 @@ def cmd_list_context(args: argparse.Namespace) -> int:
print(colored(f"[{jsonl_name}]", Colors.CYAN))
count = 0
seed_only = True
curated = False
for line in jsonl_file.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
@@ -348,11 +443,14 @@ def cmd_list_context(args: argparse.Namespace) -> int:
except json.JSONDecodeError:
continue
if not isinstance(data, dict):
continue
file_path = data.get("file")
if not file_path:
# Seed / comment row — don't count as a real entry
# Placeholder / comment row — don't count as a real entry
continue
seed_only = False
curated = True
count += 1
entry_type = data.get("type", "file")
@@ -364,8 +462,8 @@ def cmd_list_context(args: argparse.Namespace) -> int:
print(f" {colored(f'{count}.', Colors.GREEN)} {file_path}")
print(f" {colored('', Colors.YELLOW)} {reason}")
if seed_only:
print(f" {colored('(no curated entries yet — only seed row)', Colors.YELLOW)}")
if not curated:
print(f" {colored('(no curated entries yet)', Colors.YELLOW)}")
print()
File diff suppressed because it is too large Load Diff
+279 -90
View File
@@ -3,9 +3,10 @@
Task utility functions.
Provides:
is_safe_task_path - Validate task path is safe to operate on
is_within_tasks_dir - Check a resolved path is a task directly under tasks/
find_task_by_name - Find task directory by name
resolve_task_dir - Resolve task directory from name, relative, or absolute path
archive_destination_for - Path a task would be archived to
archive_task_dir - Archive task to monthly directory
run_task_hooks - Run lifecycle hooks for task events
"""
@@ -16,70 +17,28 @@ import shutil
import sys
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING
from .paths import get_repo_root, get_tasks_dir
if TYPE_CHECKING:
import subprocess
# =============================================================================
# Path Safety
# =============================================================================
def is_safe_task_path(task_path: str, repo_root: Path | None = None) -> bool:
"""Check if a relative task path is safe to operate on.
Args:
task_path: Task path (relative to repo_root).
repo_root: Repository root path. Defaults to auto-detected.
Returns:
True if safe, False if dangerous.
"""
if repo_root is None:
repo_root = get_repo_root()
normalized = task_path.replace("\\", "/")
# Check empty or null
if not normalized or normalized == "null":
print("Error: empty or null task path", file=sys.stderr)
return False
# Reject absolute paths
if Path(task_path).is_absolute():
print(f"Error: absolute path not allowed: {task_path}", file=sys.stderr)
return False
# Reject ".", "..", paths starting with "./" or "../", or containing ".."
if normalized in (".", "..") or normalized.startswith("./") or normalized.startswith("../") or ".." in normalized:
print(f"Error: path traversal not allowed: {task_path}", file=sys.stderr)
return False
# Final check: ensure resolved path is not the repo root
abs_path = repo_root / Path(normalized)
if abs_path.exists():
try:
resolved = abs_path.resolve()
root_resolved = repo_root.resolve()
if resolved == root_resolved:
print(f"Error: path resolves to repo root: {task_path}", file=sys.stderr)
return False
except (OSError, IOError):
pass
return True
def is_within_tasks_dir(task_dir_abs: Path, repo_root: Path | None = None) -> bool:
"""Check that a resolved task directory really is a task under the tasks dir.
A real task lives directly at ``.trellis/tasks/<name>``. This returns True
only when ``task_dir_abs`` is an immediate child of the tasks directory.
Guards archive: ``resolve_task_dir`` falls back to ``repo_root/<name>`` for
an unknown name, so a mistyped ``task.py archive src`` resolves to the real
``src/`` source directory. Without this check archive would ``shutil.move``
it out of the repo. Also rejects the tasks dir itself and anything nested
under ``archive/`` (already-archived tasks).
Narrows ``resolve_task_dir``'s containment for archive: that chokepoint
accepts anything under the tasks dir, including a task already in
``archive/<YYYY-MM>/``. This rejects those, plus the tasks dir itself, so
``shutil.move`` never re-archives an archived task into a nested copy.
"""
if repo_root is None:
repo_root = get_repo_root()
@@ -100,25 +59,46 @@ def is_within_tasks_dir(task_dir_abs: Path, repo_root: Path | None = None) -> bo
def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None:
"""Find task directory by name (exact or suffix match).
A task name is a single directory name under ``tasks_dir``, never a path:
names carrying a separator or a dot segment are rejected before the join,
so ``".."`` cannot hand back the tasks dir's own parent.
An ambiguous suffix (two tasks created on different days with the same
slug) is a failure, not a coin flip ``iterdir()`` order is filesystem
order, so silently picking the first match picks a different task on a
different machine.
Args:
task_name: Task name to find.
tasks_dir: Tasks directory path.
Returns:
Absolute path to task directory, or None if not found.
Absolute path to task directory, or None if not found or ambiguous.
"""
if not task_name or not tasks_dir or not tasks_dir.is_dir():
return None
if "/" in task_name or "\\" in task_name or task_name in (".", ".."):
print(f"Error: invalid task name: {task_name}", file=sys.stderr)
return None
# Try exact match first
exact_match = tasks_dir / task_name
if exact_match.is_dir():
return exact_match
# Try suffix match (e.g., "my-task" matches "01-21-my-task")
for d in tasks_dir.iterdir():
if d.is_dir() and d.name.endswith(f"-{task_name}"):
return d
matches = sorted(
d for d in tasks_dir.iterdir()
if d.is_dir() and d.name.endswith(f"-{task_name}")
)
if len(matches) == 1:
return matches[0]
if matches:
print(f"Error: ambiguous task name '{task_name}' matches:", file=sys.stderr)
for match in matches:
print(f" - {match.name}", file=sys.stderr)
print("Pass the full task directory name.", file=sys.stderr)
return None
@@ -127,6 +107,13 @@ def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None:
# Archive Operations
# =============================================================================
def archive_destination_for(task_dir_abs: Path) -> Path:
"""Path a task would be archived to: <tasks>/archive/<YYYY-MM>/<name>."""
tasks_dir = task_dir_abs.parent
year_month = datetime.now().strftime("%Y-%m")
return tasks_dir / "archive" / year_month / task_dir_abs.name
def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path | None:
"""Archive a task directory to archive/{YYYY-MM}/.
@@ -141,11 +128,8 @@ def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path
print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr)
return None
# Get tasks directory (parent of the task)
tasks_dir = task_dir_abs.parent
archive_dir = tasks_dir / "archive"
year_month = datetime.now().strftime("%Y-%m")
month_dir = archive_dir / year_month
dest = archive_destination_for(task_dir_abs)
month_dir = dest.parent
# Create archive directory
try:
@@ -154,9 +138,22 @@ def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path
print(f"Error: Failed to create archive directory: {e}", file=sys.stderr)
return None
# Move task to archive
task_name = task_dir_abs.name
dest = month_dir / task_name
# shutil.move into an existing directory moves the source *inside* it,
# producing archive/<month>/<task>/<task>/ and returning a path that is not
# where the task actually landed. That wrong path then flows into the
# printed result, the after_archive hook's TASK_JSON_PATH, and auto-commit
# staging, so refuse before the move instead.
if dest.exists():
print(
f"Error: refusing to archive {task_dir_abs}: "
f"archive destination already exists: {dest}",
file=sys.stderr,
)
print(
"Move or rename the existing archived task, then retry.",
file=sys.stderr,
)
return None
try:
shutil.move(str(task_dir_abs), str(dest))
@@ -196,63 +193,209 @@ def archive_task_complete(
# =============================================================================
def resolve_task_dir(target_dir: str, repo_root: Path) -> Path | None:
"""Resolve task directory to absolute path.
"""Resolve task directory to an absolute path inside the tasks directory.
Supports:
- Absolute path: /path/to/task
- Relative path: .trellis/tasks/01-31-my-task
- Task name: my-task (uses find_task_by_name for lookup)
This is the containment chokepoint for every command that accepts a task
directory argument. The candidate is resolved (following symlinks) and must
land strictly under ``.trellis/tasks/``; archived tasks under
``archive/<YYYY-MM>/`` qualify. Traversal (``../victim``), absolute paths
outside the repo, a task dir symlinked out of the tasks tree, and the
tasks directory itself are all rejected here so no caller has to re-check.
The containment base is the tasks dir's own real location, so a
``.trellis`` that is itself a symlink into a shared store (#567) works.
Args:
target_dir: Task directory specification.
repo_root: Repository root path.
Returns:
Resolved absolute path, or None when it resolves outside
`repo_root`. Both sides are resolved before comparing, since
`repo_root` may itself sit behind a symlink (/tmp does on macOS).
Absolute path spelled through the repo's own tasks dir (in-repo
lexical form), or None when it is not a location inside the tasks
directory (an error naming the path is printed to stderr).
"""
if not target_dir:
return Path()
print("Error: task directory is required", file=sys.stderr)
return None
normalized = target_dir.replace("\\", "/")
while normalized.startswith("./"):
normalized = normalized[2:]
# Absolute path
tasks_dir = get_tasks_dir(repo_root)
if Path(target_dir).is_absolute():
candidate = Path(target_dir)
# Relative path (contains path separator or starts with .trellis)
elif "/" in normalized or normalized.startswith(".trellis"):
# Relative path (contains path separator or starts with .trellis)
candidate = repo_root / Path(normalized)
else:
# Task name - try to find in tasks directory; fall back to treating
# it as a relative path when not found.
tasks_dir = get_tasks_dir(repo_root)
found = find_task_by_name(target_dir, tasks_dir)
candidate = found if found else repo_root / Path(normalized)
# Task name - must resolve inside the tasks directory. The historical
# fallback to repo_root/<name> only ever produced a path the check
# below rejects, so a miss ends here instead.
candidate = find_task_by_name(target_dir, tasks_dir)
if candidate is None:
# find_task_by_name reports invalid names and ambiguity itself.
print(
f"Error: could not resolve task '{target_dir}' under {tasks_dir}",
file=sys.stderr,
)
return None
try:
resolved = candidate.resolve()
root = repo_root.resolve()
except OSError:
tasks_lexical = get_tasks_dir(repo_root.resolve())
tasks_resolved = tasks_lexical.resolve()
except (OSError, RuntimeError) as e:
print(f"Error: could not resolve task directory '{target_dir}': {e}", file=sys.stderr)
return None
try:
resolved.relative_to(root)
except ValueError:
if resolved == tasks_resolved:
print(
f"Error: refusing to use '{target_dir}': {tasks_resolved} is the tasks "
"directory itself, not a task",
file=sys.stderr,
)
return None
return resolved
if tasks_resolved not in resolved.parents:
print(
f"Error: refusing to use '{target_dir}': {resolved} is outside {tasks_resolved}",
file=sys.stderr,
)
return None
# Hand back the path spelled through the repo's own tasks dir rather than
# `resolved`: `.trellis` may be a symlink into a store outside the repo
# (#567), in which case `resolved` sits outside the repo even though the
# task is legitimate, and callers converting to a repo-relative ref for
# storage would refuse it.
return tasks_lexical / resolved.relative_to(tasks_resolved)
# =============================================================================
# Lifecycle Hooks
# =============================================================================
HOOK_TIMEOUT_SECONDS = 60
HOOK_KILL_GRACE_SECONDS = 5
HOOK_OUTPUT_LIMIT = 2000
def _kill_hook_tree(proc: subprocess.Popen[str]) -> None:
"""Kill a timed-out hook's whole process tree, not just the shell.
With ``shell=True`` the direct child is the shell; the actual work runs in
its children. Killing only the shell leaves grandchildren alive holding the
inherited stdout/stderr pipes, and collecting output afterwards then blocks
until those orphans exit the exact "command that never returns" the
timeout exists to prevent.
POSIX: the hook is started with ``start_new_session=True``, so the shell and
every descendant share one fresh process group; SIGKILL the group.
Windows: ``taskkill /F /T`` walks the tree (best effort).
Either way ``proc.kill()`` is the fallback.
Limitation: a hook that calls ``setsid`` itself leaves the group and
survives this. Accepted and out of scope a hook is arbitrary code, and the
timeout is a liveness guarantee, not a containment boundary.
"""
import os
import signal
import subprocess
if os.name == "posix":
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
return
except (ProcessLookupError, PermissionError, OSError):
pass
else:
try:
subprocess.run(
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
capture_output=True,
)
except (OSError, ValueError):
pass
try:
proc.kill()
except OSError:
pass
def _release_hook_process(proc: subprocess.Popen[str]) -> None:
"""Close our pipe ends and reap a finished hook under a bound.
``Popen`` used as a context manager calls ``wait()`` with no timeout when
the block exits. That is the one place the liveness guarantee could still
be lost: if ``_kill_hook_tree`` failed outright both the process-group
kill and the direct kill raising the lifecycle command would block there
forever, which is the exact hang the timeout exists to prevent.
"""
import subprocess
for stream in (proc.stdout, proc.stderr, proc.stdin):
if stream is not None:
try:
stream.close()
except OSError:
pass
try:
proc.wait(timeout=HOOK_KILL_GRACE_SECONDS)
except (subprocess.TimeoutExpired, OSError, ValueError):
# Leaves a zombie until this process exits. Fail-open and bounded beats
# a task command that never returns.
pass
def _decode_hook_output(raw: object) -> str:
"""TimeoutExpired carries bytes or str depending on the platform."""
if raw is None:
return ""
if isinstance(raw, bytes):
return raw.decode("utf-8", errors="replace")
return str(raw)
def _print_hook_stream(name: str, text: str) -> None:
"""Print a captured hook stream to stderr, truncated."""
text = text.strip()
if not text:
return
if len(text) > HOOK_OUTPUT_LIMIT:
text = (
text[:HOOK_OUTPUT_LIMIT]
+ f"\n… ({len(text) - HOOK_OUTPUT_LIMIT} more characters truncated)"
)
print(f" {name}:", file=sys.stderr)
for line in text.splitlines():
print(f" {line}", file=sys.stderr)
def run_task_hooks(event: str, task_json_path: Path, repo_root: Path) -> None:
"""Run lifecycle hooks for a task event.
Hooks are shell commands read from ``.trellis/config.yaml`` and executed
with ``shell=True`` from the repo root see the trust boundary note in
``.trellis/spec/cli/backend/script-conventions.md``.
Fail-open by design: a broken hook warns and the lifecycle command
continues. The warning names the event, command, exit status, and both
captured streams, because a hook whose only symptom is "nothing happened"
is undebuggable. A hook that hangs is bounded by
``HOOK_TIMEOUT_SECONDS``; output is captured, so without the timeout the
user sees a task command that never returns and prints nothing. On timeout
the hook's entire process tree is killed (see ``_kill_hook_tree``) and
output is collected under a bounded grace, so a surviving grandchild
holding the pipes cannot re-create that same hang. Cleanup is bounded for
the same reason see ``_release_hook_process``.
Args:
event: Event name (e.g. "after_create").
task_json_path: Absolute path to the task's task.json.
@@ -270,28 +413,74 @@ def run_task_hooks(event: str, task_json_path: Path, repo_root: Path) -> None:
env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)}
# POSIX only: a fresh session puts the shell and every descendant in one
# process group, which is what makes the timeout kill the whole tree.
popen_kwargs: dict = {}
if os.name == "posix":
popen_kwargs["start_new_session"] = True
for cmd in commands:
proc = None
try:
result = subprocess.run(
proc = subprocess.Popen(
cmd,
shell=True,
cwd=repo_root,
env=env,
capture_output=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
**popen_kwargs,
)
if result.returncode != 0:
try:
stdout, stderr = proc.communicate(timeout=HOOK_TIMEOUT_SECONDS)
except subprocess.TimeoutExpired as e:
_kill_hook_tree(proc)
stdout = _decode_hook_output(e.stdout)
stderr = _decode_hook_output(e.stderr)
try:
# Bounded: an orphan that escaped the kill still holds
# the pipes, and waiting on it forever here would be
# the very hang the timeout prevents.
rest_out, rest_err = proc.communicate(
timeout=HOOK_KILL_GRACE_SECONDS
)
stdout = rest_out or stdout
stderr = rest_err or stderr
except (subprocess.TimeoutExpired, OSError, ValueError):
pass
print(
colored(f"[WARN] Hook failed ({event}): {cmd}", Colors.YELLOW),
colored(
f"[WARN] Hook timed out ({event}) after "
f"{HOOK_TIMEOUT_SECONDS}s: {cmd}",
Colors.YELLOW,
),
file=sys.stderr,
)
if result.stderr.strip():
print(f" {result.stderr.strip()}", file=sys.stderr)
print(f" cwd: {repo_root}", file=sys.stderr)
_print_hook_stream("stdout", stdout)
_print_hook_stream("stderr", stderr)
continue
if proc.returncode != 0:
print(
colored(
f"[WARN] Hook failed ({event}): exit {proc.returncode}: {cmd}",
Colors.YELLOW,
),
file=sys.stderr,
)
print(f" cwd: {repo_root}", file=sys.stderr)
_print_hook_stream("stdout", stdout or "")
_print_hook_stream("stderr", stderr or "")
except Exception as e:
print(
colored(f"[WARN] Hook error ({event}): {cmd}{e}", Colors.YELLOW),
colored(
f"[WARN] Hook error ({event}): {cmd}{type(e).__name__}: {e}",
Colors.YELLOW,
),
file=sys.stderr,
)
@@ -305,5 +494,5 @@ if __name__ == "__main__":
tasks = get_tasks_dir(repo)
print(f"Tasks dir: {tasks}")
print(f"is_safe_task_path('.trellis/tasks/test'): {is_safe_task_path('.trellis/tasks/test', repo)}")
print(f"is_safe_task_path('../test'): {is_safe_task_path('../test', repo)}")
print(f"resolve_task_dir('.trellis/tasks/test'): {resolve_task_dir('.trellis/tasks/test', repo)}")
print(f"resolve_task_dir('../test'): {resolve_task_dir('../test', repo)}")
+13 -3
View File
@@ -12,10 +12,11 @@ Provides:
from __future__ import annotations
import sys
from collections.abc import Iterator
from pathlib import Path
from .io import read_json
from .io import describe_json_read_failure, read_json_checked
from .paths import FILE_TASK_JSON
from .types import TaskInfo
@@ -28,13 +29,22 @@ def load_task(task_dir: Path) -> TaskInfo | None:
Returns:
TaskInfo if task.json exists and is valid, None otherwise.
A directory without task.json is not a task, so it is skipped silently.
A task.json that exists but cannot be loaded is different: the task
disappears from `task.py list` and from every context the iterator feeds.
Callers stay tolerant, but the skip is announced on stderr so a task
cannot vanish from the workflow with no diagnostic anywhere.
"""
task_json = task_dir / FILE_TASK_JSON
if not task_json.is_file():
return None
data = read_json(task_json)
if not data:
data, reason = read_json_checked(task_json)
if data is None:
problem, hint = describe_json_read_failure(task_json, reason)
print(f"[WARN] Skipping task '{task_dir.name}': {problem}", file=sys.stderr)
print(f" {hint}", file=sys.stderr)
return None
return TaskInfo(
+116 -9
View File
@@ -2,13 +2,23 @@
"""
Standalone reader for .trellis/config.yaml.
Mirrors a minimal subset of common.config so callers (hooks, workflow_phase)
can read configuration without importing the full task/repo helpers. Returns
an empty dict on missing/malformed files so callers stay simple.
Owns the minimal YAML parser used across Trellis. ``common.config`` imports
``parse_simple_yaml`` from here rather than keeping its own copy: this module
imports nothing from the package, so hooks can load it as a single file, and
one parser cannot drift from another. Returns an empty dict on
missing/malformed files so callers stay simple.
Supported subset: ``key: value`` scalars (everything is a string), nested
mappings by indentation, ``- `` lists of scalars, ``#`` comments (whole-line
and inline outside quotes), and one layer of matching surrounding quotes.
Constructs outside that subset block scalars, anchors, aliases, merge keys,
flow collections, and mappings nested inside a list are reported on stderr
and skipped rather than parsed into a plausible-looking wrong value.
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Optional
@@ -52,11 +62,62 @@ def _next_content_line(lines: list[str], start: int) -> tuple[int, str]:
return i, ""
def _warn_unsupported(source: str, lineno: int, line: str, reason: str) -> None:
"""Report a YAML construct this parser cannot represent, and move on."""
print(
f"[WARN] {source}:{lineno}: {reason}; ignoring: {line.strip()}",
file=sys.stderr,
)
def _is_block_scalar(value: str) -> bool:
"""True for ``|``, ``>`` and their chomping/indent indicators (``|-``, ``>2``)."""
if not value or value[0] not in ("|", ">"):
return False
return all(ch in "+-0123456789" for ch in value[1:])
def _unsupported_value(key: str, value: str) -> str | None:
"""Name the unsupported construct in an unquoted scalar value, else None.
Only unquoted values are inspected: ``cmd: "[a] | b"`` is a string the user
wrote deliberately, while a bare ``notes: |`` or ``base: *anchor`` would
otherwise be stored as the literal marker with the real content dropped.
"""
if key == "<<":
return "YAML merge keys are not supported"
if _is_block_scalar(value):
return "block scalars are not supported"
if value.startswith("&"):
return "YAML anchors are not supported"
if value.startswith("*"):
return "YAML aliases are not supported"
if value.startswith("["):
return "flow sequences are not supported (use `- ` list items)"
if value.startswith("{"):
return "flow mappings are not supported (use an indented mapping)"
return None
def _skip_indented_body(lines: list[str], start: int, indent: int) -> int:
"""Skip the continuation lines of a rejected key (block scalar body etc.)."""
i = start
while i < len(lines):
stripped = lines[i].strip()
if stripped and len(lines[i]) - len(lines[i].lstrip()) <= indent:
break
i += 1
return i
def _parse_yaml_block(
lines: list[str], start: int, min_indent: int, target: dict
lines: list[str], start: int, min_indent: int, target: dict, source: str
) -> int:
i = start
current_list: list | None = None
# Indent of the key that opened current_list, so a deeper `key: value`
# can be recognized as a mapping inside that list.
list_owner_indent = 0
while i < len(lines):
line = lines[i]
@@ -75,10 +136,33 @@ def _parse_yaml_block(
current_list.append(_unquote(stripped[2:].strip()))
i += 1
elif ":" in stripped:
if current_list is not None and indent > list_owner_indent:
# `- name: cli` / ` path: x`: the second key belongs to a
# mapping inside the list. Storing it would hoist it into the
# parent dict as a sibling of the list — a nested key silently
# becoming a root key.
_warn_unsupported(
source,
i + 1,
line,
"mappings inside a list are not supported",
)
i += 1
continue
key, _, value = stripped.partition(":")
key = key.strip()
value = _strip_inline_comment(value).strip()
was_quoted = len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'")
if not was_quoted:
reason = _unsupported_value(key, value)
if reason is not None:
_warn_unsupported(source, i + 1, line, reason)
current_list = None
i = _skip_indented_body(lines, i + 1, indent)
continue
value = _unquote(value)
current_list = None
@@ -92,6 +176,7 @@ def _parse_yaml_block(
i = next_i
elif next_line.strip().startswith("- "):
current_list = []
list_owner_indent = indent
target[key] = current_list
i += 1
else:
@@ -99,7 +184,7 @@ def _parse_yaml_block(
if next_indent > indent:
nested: dict = {}
target[key] = nested
i = _parse_yaml_block(lines, i + 1, next_indent, nested)
i = _parse_yaml_block(lines, i + 1, next_indent, nested, source)
else:
target[key] = {}
i += 1
@@ -109,11 +194,33 @@ def _parse_yaml_block(
return i
def parse_simple_yaml(content: str) -> dict:
"""Parse a small subset of YAML. See common.config for full doc."""
def parse_simple_yaml(content: str, source: str = "config.yaml") -> dict:
"""Parse simple YAML with nested dict support (no dependencies).
Supports:
- key: value (string)
- key: (followed by list items)
- item1
- item2
- key: (followed by nested dict)
nested_key: value
nested_key2:
- item
Uses indentation to detect nesting (2+ spaces deeper = child). Every value
is a string; consumers coerce. Unsupported constructs are reported on
stderr against ``source`` and skipped see the module docstring.
Args:
content: YAML content string.
source: Label used in warnings, normally the config file path.
Returns:
Parsed dict (values can be str, list[str], or dict).
"""
lines = content.splitlines()
result: dict = {}
_parse_yaml_block(lines, 0, 0, result)
_parse_yaml_block(lines, 0, 0, result, source)
return result
@@ -126,7 +233,7 @@ def read_trellis_config(repo_root: Optional[Path] = None) -> dict:
except (FileNotFoundError, OSError):
return {}
try:
parsed = parse_simple_yaml(content)
parsed = parse_simple_yaml(content, source=str(config_file))
except Exception:
return {}
return parsed if isinstance(parsed, dict) else {}
+24 -1
View File
@@ -141,6 +141,25 @@ def _platform_matches(platform: str, block_names: list[str]) -> bool:
return False
_PLATFORM_MARKER_LABELS: dict[str, str] = {
# workflow.md marker blocks label platforms with their product names, but
# every caller passes the stable id instead (`--platform {{CLI_FLAG}}` in
# the start / continue commands). `_platform_matches` only strips
# punctuation, so an id that is not its label-minus-spaces never matches and
# `filter_platform` drops the block WITHOUT error — the section just comes
# back empty. Four platforms shipped that way before this table existed.
#
# Add an entry whenever a platform's id is not its marker label with the
# separators removed. `test/registry-invariants.test.ts` asserts every
# registry id keeps a non-empty routing section, so a missing entry fails
# there rather than silently blanking that platform's routing.
"claude": "Claude Code",
"kimi": "Kimi Code",
"omp": "Oh My Pi",
"dsh": "DeepSeek Harness",
}
def resolve_effective_platform(platform: str, config: dict) -> str:
"""Map ``codex`` to a dispatch-mode-namespaced virtual platform name.
@@ -155,8 +174,12 @@ def resolve_effective_platform(platform: str, config: dict) -> str:
explicit values fall back to ``inline`` safely; this renderer deliberately
does not warn because it can run in normal CLI output flows.
Other platforms are returned unchanged.
Platforms whose marker label differs from their id resolve through
``_PLATFORM_MARKER_LABELS``. Everything else is returned unchanged.
"""
label = _PLATFORM_MARKER_LABELS.get(platform.strip().lower())
if label:
return label
if platform == "codex":
mode = "auto"
codex_cfg = config.get("codex") if isinstance(config, dict) else None
+219 -30
View File
@@ -4,18 +4,19 @@
Task Management Script.
Usage:
python3 task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] [--package <pkg>] [--no-start]
python3 task.py create "<title>" --description "<desc>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] [--package <pkg>] [--no-start] [--force]
python3 task.py add-context <dir> <file> <path> [reason] # Add jsonl entry
python3 task.py validate <dir> # Validate jsonl files
python3 task.py list-context <dir> # List jsonl entries
python3 task.py start <dir> # Set active task
python3 task.py start <dir> # Set active task, record current branch
python3 task.py current [--source] [--json] # Show active task
python3 task.py finish # Clear active task
python3 task.py set-branch <dir> <branch> # Set git branch
python3 task.py set-base-branch <dir> <branch> # Set PR target branch
python3 task.py set-scope <dir> <scope> # Set scope for PR title
python3 task.py set-meta <dir> <key> <value> # Set a task metadata key
python3 task.py archive <task-dir> # Archive completed task
python3 task.py rename <dir> <new-slug> [--dry-run] # Rename task + references
python3 task.py archive <task-dir> [--skip-branch-validation] # Archive completed task
python3 task.py list # List active tasks
python3 task.py list-archive [month] # List archived tasks
python3 task.py add-subtask <parent-dir> <child-dir> # Link child to parent
@@ -27,9 +28,11 @@ from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from common.log import Colors, colored
from common.paths import (
DEVELOPER_HINT,
DIR_WORKFLOW,
DIR_TASKS,
FILE_TASK_JSON,
@@ -44,13 +47,19 @@ from common.active_task import (
resolve_context_key,
set_active_task,
)
from common.io import read_json, write_json
from common.git import current_branch_name
from common.io import (
describe_json_read_failure,
read_json_checked,
write_json,
)
from common.task_utils import resolve_task_dir, run_task_hooks
from common.tasks import iter_active_tasks, children_progress
# Import command handlers from split modules (also re-exports for plan.py compatibility)
from common.task_store import (
cmd_create,
cmd_rename,
cmd_archive,
cmd_set_branch,
cmd_set_base_branch,
@@ -63,6 +72,7 @@ from common.task_context import (
cmd_add_context,
cmd_validate,
cmd_list_context,
curated_entry_count,
)
@@ -70,6 +80,94 @@ from common.task_context import (
# Command: start / finish
# =============================================================================
def _record_start_state(
task_json_path: Path,
repo_root: Path,
label: str = "",
) -> None:
"""Move a freshly started task to in_progress and record its branch.
Both updates share one read/write: the status flip from planning, and the
checked-out branch when `branch` is still empty. Recording at start is what
keeps `branch` trustworthy at archive time a task whose branch is only
ever set by hand tends to reach archive with `branch: null`.
Tolerant on purpose a broken task.json does not fail `start`, because the
session pointer is the point of the command. But the read overwrites the
file it just read, so no failure may be silent: without a message the
absent status line looks like the task simply was not in planning.
"""
data, reason = read_json_checked(task_json_path)
if data is None:
problem, hint = describe_json_read_failure(task_json_path, reason)
print(
colored(f"Warning: {problem}; task.json not updated.", Colors.YELLOW),
file=sys.stderr,
)
print(hint, file=sys.stderr)
return
applied: list[str] = []
if data.get("status") == "planning":
data["status"] = "in_progress"
applied.append(f"✓ Status: planning → in_progress{label}")
# Only fill an empty field: an explicit `set-branch` must survive a later
# `start` (re-starting a task after a checkout is a normal thing to do).
base_branch_conflict: str | None = None
if not data.get("branch"):
branch = current_branch_name(repo_root)
if branch:
data["branch"] = branch
applied.append(f"✓ Branch recorded: {branch}{label}")
if branch == data.get("base_branch"):
base_branch_conflict = branch
else:
print(
colored(
"Note: no checked-out branch (detached HEAD, or not a git "
"repository); task branch not recorded.",
Colors.YELLOW,
),
file=sys.stderr,
)
if not applied:
return
if not write_json(task_json_path, data):
print(
colored(
f"Warning: Failed to write {task_json_path}; "
"status and branch are unchanged.",
Colors.YELLOW,
),
file=sys.stderr,
)
return
for line in applied:
print(colored(line, Colors.GREEN))
if base_branch_conflict:
# Recorded anyway — the value is true, it just cannot describe a PR.
# Archive refuses this shape, so say so now rather than at the gate.
print(
colored(
f"Warning: '{base_branch_conflict}' is also this task's base_branch; "
"a PR cannot target its own branch, and archive will refuse it.",
Colors.YELLOW,
),
file=sys.stderr,
)
print(
f"Once you branch off, run: python3 {DIR_WORKFLOW}/scripts/task.py "
"set-branch <task> <feature-branch>",
file=sys.stderr,
)
def cmd_start(args: argparse.Namespace) -> int:
"""Set active task."""
repo_root = get_repo_root()
@@ -82,11 +180,40 @@ def cmd_start(args: argparse.Namespace) -> int:
# Resolve task directory (supports task name, relative path, or absolute path)
full_path = resolve_task_dir(task_input, repo_root)
if not full_path or not full_path.is_dir():
if full_path is None:
# resolve_task_dir already named the exact reason on stderr. A second,
# generic line on stdout would split one diagnosis across two streams
# and bury the specific message.
return 1
if not full_path.is_dir():
print(colored(f"Error: Task not found: {task_input}", Colors.RED))
print("Hint: Use task name (e.g., 'my-task') or full path (e.g., '.trellis/tasks/01-31-my-task')")
return 1
# Context-manifest gate (#573): a seeded-but-uncurated implement/check
# manifest means every sub-agent dispatched for this task runs with zero
# spec context, and nothing downstream surfaces that to the main session.
# An absent manifest is not gated — create seeds the files only on
# sub-agent-capable platforms, so absence means no sub-agent reads them.
if not getattr(args, "allow_empty_context", False):
empty_manifests = [
name
for name in ("implement.jsonl", "check.jsonl")
if curated_entry_count(full_path / name) == 0
]
if empty_manifests:
print(colored(
f"Error: {' and '.join(empty_manifests)} "
f"{'has' if len(empty_manifests) == 1 else 'have'} no curated entries",
Colors.RED,
))
print("Sub-agents (implement/check) would run with zero spec context.")
print(f" Curate: python3 .trellis/scripts/task.py add-context {task_input} implement <path> \"<why>\"")
print(f" Verify: python3 .trellis/scripts/task.py validate {task_input}")
print(" Intentionally empty? Re-run start with --allow-empty-context")
return 1
# Convert to relative path for storage. repo_root is resolved because
# full_path already is (resolve_task_dir only returns paths inside the
# resolved root), so an unresolved repo_root would mismatch under a
@@ -123,11 +250,7 @@ def cmd_start(args: argparse.Namespace) -> int:
# Still flip task.json status: planning → in_progress so downstream phases proceed.
if task_json_path.is_file():
data = read_json(task_json_path)
if data and data.get("status") == "planning":
data["status"] = "in_progress"
if write_json(task_json_path, data):
print(colored("✓ Status: planning → in_progress (degraded)", Colors.GREEN))
_record_start_state(task_json_path, repo_root, " (degraded)")
run_task_hooks("after_start", task_json_path, repo_root)
return 0
@@ -137,11 +260,7 @@ def cmd_start(args: argparse.Namespace) -> int:
print(f"Source: {active.source}")
if task_json_path.is_file():
data = read_json(task_json_path)
if data and data.get("status") == "planning":
data["status"] = "in_progress"
if write_json(task_json_path, data):
print(colored("✓ Status: planning → in_progress", Colors.GREEN))
_record_start_state(task_json_path, repo_root)
print()
print(colored("The hook will now inject context from this task's jsonl files.", Colors.BLUE))
@@ -181,8 +300,20 @@ def cmd_current(args: argparse.Namespace) -> int:
if getattr(args, "json", False):
task_obj = None
read_error = None
if active.task_path:
data = read_json(repo_root / active.task_path / FILE_TASK_JSON) or {}
task_json_path = repo_root / active.task_path / FILE_TASK_JSON
data, reason = read_json_checked(task_json_path)
if data is None:
# Without this, a corrupt task.json emits null for every field
# — indistinguishable from a task whose fields really are null.
problem, hint = describe_json_read_failure(task_json_path, reason)
read_error = {
"file": str(task_json_path),
"reason": reason,
"message": f"{problem}. {hint}",
}
data = {}
task_obj = {
"dir": active.task_path,
"id": data.get("id") or data.get("name"),
@@ -193,11 +324,15 @@ def cmd_current(args: argparse.Namespace) -> int:
"branch": data.get("branch"),
"base_branch": data.get("base_branch"),
}
print(json.dumps({
payload = {
"current_task": task_obj,
"source": active.source,
"stale": active.stale,
}, ensure_ascii=False))
}
# Only present when the read failed, so the healthy shape is unchanged.
if read_error:
payload["error"] = read_error
print(json.dumps(payload, ensure_ascii=False))
return 0 if active.task_path else 1
if args.source:
@@ -252,7 +387,10 @@ def cmd_list(args: argparse.Namespace) -> int:
if as_json:
if filter_mine and not developer:
print(json.dumps({"error": "No developer set"}), file=sys.stderr)
print(
json.dumps({"error": "No developer set", "hint": DEVELOPER_HINT}),
file=sys.stderr,
)
return 1
items = []
@@ -280,6 +418,7 @@ def cmd_list(args: argparse.Namespace) -> int:
if filter_mine:
if not developer:
print(colored("Error: No developer set. Run init_developer.py first", Colors.RED), file=sys.stderr)
print(DEVELOPER_HINT, file=sys.stderr)
return 1
print(colored(f"My tasks (assignee: {developer}):", Colors.BLUE))
else:
@@ -388,20 +527,21 @@ def show_usage() -> None:
print("""Task Management Script
Usage:
python3 task.py create <title> Create new task directory
python3 task.py create <title> --package <pkg> Create task for a specific package
python3 task.py create <title> --parent <dir> Create task as child of parent
python3 task.py create <title> --no-start Create without making it active in this session
python3 task.py create <title> --description <desc> Create new task directory (both required, non-empty)
python3 task.py create <title> --description <desc> --package <pkg> Create task for a specific package
python3 task.py create <title> --description <desc> --parent <dir> Create task as child of parent
python3 task.py create <title> --description <desc> --no-start Create without making it active in this session
python3 task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl
python3 task.py validate <dir> Validate jsonl files
python3 task.py list-context <dir> List jsonl entries
python3 task.py start <dir> Set active task
python3 task.py start <dir> Set active task; records the checked-out branch when unset
python3 task.py current [--source] Show active task
python3 task.py finish Clear active task
python3 task.py set-branch <dir> <branch> Set git branch
python3 task.py set-base-branch <dir> <branch> Set PR target branch
python3 task.py set-scope <dir> <scope> Set scope for PR title
python3 task.py set-meta <dir> <key> <value> Set/overwrite a task metadata key
python3 task.py rename <dir> <new-slug> Rename task, identity fields and references
python3 task.py archive <task-dir> Archive completed task
python3 task.py add-subtask <parent> <child> Link child task to parent
python3 task.py remove-subtask <parent> <child> Unlink child from parent
@@ -411,22 +551,38 @@ Usage:
Monorepo options:
--package <pkg> Package name (validated against config.yaml packages)
Rename options:
--dry-run Print the change set without writing anything
Archive options:
--no-commit Skip the auto git commit after archiving
--skip-branch-validation Archive despite missing or self-referential branch metadata.
Archive normally refuses a task with no `branch` when it has a
`base_branch` and the repo has a remote, or with
`branch == base_branch`; repair those with `set-branch` /
`set-base-branch` instead. Use this flag only for tasks that
were never PR-backed. A recorded branch that was merged and
deleted is only a warning and needs no flag.
List options:
--mine, -m Show only tasks assigned to current developer
--status, -s <s> Filter by status (planning, in_progress, review, completed)
--json Output machine-readable JSON (also available on `current`)
Examples:
python3 task.py create "Add login feature" --slug add-login
python3 task.py create "Add login feature" --slug add-login --package cli
python3 task.py create "Add login feature" --meta linear=ENG-123 --meta epic=auth
python3 task.py create "Child task" --slug child --parent .trellis/tasks/01-21-parent
python3 task.py create "Add login feature" --description "Email + password sign-in" --slug add-login
python3 task.py create "Add login feature" --description "Email + password sign-in" --slug add-login --package cli
python3 task.py create "Add login feature" --description "Email + password sign-in" --meta linear=ENG-123 --meta epic=auth
python3 task.py create "Child task" --description "Session cookie handling" --slug child --parent .trellis/tasks/01-21-parent
python3 task.py add-context <dir> implement .trellis/spec/cli/backend/auth.md "Auth guidelines"
python3 task.py set-branch <dir> task/add-login
python3 task.py start .trellis/tasks/01-21-add-login
python3 task.py current --source
python3 task.py finish
python3 task.py rename add-login add-sso --dry-run # Preview the change set
python3 task.py rename add-login add-sso
python3 task.py archive add-login
python3 task.py archive add-login --skip-branch-validation # Task never had a branch of its own
python3 task.py add-subtask parent-task child-task # Link existing tasks
python3 task.py remove-subtask parent-task child-task
python3 task.py list # List all active tasks
@@ -479,11 +635,15 @@ def main() -> int:
# create
p_create = subparsers.add_parser("create", help="Create new task")
p_create.add_argument("title", help="Task title")
p_create.add_argument("title", help="Task title (required, non-empty)")
p_create.add_argument("--slug", "-s", help="Task slug without the MM-DD date prefix")
p_create.add_argument("--assignee", "-a", help="Assignee developer")
p_create.add_argument("--priority", "-p", default="P2", help="Priority (P0-P3)")
p_create.add_argument("--description", "-d", help="Task description")
p_create.add_argument(
"--description",
"-d",
help="Task description (required, non-empty — an empty one is refused at archive)",
)
p_create.add_argument("--parent", help="Parent task directory (establishes subtask link)")
p_create.add_argument("--package", help="Package name for monorepo projects")
p_create.add_argument(
@@ -500,6 +660,11 @@ def main() -> int:
action="store_true",
help="Create the task without making it active in this session",
)
p_create.add_argument(
"--force",
action="store_true",
help="Overwrite task.json when the task directory already exists",
)
# add-context
p_add = subparsers.add_parser("add-context", help="Add context entry")
@@ -519,6 +684,11 @@ def main() -> int:
# start
p_start = subparsers.add_parser("start", help="Set active task")
p_start.add_argument("dir", help="Task directory")
p_start.add_argument(
"--allow-empty-context",
action="store_true",
help="Start even when implement.jsonl / check.jsonl have no curated entries",
)
# current
p_current = subparsers.add_parser("current", help="Show active task")
@@ -551,10 +721,28 @@ def main() -> int:
p_setmeta.add_argument("key", help="Metadata key")
p_setmeta.add_argument("value", help="Metadata value")
# rename
p_rename = subparsers.add_parser("rename", help="Rename task and its references")
p_rename.add_argument("name", help="Task directory or name")
p_rename.add_argument("new_slug", help="New slug without the MM-DD date prefix")
p_rename.add_argument(
"--dry-run",
action="store_true",
help="Print the change set without writing anything",
)
# archive
p_archive = subparsers.add_parser("archive", help="Archive task")
p_archive.add_argument("name", help="Task directory or name")
p_archive.add_argument("--no-commit", action="store_true", help="Skip auto git commit after archive")
p_archive.add_argument(
"--skip-branch-validation",
action="store_true",
help=(
"Archive even when branch metadata is missing or self-referential "
"(for tasks that were never PR-backed)"
),
)
# list
p_list = subparsers.add_parser("list", help="List tasks")
@@ -594,6 +782,7 @@ def main() -> int:
"set-base-branch": cmd_set_base_branch,
"set-scope": cmd_set_scope,
"set-meta": cmd_set_meta,
"rename": cmd_rename,
"archive": cmd_archive,
"add-subtask": cmd_add_subtask,
"remove-subtask": cmd_remove_subtask,
+14 -2
View File
@@ -52,8 +52,10 @@ python3 ./.trellis/scripts/task.py list [--mine] [--status <s>]
python3 ./.trellis/scripts/task.py list-archive
# Code-spec context (injected into implement/check agents via JSONL).
# `implement.jsonl` / `check.jsonl` are seeded on `task create` for sub-agent-capable
# platforms; the AI curates real spec + research entries during planning when needed.
# `implement.jsonl` / `check.jsonl` are seeded (empty) on `task create` for sub-agent-capable
# platforms; the AI curates real spec + research entries during planning. `validate` fails
# and `start` refuses while a seeded manifest is still empty — sub-agents would run with
# zero spec context. Pass `start --allow-empty-context` when that is intentional.
python3 ./.trellis/scripts/task.py add-context <name> <action> <file> <reason>
python3 ./.trellis/scripts/task.py list-context <name> [action]
python3 ./.trellis/scripts/task.py validate <name>
@@ -119,6 +121,7 @@ python3 ./.trellis/scripts/get_context.py --mode phase --step <X.Y> # detailed
TAG ↔ PHASE scoping:
[workflow-state:no_task] → no active task; before Phase 1
[workflow-state:task_error] → active task record is unreadable; repair it before continuing
[workflow-state:planning] → all of Phase 1 (status='planning')
[workflow-state:planning-inline] → Codex inline variant of Phase 1
[workflow-state:in_progress] → Phase 2 + Phase 3.2-3.4
@@ -179,6 +182,14 @@ Simple conversation / small task: ask only whether this turn should create a Tre
Complex task: ask the user if you can create a Trellis task and enter the planning phase. If the user says no, explain, clarify scope, or suggest a smaller split.
[/workflow-state:no_task]
<!-- Per-turn breadcrumb: shown when the active task record cannot be read. -->
[workflow-state:task_error]
The active task record could not be read. Do not create or activate another task.
Inspect the task directory named above and repair its task.json. It must be a valid JSON object with a non-empty status.
Preserve existing task fields and artifacts. If the correct status cannot be determined safely, ask the user before reconstructing the record.
[/workflow-state:task_error]
### Phase 1: Plan
- 1.0 Create task `[required · once]` (only after task-creation consent)
- 1.1 Requirement exploration `[required · repeatable]` (`prd.md`; complex tasks also need `design.md` + `implement.md`)
@@ -660,6 +671,7 @@ All tag blocks live in the `## Phase Index` section above, immediately after eac
| Scope | Corresponding tag |
|---|---|
| No active task (before Phase 1) | `[workflow-state:no_task]` (after the Phase Index ASCII art) |
| Active task record unreadable | `[workflow-state:task_error]` (repair the existing task before continuing) |
| All of Phase 1 (task created → ready for implementation) | `[workflow-state:planning]` (after Phase 1 summary) |
| Codex inline Phase 1 | `[workflow-state:planning-inline]` |
| Phase 2 + Phase 3.23.4 (implementation + check + wrap-up) | `[workflow-state:in_progress]` (after Phase 2 summary) |
-2
View File
@@ -101,7 +101,6 @@ Codebase reading roles:
Skills live in `~/.codex/skills/` (personal) and `.codex/skills/` (project-shared). Before starting a task, scan available skills. If one matches, read its `SKILL.md` and follow it. Announce which skill you're using.
<!-- TRELLIS:START -->
# Trellis Instructions
These instructions are for AI assistants working in this project.
@@ -116,7 +115,6 @@ This project is managed by Trellis. The working knowledge you need lives under `
If a Trellis command is available on your platform (e.g. `/trellis:finish-work`, `/trellis:continue`), prefer it over manual steps. Not every platform exposes every command.
If you're using Codex or another agent-capable tool, additional project-scoped helpers may live in:
- `.agents/skills/` — reusable Trellis skills
- `.codex/agents/` — optional custom subagents