mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
update: trellis
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Regular → Executable
+47
-9
@@ -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)
|
||||
|
||||
Regular → Executable
+20
-7
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
0.6.15
|
||||
0.6.16
|
||||
+943
-79
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
+1008
-119
File diff suppressed because it is too large
Load Diff
@@ -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)}")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
@@ -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
@@ -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.2–3.4 (implementation + check + wrap-up) | `[workflow-state:in_progress]` (after Phase 2 summary) |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user