feat: make crosswalk and stop-line offsets solvable
Step 1 of the control-marking task, and deliberately server-only: no handle is drawn yet. This project already shipped a range handle for `profile.interval`, which compileGeometry ignores, so the control dragged and changed nothing. The consumer comes first now. Two kinds join the taxonomy — `junction-crosswalk-inset` and `junction-stop-line-offset`, both on the existing `junction-approach` anchor. The solver writes them onto the approach entry, `applyDirectJunctionPlans` carries them onto the compiled approach, and `compileControlMarkings` reads them in place of the module constants it used for every junction. They move markings without reshaping the junction, so unlike width and cutback they deliberately do not trigger a boundary recompute. `applyJunctionConstraint` becomes an explicit switch. Its trailing `else` had meant every kind that was not approach-width fell through to the cutback validator, so a new kind would have been silently validated and written as a cutback. The same non-exhaustive shape in the test fixture's `valueFor` is fixed the same way, and now throws for an unnamed kind rather than answering with a corner radius. design.md's taxonomy is updated with it — a test asserts the two cannot drift, which is what caught the omission. Measured on a 41-road workspace with 8 crossings: both constraints change their marking geometry, neither drags the other, and out-of-range blocks instead of clamping. That measurement is not in the suite: the synthetic junction resolves `junction_inset_m` to 0 because its crossing never binds to a plan, and the committed OSM fixture has no crossings at all. The tests assert the wiring the handles will depend on — values reaching the approach entry, distinct branches, blocking diagnostics — and the gap is recorded in the test itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
32
.trellis/.gitignore
vendored
Normal file
32
.trellis/.gitignore
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Developer identity (local only)
|
||||||
|
.developer
|
||||||
|
|
||||||
|
# Current task pointer (each dev works on different task)
|
||||||
|
.current-task
|
||||||
|
|
||||||
|
# Session/window scoped runtime state
|
||||||
|
.runtime/
|
||||||
|
|
||||||
|
# Ralph Loop state file
|
||||||
|
.ralph-state.json
|
||||||
|
|
||||||
|
# Agent runtime files
|
||||||
|
.agents/
|
||||||
|
.agent-log
|
||||||
|
.session-id
|
||||||
|
|
||||||
|
# Task directory runtime files
|
||||||
|
.plan-log
|
||||||
|
|
||||||
|
# Atomic update temp files
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# Update backup directories
|
||||||
|
.backup-*
|
||||||
|
|
||||||
|
# Conflict resolution temp files
|
||||||
|
*.new
|
||||||
|
|
||||||
|
# Python cache
|
||||||
|
**/__pycache__/
|
||||||
|
**/*.pyc
|
||||||
145
.trellis/.template-hashes.json
Normal file
145
.trellis/.template-hashes.json
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
{
|
||||||
|
"__version": 2,
|
||||||
|
"hashes": {
|
||||||
|
".claude/agents/trellis-check.md": "9e48342243f311d55386f8fb42933945e87aba73d5ade547133aa98345a06128",
|
||||||
|
".claude/agents/trellis-implement.md": "73b56b3047c0e852382c4630aa181fb847d1e5ea1f63459dac4bf728f43fd097",
|
||||||
|
".claude/agents/trellis-research.md": "add4aa4259ded425b04ec992802c646490352bb4eb3730a7ab450beea87d4faa",
|
||||||
|
".claude/settings.json": "1a65892b2b161910468970ab30ebc3f8216241640f75fccbe4be5552c58a2752",
|
||||||
|
".claude/commands/trellis/continue.md": "6c34c41824f8eff4b2df792e032e0c36787b59f8a8879b0338347073f76ad52c",
|
||||||
|
".claude/commands/trellis/finish-work.md": "d6aa570ab684f57e4845de2d84a1ff6d9f0908e04c5a56e14fd70ae739c369fc",
|
||||||
|
".claude/skills/trellis-before-dev/SKILL.md": "00c9d1c83bc318e91b27a019bc954c2957cfad907ff20d854b596f16cad2c0f3",
|
||||||
|
".claude/skills/trellis-brainstorm/SKILL.md": "a0f226ddcb8a3e846acd2a35d121996e9ca55165ce76202095d0b65e2b48a5e8",
|
||||||
|
".claude/skills/trellis-break-loop/SKILL.md": "f5a93699832f29dee443b53c135a7459519b371f689af191d15c29e8ee5c7bde",
|
||||||
|
".claude/skills/trellis-check/SKILL.md": "dfb0600e95c19c7a83200465b6a92a5514ec9778ba4083efa881bed541cc74a8",
|
||||||
|
".claude/skills/trellis-update-spec/SKILL.md": "d975db7af166578488958751ae2c56edb827a68bddb569aa27acc3453f64e610",
|
||||||
|
".claude/skills/trellis-channel/references/command-reference.md": "c4df3d940d89814310bfdaded20e84dd1d6b96786f6772688de7a123338d3c8e",
|
||||||
|
".claude/skills/trellis-channel/references/forum.md": "407db39e8ebde47cea8637e3069bdb3d76ba42b43ed1d08ba97a56f26dbcfcb4",
|
||||||
|
".claude/skills/trellis-channel/references/progress-debugging.md": "93b021fd8d5d3d4ee54952c40424ab91ca57c2ab8032bfbfc3b5c5e22f1464cd",
|
||||||
|
".claude/skills/trellis-channel/references/workers.md": "d4481b2858bca82050cb8d131916da8a2ccc3857f199cdb9ced8bb41d592e2a7",
|
||||||
|
".claude/skills/trellis-channel/references/workflows.md": "0782d602efde6e94a7d7d7950fd605eedc9a1f5a523908284e76b628fd618fd4",
|
||||||
|
".claude/skills/trellis-channel/SKILL.md": "ff84801c511a736da018b4b1149348ebc9a16b7fc18d905ec10cfa7acbf269a3",
|
||||||
|
".claude/skills/trellis-meta/references/customize-local/add-project-local-conventions.md": "86009ccb5d0373f399582da0bc570c4e5c6053c3c764857424ff93384f0e04e5",
|
||||||
|
".claude/skills/trellis-meta/references/customize-local/change-agents.md": "4216ac3cc570038fd8ce3319a85932bb537fea9e0a7f4d11fa315cfa645c9c85",
|
||||||
|
".claude/skills/trellis-meta/references/customize-local/change-context-loading.md": "350d319dc1ab99609ddbf52cf8c06c71bd97ba1a29ce2eac8b97d0bb192938bf",
|
||||||
|
".claude/skills/trellis-meta/references/customize-local/change-hooks.md": "4c18b134f05d5ba1609c517ec94c97c9442b5f0670aa2211c487c31ed2a4f358",
|
||||||
|
".claude/skills/trellis-meta/references/customize-local/change-skills-or-commands.md": "5b942e9f512e049a75e8dd9e8cc4d49786f6da61b22afc82bb447acf808f9fe2",
|
||||||
|
".claude/skills/trellis-meta/references/customize-local/change-spec-structure.md": "31eccaad7097d96e66a45c1b4caea1ba4f2e54b7814184c3ebf82c87dafc4841",
|
||||||
|
".claude/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "60ff9efb93604b87a461a4af30322d76750402a51e40f31531a7ff88d309996d",
|
||||||
|
".claude/skills/trellis-meta/references/customize-local/change-workflow.md": "43fa780a2ca580de121b10893d49b99f978873deebbf45008c466e5ac6651519",
|
||||||
|
".claude/skills/trellis-meta/references/customize-local/overview.md": "ce8f09e9f93ce9a48500763fb3a4db2b3908a5fbf4f985ab71dacebb404cf8f4",
|
||||||
|
".claude/skills/trellis-meta/references/local-architecture/bundled-skills.md": "aa6a0bf83060205ee4ea621c467fb900a7db06b4476a4ad472cc4e248c887389",
|
||||||
|
".claude/skills/trellis-meta/references/local-architecture/context-injection.md": "8497289bf333b3aa456f317039d1239b7ece79254aa0eb62cfc647714c866084",
|
||||||
|
".claude/skills/trellis-meta/references/local-architecture/generated-files.md": "7eb2d452eddb4f4226f7578c2ec6d5ee0434ed172ba4c36107cc8bdff7554dc6",
|
||||||
|
".claude/skills/trellis-meta/references/local-architecture/multi-agent-channel.md": "56e5070474aeca872e2d70c46feea5aaafecd3d3ec052c3f7b1877358dca62e9",
|
||||||
|
".claude/skills/trellis-meta/references/local-architecture/overview.md": "50638fd9eaaba2e0edf2f2a84d920578d5b2fb7934031b174e417d3dc510b2a6",
|
||||||
|
".claude/skills/trellis-meta/references/local-architecture/spec-system.md": "b8d8a6a0888b44a232c8f50161b9e20e903cf621ad7be4021715ab6fab226f47",
|
||||||
|
".claude/skills/trellis-meta/references/local-architecture/task-system.md": "2b561d49c390f7d0db5391912946133be4bf73189231e2b8cc9afa1c5ac6165a",
|
||||||
|
".claude/skills/trellis-meta/references/local-architecture/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6",
|
||||||
|
".claude/skills/trellis-meta/references/local-architecture/workspace-memory.md": "e6427b46aba744563c2444b30df4043cd856561b7709ec2dece26095416421fd",
|
||||||
|
".claude/skills/trellis-meta/references/platform-files/agents.md": "b806b1f0de6dfc74720014aee15ede767a58925b2b3b964f2e557ed39a65bbc0",
|
||||||
|
".claude/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "91e9855e418673bcd7770411e24e28390738b123532eef000beba0a29cf154e1",
|
||||||
|
".claude/skills/trellis-meta/references/platform-files/overview.md": "1aec9087ccedd56a213af877b5db474131aa4b4789b6ac0e8da73b058aa43bd4",
|
||||||
|
".claude/skills/trellis-meta/references/platform-files/platform-map.md": "3c0d4546461c06d1aaf006276e34e250e0ca7bbb5b853fb45c58114cebca4042",
|
||||||
|
".claude/skills/trellis-meta/references/platform-files/skills-and-commands.md": "e39831d860bd27a04e7757f7bd941ab83b3c5b11ad4460cec03975b655f26cc3",
|
||||||
|
".claude/skills/trellis-meta/SKILL.md": "ee7bcfd0023def62688339c4b63c50f2fbeea12a34eb89e8bb63a992b58cb168",
|
||||||
|
".claude/skills/trellis-session-insight/references/cli-quick-reference.md": "c520353fe3fc00b9702ed4f780647c8fbd6d342e66527a03647f533a9fe09779",
|
||||||
|
".claude/skills/trellis-session-insight/references/triggering-patterns.md": "121ecd23be83d1567e8ce15c366a81073d7a2b1d3ad616fce235c07ca1f1cc20",
|
||||||
|
".claude/skills/trellis-session-insight/SKILL.md": "d20f1d20c6946e26ba6f848b9a016d9a16c803d87501462e5b11a2ced6f56643",
|
||||||
|
".claude/skills/trellis-spec-bootstrap/references/mcp-setup.md": "df542fc8f279edd38046d26a7c8151804b708f57b24d4aa2733cea587a88c65e",
|
||||||
|
".claude/skills/trellis-spec-bootstrap/references/repository-analysis.md": "0dae98d774f6e34559b9f3442888ac43e3a8af110c37cbefc49ce256986858b6",
|
||||||
|
".claude/skills/trellis-spec-bootstrap/references/spec-task-planning.md": "ef493d028c3b0807a8a534bb71fb92a68129f273db763ad27ceb464a522e799d",
|
||||||
|
".claude/skills/trellis-spec-bootstrap/references/spec-writing.md": "e9800fe9ed4a4cd87062ea1829cf2caa8d170ec15e141678a6a30e74c497f47d",
|
||||||
|
".claude/skills/trellis-spec-bootstrap/SKILL.md": "97bfa68c06cebb558eb4464bc1b81f7d2d56040d75baa8de1ee5ad90cca0196a",
|
||||||
|
".claude/hooks/inject-subagent-context.py": "7c2c5640445ea0d98ce6a13126ea105ec4b02ca661ef4a7711240b595efefbbf",
|
||||||
|
".claude/hooks/inject-workflow-state.py": "89cef2b197dd61a731b946114207d2601b240058941ccf28622ee211d9c62eb3",
|
||||||
|
".claude/hooks/session-start.py": "176db5b725d4890b201559bba5dce0fb75b5ab6b290c420b691d05f3dc203d3a",
|
||||||
|
".claude/hooks/statusline.py": "edbd8d1443ac8e7bb4f8cce61cb21812a8b0e17c59e1bc5c0e3ad03ea1f69adb",
|
||||||
|
".agents/skills/trellis-continue/SKILL.md": "7723ccf49fbf19d8f086cacc7a080bd8be8db6fc70a32908b80f68efa318d7bf",
|
||||||
|
".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-break-loop/SKILL.md": "f5a93699832f29dee443b53c135a7459519b371f689af191d15c29e8ee5c7bde",
|
||||||
|
".agents/skills/trellis-check/SKILL.md": "dfb0600e95c19c7a83200465b6a92a5514ec9778ba4083efa881bed541cc74a8",
|
||||||
|
".agents/skills/trellis-update-spec/SKILL.md": "003ce08a3404aeb50998029392c4d4e57b626edf526d3ebd585032bb92dcbb96",
|
||||||
|
".agents/skills/trellis-channel/references/command-reference.md": "c4df3d940d89814310bfdaded20e84dd1d6b96786f6772688de7a123338d3c8e",
|
||||||
|
".agents/skills/trellis-channel/references/forum.md": "407db39e8ebde47cea8637e3069bdb3d76ba42b43ed1d08ba97a56f26dbcfcb4",
|
||||||
|
".agents/skills/trellis-channel/references/progress-debugging.md": "93b021fd8d5d3d4ee54952c40424ab91ca57c2ab8032bfbfc3b5c5e22f1464cd",
|
||||||
|
".agents/skills/trellis-channel/references/workers.md": "d4481b2858bca82050cb8d131916da8a2ccc3857f199cdb9ced8bb41d592e2a7",
|
||||||
|
".agents/skills/trellis-channel/references/workflows.md": "0782d602efde6e94a7d7d7950fd605eedc9a1f5a523908284e76b628fd618fd4",
|
||||||
|
".agents/skills/trellis-channel/SKILL.md": "ff84801c511a736da018b4b1149348ebc9a16b7fc18d905ec10cfa7acbf269a3",
|
||||||
|
".agents/skills/trellis-meta/references/customize-local/add-project-local-conventions.md": "86009ccb5d0373f399582da0bc570c4e5c6053c3c764857424ff93384f0e04e5",
|
||||||
|
".agents/skills/trellis-meta/references/customize-local/change-agents.md": "4216ac3cc570038fd8ce3319a85932bb537fea9e0a7f4d11fa315cfa645c9c85",
|
||||||
|
".agents/skills/trellis-meta/references/customize-local/change-context-loading.md": "350d319dc1ab99609ddbf52cf8c06c71bd97ba1a29ce2eac8b97d0bb192938bf",
|
||||||
|
".agents/skills/trellis-meta/references/customize-local/change-hooks.md": "4c18b134f05d5ba1609c517ec94c97c9442b5f0670aa2211c487c31ed2a4f358",
|
||||||
|
".agents/skills/trellis-meta/references/customize-local/change-skills-or-commands.md": "5b942e9f512e049a75e8dd9e8cc4d49786f6da61b22afc82bb447acf808f9fe2",
|
||||||
|
".agents/skills/trellis-meta/references/customize-local/change-spec-structure.md": "31eccaad7097d96e66a45c1b4caea1ba4f2e54b7814184c3ebf82c87dafc4841",
|
||||||
|
".agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "60ff9efb93604b87a461a4af30322d76750402a51e40f31531a7ff88d309996d",
|
||||||
|
".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/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/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6",
|
||||||
|
".agents/skills/trellis-meta/references/local-architecture/workspace-memory.md": "e6427b46aba744563c2444b30df4043cd856561b7709ec2dece26095416421fd",
|
||||||
|
".agents/skills/trellis-meta/references/platform-files/agents.md": "b806b1f0de6dfc74720014aee15ede767a58925b2b3b964f2e557ed39a65bbc0",
|
||||||
|
".agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "91e9855e418673bcd7770411e24e28390738b123532eef000beba0a29cf154e1",
|
||||||
|
".agents/skills/trellis-meta/references/platform-files/overview.md": "1aec9087ccedd56a213af877b5db474131aa4b4789b6ac0e8da73b058aa43bd4",
|
||||||
|
".agents/skills/trellis-meta/references/platform-files/platform-map.md": "3c0d4546461c06d1aaf006276e34e250e0ca7bbb5b853fb45c58114cebca4042",
|
||||||
|
".agents/skills/trellis-meta/references/platform-files/skills-and-commands.md": "e39831d860bd27a04e7757f7bd941ab83b3c5b11ad4460cec03975b655f26cc3",
|
||||||
|
".agents/skills/trellis-meta/SKILL.md": "ee7bcfd0023def62688339c4b63c50f2fbeea12a34eb89e8bb63a992b58cb168",
|
||||||
|
".agents/skills/trellis-session-insight/references/cli-quick-reference.md": "c520353fe3fc00b9702ed4f780647c8fbd6d342e66527a03647f533a9fe09779",
|
||||||
|
".agents/skills/trellis-session-insight/references/triggering-patterns.md": "121ecd23be83d1567e8ce15c366a81073d7a2b1d3ad616fce235c07ca1f1cc20",
|
||||||
|
".agents/skills/trellis-session-insight/SKILL.md": "d20f1d20c6946e26ba6f848b9a016d9a16c803d87501462e5b11a2ced6f56643",
|
||||||
|
".agents/skills/trellis-spec-bootstrap/references/mcp-setup.md": "df542fc8f279edd38046d26a7c8151804b708f57b24d4aa2733cea587a88c65e",
|
||||||
|
".agents/skills/trellis-spec-bootstrap/references/repository-analysis.md": "0dae98d774f6e34559b9f3442888ac43e3a8af110c37cbefc49ce256986858b6",
|
||||||
|
".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/hooks.json": "85a58ba7cdf1e19e7f75ddcc64e5680180c487ca266a74bd5005f31abeee2e02",
|
||||||
|
".codex/config.toml": "9f2d20e28f0bc9c886312eca3ad3bba41533ef4615aaaafe25e98152302267bb",
|
||||||
|
"AGENTS.md": "6cacfe99748b435d0660c2463c697bc323d53798aecf3492283ca8eac1b29682",
|
||||||
|
".trellis/agents/check.md": "edb4f57361407249a53bf5998ebf91c40d2b969e826a2c5e1b4e813a08bcb175",
|
||||||
|
".trellis/agents/implement.md": "66e25ad046c94869442834bc3cdfbd5a9a7412d3ff54561d64d2886552c27e87",
|
||||||
|
".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/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/git_context.py": "fa30ced454f1a91ffc9f8b2abeb32225e3447cbdc90bad783797374eba07265d",
|
||||||
|
".trellis/scripts/common/io.py": "75648caae03d5b1107d7aeccaa785d133b25762266e54a520d90ca8c76b43bdb",
|
||||||
|
".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/session_context.py": "3379ef1766e4e5ca77cbb7c040dbba3883fcca2548580299e3b38dbf22f4f7d5",
|
||||||
|
".trellis/scripts/common/task_context.py": "6fc3abb9e483043bc8cc3477ae48e5503399402009fbfc58db80809472c5690b",
|
||||||
|
".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/types.py": "9962081cc2608fb9d1deb32c6880e336f62cdca6b338e7ae813304701e155ee9",
|
||||||
|
".trellis/scripts/common/workflow_phase.py": "79ee522de20246acf1e2c222e8ad180ad25aaec7fec98214a93d9e81b350d9a8",
|
||||||
|
".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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
.trellis/.version
Normal file
1
.trellis/.version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
0.6.15
|
||||||
70
.trellis/agents/check.md
Normal file
70
.trellis/agents/check.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
name: check
|
||||||
|
description: |
|
||||||
|
Code quality auditor for the Trellis channel runtime. Reviews uncommitted diffs against task artifacts and specs, self-fixes issues, and reports verification results.
|
||||||
|
provider: claude
|
||||||
|
labels: [trellis, check]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Check Agent (channel runtime)
|
||||||
|
|
||||||
|
You are the Check Agent spawned by `trellis channel spawn --agent check` inside the Trellis channel runtime. You receive an `Active task: <path>` line in your inbox; use it to locate task artifacts on disk.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Before reviewing, read in this order:
|
||||||
|
|
||||||
|
1. `<task-path>/check.jsonl` if present — spec manifest curated for this turn; read every listed file
|
||||||
|
2. `<task-path>/prd.md` — requirements
|
||||||
|
3. `<task-path>/design.md` if present — technical design
|
||||||
|
4. `<task-path>/implement.md` if present — execution plan
|
||||||
|
5. `.trellis/spec/` — project-wide guidelines (load only what is relevant to the diff under review)
|
||||||
|
|
||||||
|
## Core Responsibilities
|
||||||
|
|
||||||
|
1. **Get the diff** — `git diff` / `git diff --staged` for uncommitted changes
|
||||||
|
2. **Review against task artifacts** — does the diff satisfy `prd.md` (and `design.md` / `implement.md` if present)?
|
||||||
|
3. **Review against specs** — naming, structure, type safety, error handling, conventions in `.trellis/spec/`
|
||||||
|
4. **Self-fix** — when an issue is mechanical and small, fix it directly with the editing tools you have
|
||||||
|
5. **Run verification** — project lint and typecheck on the changed scope
|
||||||
|
6. **Report** — concrete findings with `file:line` citations and what was fixed vs. what is open
|
||||||
|
|
||||||
|
## Forbidden Operations
|
||||||
|
|
||||||
|
- `git commit`
|
||||||
|
- `git push`
|
||||||
|
- `git merge`
|
||||||
|
|
||||||
|
The supervising main session owns commits. Report the post-fix state; do not commit on its behalf.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Run `git diff --name-only` and `git diff` to scope the changes
|
||||||
|
2. Read the task artifacts and relevant spec files
|
||||||
|
3. For each issue:
|
||||||
|
- If mechanical (lint nit, missing type, wrong import, dead branch) → fix in-place
|
||||||
|
- If a design/judgment issue → record and report, do not silently rewrite
|
||||||
|
4. Run the project's lint and typecheck on the changed scope after self-fixes
|
||||||
|
5. Report
|
||||||
|
|
||||||
|
## Report Format
|
||||||
|
|
||||||
|
```
|
||||||
|
## Self-Check Complete
|
||||||
|
|
||||||
|
### Files Checked
|
||||||
|
- <path>
|
||||||
|
|
||||||
|
### Issues Found and Fixed
|
||||||
|
1. `<file>:<line>` — <what was wrong> → <what you changed>
|
||||||
|
|
||||||
|
### Issues Not Fixed
|
||||||
|
- `<file>:<line>` — <issue> — <why deferred to the main session>
|
||||||
|
|
||||||
|
### Verification Results
|
||||||
|
- TypeCheck: <pass|fail|skipped + reason>
|
||||||
|
- Lint: <pass|fail|skipped + reason>
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
Checked <N> files, found <X> issues, fixed <Y>, <X-Y> open.
|
||||||
|
```
|
||||||
71
.trellis/agents/implement.md
Normal file
71
.trellis/agents/implement.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
name: implement
|
||||||
|
description: |
|
||||||
|
Code implementation expert for the Trellis channel runtime. Understands specs and task artifacts, then implements features. No git commit allowed.
|
||||||
|
provider: claude
|
||||||
|
labels: [trellis, implement]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Implement Agent (channel runtime)
|
||||||
|
|
||||||
|
You are the Implement Agent spawned by `trellis channel spawn --agent implement` inside the Trellis channel runtime. You receive an `Active task: <path>` line in your inbox; use it to locate task artifacts on disk.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Before implementing, read in this order:
|
||||||
|
|
||||||
|
1. `<task-path>/implement.jsonl` if present — spec manifest curated for this turn; read every listed file
|
||||||
|
2. `<task-path>/prd.md` — requirements
|
||||||
|
3. `<task-path>/design.md` if present — technical design
|
||||||
|
4. `<task-path>/implement.md` if present — execution plan
|
||||||
|
5. `.trellis/spec/` — project-wide guidelines (load only what is relevant to the diff you are about to write)
|
||||||
|
|
||||||
|
## Core Responsibilities
|
||||||
|
|
||||||
|
1. **Understand specs** — read relevant spec files in `.trellis/spec/`
|
||||||
|
2. **Understand task artifacts** — read the artifacts listed above
|
||||||
|
3. **Implement features** — write code that follows specs and existing patterns
|
||||||
|
4. **Self-check** — run lint and typecheck on the changed scope before reporting
|
||||||
|
|
||||||
|
## Forbidden Operations
|
||||||
|
|
||||||
|
- `git commit`
|
||||||
|
- `git push`
|
||||||
|
- `git merge`
|
||||||
|
|
||||||
|
The supervising main session owns commits. Report what changed; do not commit on its behalf.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Read relevant specs based on task type and the files in `implement.jsonl` if present
|
||||||
|
2. Read the task's `prd.md`, `design.md` if present, and `implement.md` if present
|
||||||
|
3. Implement features following specs and existing patterns
|
||||||
|
4. Run the project's lint and typecheck commands on the changed scope
|
||||||
|
5. Report files touched, key decisions, and verification results back to the channel
|
||||||
|
|
||||||
|
## Code Standards
|
||||||
|
|
||||||
|
- Follow existing code patterns
|
||||||
|
- Don't add unnecessary abstractions
|
||||||
|
- Only do what the PRD asks for; no speculative scope expansion
|
||||||
|
- Surface uncertainty back to the channel rather than guessing
|
||||||
|
|
||||||
|
## Report Format
|
||||||
|
|
||||||
|
```
|
||||||
|
## Implementation Complete
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
- <path> — <one-line description>
|
||||||
|
|
||||||
|
### Implementation Summary
|
||||||
|
1. <step>
|
||||||
|
2. <step>
|
||||||
|
|
||||||
|
### Verification Results
|
||||||
|
- Lint: <pass|fail|skipped + reason>
|
||||||
|
- TypeCheck: <pass|fail|skipped + reason>
|
||||||
|
|
||||||
|
### Open Questions
|
||||||
|
- <if any, otherwise omit>
|
||||||
|
```
|
||||||
158
.trellis/config.yaml
Normal file
158
.trellis/config.yaml
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# Trellis Configuration
|
||||||
|
# Project-level settings for the Trellis workflow system
|
||||||
|
#
|
||||||
|
# All values have sensible defaults. Only override what you need.
|
||||||
|
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
# Session Recording
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Commit message used when auto-committing journal/index changes
|
||||||
|
# after running add_session.py
|
||||||
|
session_commit_message: "chore: record journal"
|
||||||
|
|
||||||
|
# Maximum lines per journal file before rotating to a new one
|
||||||
|
max_journal_lines: 2000
|
||||||
|
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
# Session Auto-Commit
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Auto-commit behavior for session journal + task archive operations.
|
||||||
|
# - true (default): scripts auto-stage and auto-commit journal / task changes
|
||||||
|
# after add_session.py / task.py archive runs.
|
||||||
|
# - false: scripts do not touch git. Files (journal-*.md, task archive moves)
|
||||||
|
# are still written to disk; you decide whether to git add / commit.
|
||||||
|
#
|
||||||
|
# Use `false` if your project's .gitignore intentionally excludes `.trellis/`
|
||||||
|
# and you want session data kept local-only, or if you prefer to review
|
||||||
|
# staged changes manually before each commit.
|
||||||
|
#
|
||||||
|
# Accepts: true / false / yes / no / 1 / 0 / on / off (case-insensitive).
|
||||||
|
#
|
||||||
|
# session_auto_commit: true
|
||||||
|
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
# Task Lifecycle Hooks
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Shell commands to run after task lifecycle events.
|
||||||
|
# Each hook receives TASK_JSON_PATH environment variable pointing to task.json.
|
||||||
|
# Hook failures print a warning but do not block the main operation.
|
||||||
|
#
|
||||||
|
# hooks:
|
||||||
|
# after_create:
|
||||||
|
# - "echo 'Task created'"
|
||||||
|
# after_start:
|
||||||
|
# - "echo 'Task started'"
|
||||||
|
# after_finish:
|
||||||
|
# - "echo 'Task finished'"
|
||||||
|
# after_archive:
|
||||||
|
# - "echo 'Task archived'"
|
||||||
|
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
# Monorepo / Packages
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Declare packages for monorepo projects.
|
||||||
|
# Trellis auto-detects workspaces during `trellis init`, but you can also
|
||||||
|
# configure them manually here.
|
||||||
|
#
|
||||||
|
# packages:
|
||||||
|
# frontend:
|
||||||
|
# path: packages/frontend
|
||||||
|
# backend:
|
||||||
|
# path: packages/backend
|
||||||
|
# docs:
|
||||||
|
# path: docs-site
|
||||||
|
# type: submodule
|
||||||
|
# # For polyrepo / meta-repo layouts (independent .git in each subdir),
|
||||||
|
# # mark the package with `git: true`. The runtime treats it as an
|
||||||
|
# # independent repository for things like git-context display.
|
||||||
|
# webapp:
|
||||||
|
# path: ./webapp
|
||||||
|
# git: true
|
||||||
|
|
||||||
|
# Default package used when --package is not specified.
|
||||||
|
# default_package: frontend
|
||||||
|
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
# Channel worker OOM guard
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
# Default safeguards for `trellis channel spawn` workers. The guard runs
|
||||||
|
# at spawn time (cleans expired idle workers, then enforces the live-worker
|
||||||
|
# budget) and inside each supervisor (self-terminates a worker that stays
|
||||||
|
# continuously idle past `idle_timeout`).
|
||||||
|
#
|
||||||
|
# Precedence: CLI flag > env var (TRELLIS_CHANNEL_WORKER_IDLE_TIMEOUT /
|
||||||
|
# TRELLIS_CHANNEL_MAX_LIVE_WORKERS) > this config > built-in default.
|
||||||
|
#
|
||||||
|
# `idle_timeout: 0` disables idle cleanup (workers can sit idle forever
|
||||||
|
# unless explicitly killed or given `--timeout`).
|
||||||
|
# `max_live_workers: 0` disables the spawn-time budget check.
|
||||||
|
#
|
||||||
|
# `trusted_context_dirs` extends the `--file`/`--jsonl`/`--agent` containment
|
||||||
|
# check beyond the worker cwd — useful when `.trellis/tasks` or
|
||||||
|
# `.trellis/workspace` is a symlink to an external directory. Realpaths under
|
||||||
|
# any listed dir are accepted in addition to cwd.
|
||||||
|
# `auto_trust_trellis_symlinks: false` disables the narrow auto-trust of
|
||||||
|
# `.trellis/tasks` / `.trellis/workspace` when either is itself a top-level
|
||||||
|
# symlink (auto-trust is on by default).
|
||||||
|
#
|
||||||
|
channel:
|
||||||
|
worker_guard:
|
||||||
|
idle_timeout: 5m
|
||||||
|
max_live_workers: 6
|
||||||
|
# trusted_context_dirs:
|
||||||
|
# - /work/user/trellis_workspace
|
||||||
|
# auto_trust_trellis_symlinks: false
|
||||||
|
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
# Codex (dispatch behavior)
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
# Codex-only knob; other platforms ignore it. Default ("auto") dispatches
|
||||||
|
# trellis-implement / trellis-check / trellis-research sub-agents. This does
|
||||||
|
# not rely on inherited parent transcripts: `fork_turns` remains
|
||||||
|
# caller-controlled, while Codex's native SubagentStart hook injects task
|
||||||
|
# context when trusted and child-side loading remains the fallback when it is
|
||||||
|
# unavailable. Set to "inline" only to keep implementation and checks in the
|
||||||
|
# main session. "sub-agent" remains a backwards-compatible alias for "auto".
|
||||||
|
# Invalid explicit values safely use inline mode.
|
||||||
|
#
|
||||||
|
# In "auto" mode, dispatched sub-agents inherit the main session's model
|
||||||
|
# unless you pin one. To use a cheaper/faster model for implement/check/
|
||||||
|
# research sub-agent work, edit `model` / `model_reasoning_effort` directly
|
||||||
|
# on the generated `.codex/agents/trellis-*.toml` files (see the commented
|
||||||
|
# hint lines in those files) — there is no config.yaml knob for this,
|
||||||
|
# `trellis update` preserves your edits across regeneration.
|
||||||
|
#
|
||||||
|
# codex:
|
||||||
|
# dispatch_mode: auto # or "inline"; legacy alias: "sub-agent"
|
||||||
|
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
# Sub-agent context injection limits
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
# Caps how much task context (implement.jsonl / check.jsonl referenced files,
|
||||||
|
# plus prd.md / design.md / implement.md) gets inlined into a sub-agent's
|
||||||
|
# first prompt. Oversized files are truncated with a notice; once the total
|
||||||
|
# payload cap is reached, remaining files degrade to index lines (path +
|
||||||
|
# reason + size) instead of being inlined.
|
||||||
|
#
|
||||||
|
# All values are byte counts. `0` disables the corresponding limit.
|
||||||
|
#
|
||||||
|
# context_injection:
|
||||||
|
# max_file_bytes: 32768 # per implement.jsonl / check.jsonl referenced file
|
||||||
|
# max_artifact_bytes: 65536 # per task artifact (prd.md / design.md / implement.md)
|
||||||
|
# max_total_bytes: 131072 # whole injected payload; overflow degrades to index lines
|
||||||
|
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
# Per-turn prompt injection
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
# Escape hatch for the per-turn <workflow-state> breadcrumb. When a user
|
||||||
|
# prompt contains the skip keyword as a standalone word (case-insensitive,
|
||||||
|
# word-boundary match — "no-trellisfoo" does NOT count), the breadcrumb is
|
||||||
|
# skipped for that turn only. Does not affect SessionStart or sub-agent
|
||||||
|
# context injection.
|
||||||
|
#
|
||||||
|
# prompt_injection:
|
||||||
|
# skip_keyword: "no-trellis" # "" disables the escape hatch entirely
|
||||||
5
.trellis/scripts/__init__.py
Executable file
5
.trellis/scripts/__init__.py
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
Trellis Python Scripts
|
||||||
|
|
||||||
|
This module provides Python implementations of Trellis workflow scripts.
|
||||||
|
"""
|
||||||
681
.trellis/scripts/add_session.py
Executable file
681
.trellis/scripts/add_session.py
Executable file
@@ -0,0 +1,681 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Add a new session to journal file and update index.md.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 add_session.py --title "Title" --commit "hash" --summary "Summary" [--package cli]
|
||||||
|
python3 add_session.py --title "Title" --branch "feat/my-branch"
|
||||||
|
|
||||||
|
# Pipe detailed content via stdin (use --stdin to opt in):
|
||||||
|
cat << 'EOF' | python3 add_session.py --stdin --title "Title" --summary "Summary"
|
||||||
|
<session content here>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Structured content (repeatable; a section with no bullets is omitted):
|
||||||
|
python3 add_session.py --title "Title" --change "Did X" --test "Ran Y" --next-step "Do Z"
|
||||||
|
|
||||||
|
Branch resolution order:
|
||||||
|
1. --branch CLI arg (explicit)
|
||||||
|
2. task.json branch field (from active task, if still exists)
|
||||||
|
3. git branch --show-current (auto-detect)
|
||||||
|
4. None (omitted gracefully)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from common.paths import (
|
||||||
|
DIR_TASKS,
|
||||||
|
DIR_WORKFLOW,
|
||||||
|
FILE_JOURNAL_PREFIX,
|
||||||
|
get_repo_root,
|
||||||
|
get_current_task,
|
||||||
|
get_developer,
|
||||||
|
get_workspace_dir,
|
||||||
|
)
|
||||||
|
from common.developer import ensure_developer
|
||||||
|
from common.git import run_git
|
||||||
|
from common.log import Colors, colored
|
||||||
|
from common.safe_commit import (
|
||||||
|
print_gitignore_warning,
|
||||||
|
safe_git_add,
|
||||||
|
safe_trellis_paths_to_add,
|
||||||
|
)
|
||||||
|
from common.tasks import load_task
|
||||||
|
from common.types import TaskInfo
|
||||||
|
from common.config import (
|
||||||
|
get_packages,
|
||||||
|
get_session_auto_commit,
|
||||||
|
get_session_commit_message,
|
||||||
|
get_max_journal_lines,
|
||||||
|
is_monorepo,
|
||||||
|
resolve_package,
|
||||||
|
validate_package,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Helper Functions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_latest_journal_info(dev_dir: Path) -> tuple[Path | None, int, int]:
|
||||||
|
"""Get latest journal file info.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (file_path, file_number, line_count).
|
||||||
|
"""
|
||||||
|
latest_file: Path | None = None
|
||||||
|
latest_num = -1
|
||||||
|
|
||||||
|
for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"):
|
||||||
|
if not f.is_file():
|
||||||
|
continue
|
||||||
|
|
||||||
|
match = re.search(r"(\d+)$", f.stem)
|
||||||
|
if match:
|
||||||
|
num = int(match.group(1))
|
||||||
|
if num > latest_num:
|
||||||
|
latest_num = num
|
||||||
|
latest_file = f
|
||||||
|
|
||||||
|
if latest_file:
|
||||||
|
lines = len(latest_file.read_text(encoding="utf-8").splitlines())
|
||||||
|
return latest_file, latest_num, lines
|
||||||
|
|
||||||
|
return None, 0, 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_session(index_file: Path) -> int:
|
||||||
|
"""Get current session number from index.md."""
|
||||||
|
if not index_file.is_file():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
content = index_file.read_text(encoding="utf-8")
|
||||||
|
for line in content.splitlines():
|
||||||
|
if "Total Sessions" in line:
|
||||||
|
match = re.search(r":\s*(\d+)", line)
|
||||||
|
if match:
|
||||||
|
return int(match.group(1))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_journal_num(filename: str) -> int:
|
||||||
|
"""Extract journal number from filename for sorting."""
|
||||||
|
match = re.search(r"(\d+)", filename)
|
||||||
|
return int(match.group(1)) if match else 0
|
||||||
|
|
||||||
|
|
||||||
|
def count_journal_files(dev_dir: Path, active_num: int) -> str:
|
||||||
|
"""Count journal files and return table rows."""
|
||||||
|
active_file = f"{FILE_JOURNAL_PREFIX}{active_num}.md"
|
||||||
|
result_lines = []
|
||||||
|
|
||||||
|
files = sorted(
|
||||||
|
[f for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md") if f.is_file()],
|
||||||
|
key=lambda f: _extract_journal_num(f.stem),
|
||||||
|
reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
|
for f in files:
|
||||||
|
filename = f.name
|
||||||
|
lines = len(f.read_text(encoding="utf-8").splitlines())
|
||||||
|
status = "Active" if filename == active_file else "Archived"
|
||||||
|
result_lines.append(f"| `{filename}` | ~{lines} | {status} |")
|
||||||
|
|
||||||
|
return "\n".join(result_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_git_branch(repo_root: Path) -> str | None:
|
||||||
|
"""Return the current checkout branch, or None for detached/non-git states."""
|
||||||
|
rc, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
|
||||||
|
if rc != 0:
|
||||||
|
return None
|
||||||
|
detected = branch_out.strip()
|
||||||
|
return detected or None
|
||||||
|
|
||||||
|
|
||||||
|
def branch_ref_exists(repo_root: Path, branch: str) -> bool:
|
||||||
|
"""Return True when branch exists locally or as the local origin ref."""
|
||||||
|
for ref in (f"refs/heads/{branch}", f"refs/remotes/origin/{branch}"):
|
||||||
|
rc, _, _ = run_git(["show-ref", "--verify", "--quiet", ref], cwd=repo_root)
|
||||||
|
if rc == 0:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_session_branch(
|
||||||
|
repo_root: Path,
|
||||||
|
cli_branch: str | None,
|
||||||
|
task_data: TaskInfo | None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Resolve journal branch without trusting stale task.json branch fields."""
|
||||||
|
if cli_branch:
|
||||||
|
return cli_branch
|
||||||
|
|
||||||
|
current_branch = get_current_git_branch(repo_root)
|
||||||
|
raw_task_branch = task_data.raw.get("branch") if task_data else None
|
||||||
|
task_branch = raw_task_branch.strip() if isinstance(raw_task_branch, str) else ""
|
||||||
|
if not task_branch:
|
||||||
|
return current_branch
|
||||||
|
|
||||||
|
if branch_ref_exists(repo_root, task_branch):
|
||||||
|
return task_branch
|
||||||
|
|
||||||
|
if current_branch:
|
||||||
|
print(
|
||||||
|
f"Warning: task.json branch '{task_branch}' no longer exists locally or as origin/{task_branch}; using current branch '{current_branch}'.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return current_branch
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Warning: task.json branch '{task_branch}' no longer exists locally or as origin/{task_branch}; omitting branch.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_git_worktree(repo_root: Path) -> bool:
|
||||||
|
"""Return True when repo_root is a linked worktree (not the main working tree).
|
||||||
|
|
||||||
|
Standard test: `git rev-parse --git-dir` (per-worktree) differs from
|
||||||
|
`git rev-parse --git-common-dir` (shared across all worktrees) once both
|
||||||
|
are resolved to absolute paths. In the main working tree these are the
|
||||||
|
same directory.
|
||||||
|
"""
|
||||||
|
rc_dir, git_dir, _ = run_git(["rev-parse", "--git-dir"], cwd=repo_root)
|
||||||
|
rc_common, git_common_dir, _ = run_git(
|
||||||
|
["rev-parse", "--git-common-dir"], cwd=repo_root
|
||||||
|
)
|
||||||
|
if rc_dir != 0 or rc_common != 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
git_dir_path = (repo_root / git_dir.strip()).resolve()
|
||||||
|
git_common_dir_path = (repo_root / git_common_dir.strip()).resolve()
|
||||||
|
return git_dir_path != git_common_dir_path
|
||||||
|
|
||||||
|
|
||||||
|
def warn_if_parallel_worktree(repo_root: Path) -> None:
|
||||||
|
"""Non-blocking note: index.md conflicts across parallel worktrees/branches
|
||||||
|
are expected and safe. Only fires when running in a linked git worktree
|
||||||
|
(not the main tree) with `session_auto_commit` enabled (#415 quick-fix tier).
|
||||||
|
"""
|
||||||
|
if not get_session_auto_commit(repo_root):
|
||||||
|
return
|
||||||
|
if not is_git_worktree(repo_root):
|
||||||
|
return
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
"[NOTE] Running in a git worktree with session_auto_commit enabled: "
|
||||||
|
"journal-*.md files auto-merge via .gitattributes, but index.md "
|
||||||
|
"conflicts across parallel worktrees/branches are expected and safe "
|
||||||
|
"to resolve by picking either side (task state lives in task.json, "
|
||||||
|
"not index.md). See .trellis/spec/cli/backend/directory-structure.md "
|
||||||
|
'("Workspace Journal Merge Behavior").',
|
||||||
|
Colors.YELLOW,
|
||||||
|
),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_new_journal_file(
|
||||||
|
dev_dir: Path, num: int, developer: str, today: str, max_lines: int = 2000,
|
||||||
|
) -> Path:
|
||||||
|
"""Create a new journal file."""
|
||||||
|
prev_num = num - 1
|
||||||
|
new_file = dev_dir / f"{FILE_JOURNAL_PREFIX}{num}.md"
|
||||||
|
|
||||||
|
content = f"""# Journal - {developer} (Part {num})
|
||||||
|
|
||||||
|
> Continuation from `{FILE_JOURNAL_PREFIX}{prev_num}.md` (archived at ~{max_lines} lines)
|
||||||
|
> Started: {today}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
"""
|
||||||
|
new_file.write_text(content, encoding="utf-8")
|
||||||
|
return new_file
|
||||||
|
|
||||||
|
|
||||||
|
def _render_bullet_section(header: str, items: list[str], bullet_prefix: str = "- ") -> str:
|
||||||
|
"""Render a Markdown section as bullets, or "" when there is no content.
|
||||||
|
|
||||||
|
A section with zero provided values is omitted entirely from the
|
||||||
|
rendered entry rather than falling back to a placeholder string.
|
||||||
|
"""
|
||||||
|
if not items:
|
||||||
|
return ""
|
||||||
|
bullets = "\n".join(f"{bullet_prefix}{item}" for item in items)
|
||||||
|
return f"\n\n### {header}\n\n{bullets}"
|
||||||
|
|
||||||
|
|
||||||
|
def _render_main_changes(changes: list[str], extra_content: str | None) -> str:
|
||||||
|
"""Render the Main Changes section from --change bullets or freeform content."""
|
||||||
|
if changes:
|
||||||
|
return _render_bullet_section("Main Changes", changes)
|
||||||
|
if extra_content:
|
||||||
|
return f"\n\n### Main Changes\n\n{extra_content}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def generate_session_content(
|
||||||
|
session_num: int,
|
||||||
|
title: str,
|
||||||
|
commit: str,
|
||||||
|
summary: str,
|
||||||
|
today: str,
|
||||||
|
package: str | None = None,
|
||||||
|
branch: str | None = None,
|
||||||
|
changes: list[str] | None = None,
|
||||||
|
extra_content: str | None = None,
|
||||||
|
tests: list[str] | None = None,
|
||||||
|
next_steps: list[str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Generate session content."""
|
||||||
|
if commit and commit != "-":
|
||||||
|
commit_table = """| Hash | Message |
|
||||||
|
|------|---------|"""
|
||||||
|
for c in commit.split(","):
|
||||||
|
c = c.strip()
|
||||||
|
commit_table += f"\n| `{c}` | (see git log) |"
|
||||||
|
else:
|
||||||
|
commit_table = "(No commits - planning session)"
|
||||||
|
|
||||||
|
package_line = f"\n**Package**: {package}" if package else ""
|
||||||
|
branch_line = f"\n**Branch**: `{branch}`" if branch else ""
|
||||||
|
|
||||||
|
main_changes_section = _render_main_changes(changes or [], extra_content)
|
||||||
|
testing_section = _render_bullet_section("Testing", tests or [], bullet_prefix="- [OK] ")
|
||||||
|
next_steps_section = _render_bullet_section("Next Steps", next_steps or [])
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
|
||||||
|
## Session {session_num}: {title}
|
||||||
|
|
||||||
|
**Date**: {today}
|
||||||
|
**Task**: {title}{package_line}{branch_line}
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
{summary}{main_changes_section}
|
||||||
|
|
||||||
|
### Git Commits
|
||||||
|
|
||||||
|
{commit_table}{testing_section}
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
[OK] **Completed**{next_steps_section}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def update_index(
|
||||||
|
index_file: Path,
|
||||||
|
dev_dir: Path,
|
||||||
|
title: str,
|
||||||
|
commit: str,
|
||||||
|
new_session: int,
|
||||||
|
active_file: str,
|
||||||
|
today: str,
|
||||||
|
branch: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Update index.md with new session info."""
|
||||||
|
# Format commit for display
|
||||||
|
commit_display = "-"
|
||||||
|
if commit and commit != "-":
|
||||||
|
commit_display = re.sub(r"([a-f0-9]{7,})", r"`\1`", commit.replace(",", ", "))
|
||||||
|
|
||||||
|
# Get file number from active_file name
|
||||||
|
match = re.search(r"(\d+)", active_file)
|
||||||
|
active_num = int(match.group(1)) if match else 0
|
||||||
|
files_table = count_journal_files(dev_dir, active_num)
|
||||||
|
|
||||||
|
print(f"Updating index.md for session {new_session}...")
|
||||||
|
print(f" Title: {title}")
|
||||||
|
print(f" Commit: {commit_display}")
|
||||||
|
print(f" Active File: {active_file}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
content = index_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
if "@@@auto:current-status" not in content:
|
||||||
|
print("Error: Markers not found in index.md. Please ensure markers exist.", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Process sections
|
||||||
|
lines = content.splitlines()
|
||||||
|
new_lines = []
|
||||||
|
|
||||||
|
in_current_status = False
|
||||||
|
in_active_documents = False
|
||||||
|
in_session_history = False
|
||||||
|
header_written = False
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if "@@@auto:current-status" in line:
|
||||||
|
new_lines.append(line)
|
||||||
|
in_current_status = True
|
||||||
|
new_lines.append(f"- **Active File**: `{active_file}`")
|
||||||
|
new_lines.append(f"- **Total Sessions**: {new_session}")
|
||||||
|
new_lines.append(f"- **Last Active**: {today}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "@@@/auto:current-status" in line:
|
||||||
|
in_current_status = False
|
||||||
|
new_lines.append(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "@@@auto:active-documents" in line:
|
||||||
|
new_lines.append(line)
|
||||||
|
in_active_documents = True
|
||||||
|
new_lines.append("| File | Lines | Status |")
|
||||||
|
new_lines.append("|------|-------|--------|")
|
||||||
|
new_lines.append(files_table)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "@@@/auto:active-documents" in line:
|
||||||
|
in_active_documents = False
|
||||||
|
new_lines.append(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "@@@auto:session-history" in line:
|
||||||
|
new_lines.append(line)
|
||||||
|
in_session_history = True
|
||||||
|
header_written = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "@@@/auto:session-history" in line:
|
||||||
|
in_session_history = False
|
||||||
|
new_lines.append(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if in_current_status:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if in_active_documents:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if in_session_history:
|
||||||
|
# Migrate old 4/6-column headers to 5-column Branch-only history.
|
||||||
|
if re.match(
|
||||||
|
r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*Base Branch\s*\|\s*$",
|
||||||
|
line,
|
||||||
|
):
|
||||||
|
new_lines.append("| # | Date | Title | Commits | Branch |")
|
||||||
|
continue
|
||||||
|
if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*$", line):
|
||||||
|
new_lines.append("| # | Date | Title | Commits | Branch |")
|
||||||
|
continue
|
||||||
|
if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*$", line):
|
||||||
|
new_lines.append("| # | Date | Title | Commits | Branch |")
|
||||||
|
continue
|
||||||
|
if re.match(r"^\|[-| ]+\|\s*$", line) and not header_written:
|
||||||
|
new_lines.append("|---|------|-------|---------|--------|")
|
||||||
|
new_lines.append(f"| {new_session} | {today} | {title} | {commit_display} | `{branch or '-'}` |")
|
||||||
|
header_written = True
|
||||||
|
continue
|
||||||
|
new_lines.append(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_lines.append(line)
|
||||||
|
|
||||||
|
index_file.write_text("\n".join(new_lines), encoding="utf-8")
|
||||||
|
print("[OK] Updated index.md successfully!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Main Function
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _auto_commit_workspace(repo_root: Path) -> None:
|
||||||
|
"""Stage Trellis-owned workspace + current-task paths and commit.
|
||||||
|
|
||||||
|
Path scope is restricted to specific products: the current developer's
|
||||||
|
journal files + index.md, and ONLY the current task directory (resolved
|
||||||
|
via ``get_current_task``). We never `git add` the whole `.trellis/` tree
|
||||||
|
or iterate over all active task dirs (#303: parallel-window dirty task
|
||||||
|
dirs must not be bundled into the session auto-commit). If `.gitignore`
|
||||||
|
blocks the specific paths we warn + skip — never retry with ``-f``.
|
||||||
|
|
||||||
|
Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when set to
|
||||||
|
``false``, this function returns immediately without touching git
|
||||||
|
(journal/index files are still written to disk by the caller).
|
||||||
|
"""
|
||||||
|
if not get_session_auto_commit(repo_root):
|
||||||
|
print(
|
||||||
|
"[OK] session_auto_commit: false — skipping git stage/commit.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
commit_msg = get_session_commit_message(repo_root)
|
||||||
|
# Resolve the current task so staging is scoped to its dir only. The ref
|
||||||
|
# is ``.trellis/tasks/<name>`` (or under archive/) — pass the bare name.
|
||||||
|
current = get_current_task(repo_root)
|
||||||
|
if current:
|
||||||
|
task_name = Path(current).name
|
||||||
|
paths = safe_trellis_paths_to_add(repo_root, task_name=task_name)
|
||||||
|
else:
|
||||||
|
# Current task unknown (0 or >=2 parallel sessions — exactly the
|
||||||
|
# parallel-window case #303 is about). Do NOT fall back to the wide
|
||||||
|
# `tasks_dir.iterdir()` scan; that would re-leak other tasks' dirty
|
||||||
|
# dirs into the session commit. Stage only the developer's journal/
|
||||||
|
# index and skip every task dir.
|
||||||
|
paths = [
|
||||||
|
p
|
||||||
|
for p in safe_trellis_paths_to_add(repo_root, task_name=None)
|
||||||
|
if not p.startswith(f"{DIR_WORKFLOW}/{DIR_TASKS}/")
|
||||||
|
]
|
||||||
|
if not paths:
|
||||||
|
print("[OK] No workspace changes to commit.", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
success, _, err = safe_git_add(paths, repo_root)
|
||||||
|
if not success:
|
||||||
|
if err and "ignored by" in err.lower():
|
||||||
|
print_gitignore_warning(paths)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"[WARN] git add failed: {err.strip() if err else 'unknown error'}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if there are staged changes for the paths we just staged.
|
||||||
|
rc, _, _ = run_git(
|
||||||
|
["diff", "--cached", "--quiet", "--", *paths], cwd=repo_root
|
||||||
|
)
|
||||||
|
if rc == 0:
|
||||||
|
print("[OK] No workspace changes to commit.", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
rc, _, commit_err = run_git(["commit", "-m", commit_msg], cwd=repo_root)
|
||||||
|
if rc == 0:
|
||||||
|
print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"[WARN] Auto-commit failed: {commit_err.strip()}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def add_session(
|
||||||
|
title: str,
|
||||||
|
commit: str = "-",
|
||||||
|
summary: str = "Session summary was not supplied.",
|
||||||
|
changes: list[str] | None = None,
|
||||||
|
extra_content: str | None = None,
|
||||||
|
tests: list[str] | None = None,
|
||||||
|
next_steps: list[str] | None = None,
|
||||||
|
auto_commit: bool = True,
|
||||||
|
package: str | None = None,
|
||||||
|
branch: str | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Add a new session."""
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
warn_if_parallel_worktree(repo_root)
|
||||||
|
ensure_developer(repo_root)
|
||||||
|
|
||||||
|
developer = get_developer(repo_root)
|
||||||
|
if not developer:
|
||||||
|
print("Error: Developer not initialized", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
dev_dir = get_workspace_dir(repo_root)
|
||||||
|
if not dev_dir:
|
||||||
|
print("Error: Workspace directory not found", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
max_lines = get_max_journal_lines(repo_root)
|
||||||
|
|
||||||
|
index_file = dev_dir / "index.md"
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
journal_file, current_num, current_lines = get_latest_journal_info(dev_dir)
|
||||||
|
current_session = get_current_session(index_file)
|
||||||
|
new_session = current_session + 1
|
||||||
|
|
||||||
|
session_content = generate_session_content(
|
||||||
|
new_session, title, commit, summary, today, package, branch,
|
||||||
|
changes=changes, extra_content=extra_content, tests=tests,
|
||||||
|
next_steps=next_steps,
|
||||||
|
)
|
||||||
|
content_lines = len(session_content.splitlines())
|
||||||
|
|
||||||
|
print("========================================", file=sys.stderr)
|
||||||
|
print("ADD SESSION", file=sys.stderr)
|
||||||
|
print("========================================", file=sys.stderr)
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
print(f"Session: {new_session}", file=sys.stderr)
|
||||||
|
print(f"Title: {title}", file=sys.stderr)
|
||||||
|
print(f"Commit: {commit}", file=sys.stderr)
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
print(f"Current journal file: {FILE_JOURNAL_PREFIX}{current_num}.md", file=sys.stderr)
|
||||||
|
print(f"Current lines: {current_lines}", file=sys.stderr)
|
||||||
|
print(f"New content lines: {content_lines}", file=sys.stderr)
|
||||||
|
print(f"Total after append: {current_lines + content_lines}", file=sys.stderr)
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
|
||||||
|
target_file = journal_file
|
||||||
|
target_num = current_num
|
||||||
|
|
||||||
|
if current_lines + content_lines > max_lines:
|
||||||
|
target_num = current_num + 1
|
||||||
|
print(f"[!] Exceeds {max_lines} lines, creating {FILE_JOURNAL_PREFIX}{target_num}.md", file=sys.stderr)
|
||||||
|
target_file = create_new_journal_file(dev_dir, target_num, developer, today, max_lines)
|
||||||
|
print(f"Created: {target_file}", file=sys.stderr)
|
||||||
|
|
||||||
|
# Append session content
|
||||||
|
if target_file:
|
||||||
|
with target_file.open("a", encoding="utf-8") as f:
|
||||||
|
f.write(session_content)
|
||||||
|
print(f"[OK] Appended session to {target_file.name}", file=sys.stderr)
|
||||||
|
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
|
||||||
|
# Update index.md
|
||||||
|
active_file = f"{FILE_JOURNAL_PREFIX}{target_num}.md"
|
||||||
|
if not update_index(
|
||||||
|
index_file,
|
||||||
|
dev_dir,
|
||||||
|
title,
|
||||||
|
commit,
|
||||||
|
new_session,
|
||||||
|
active_file,
|
||||||
|
today,
|
||||||
|
branch,
|
||||||
|
):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
print("========================================", file=sys.stderr)
|
||||||
|
print(f"[OK] Session {new_session} added successfully!", file=sys.stderr)
|
||||||
|
print("========================================", file=sys.stderr)
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
print("Files updated:", file=sys.stderr)
|
||||||
|
print(f" - {target_file.name if target_file else 'journal'}", file=sys.stderr)
|
||||||
|
print(" - index.md", file=sys.stderr)
|
||||||
|
|
||||||
|
# Auto-commit workspace changes
|
||||||
|
if auto_commit:
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
_auto_commit_workspace(repo_root)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Main Entry
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""CLI entry point."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Add a new session to journal file and update index.md"
|
||||||
|
)
|
||||||
|
parser.add_argument("--title", required=True, help="Session title")
|
||||||
|
parser.add_argument("--commit", default="-", help="Comma-separated commit hashes")
|
||||||
|
parser.add_argument("--summary", default="Session summary was not supplied.", help="Brief summary")
|
||||||
|
parser.add_argument("--content-file", help="Path to file with detailed content")
|
||||||
|
parser.add_argument("--package", help="Package name tag (e.g., cli, docs-site)")
|
||||||
|
parser.add_argument("--branch", help="Branch name (auto-detected if omitted)")
|
||||||
|
parser.add_argument("--change", action="append", help="Main Changes bullet (repeatable)")
|
||||||
|
parser.add_argument("--test", action="append", help="Testing bullet (repeatable)")
|
||||||
|
parser.add_argument("--next-step", action="append", help="Next Steps bullet (repeatable)")
|
||||||
|
parser.add_argument("--no-commit", action="store_true",
|
||||||
|
help="Skip auto-commit of workspace changes")
|
||||||
|
parser.add_argument("--stdin", action="store_true",
|
||||||
|
help="Read extra content from stdin (explicit opt-in)")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
extra_content: str | None = None
|
||||||
|
if args.content_file:
|
||||||
|
content_path = Path(args.content_file)
|
||||||
|
if content_path.is_file():
|
||||||
|
extra_content = content_path.read_text(encoding="utf-8")
|
||||||
|
elif args.stdin:
|
||||||
|
extra_content = sys.stdin.read()
|
||||||
|
|
||||||
|
# Load active task once — shared by package and branch resolution
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
current = get_current_task(repo_root)
|
||||||
|
task_data = load_task(repo_root / current) if current else None
|
||||||
|
|
||||||
|
package = args.package
|
||||||
|
if package:
|
||||||
|
# CLI source: fail-fast in monorepo, ignore in single-repo
|
||||||
|
if not is_monorepo(repo_root):
|
||||||
|
print("Warning: --package ignored in single-repo project", file=sys.stderr)
|
||||||
|
package = None
|
||||||
|
elif not validate_package(package, repo_root):
|
||||||
|
packages = get_packages(repo_root)
|
||||||
|
available = ", ".join(sorted(packages.keys())) if packages else "(none)"
|
||||||
|
print(f"Error: unknown package '{package}'. Available: {available}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
else:
|
||||||
|
# Inferred: active task's task.json.package → default_package → None
|
||||||
|
task_package = task_data.package if task_data else None
|
||||||
|
package = resolve_package(task_package, repo_root)
|
||||||
|
|
||||||
|
branch = resolve_session_branch(repo_root, args.branch, task_data)
|
||||||
|
|
||||||
|
return add_session(
|
||||||
|
args.title, args.commit, args.summary,
|
||||||
|
changes=args.change, extra_content=extra_content, tests=args.test,
|
||||||
|
next_steps=args.next_step,
|
||||||
|
auto_commit=not args.no_commit,
|
||||||
|
package=package,
|
||||||
|
branch=branch,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
92
.trellis/scripts/common/__init__.py
Executable file
92
.trellis/scripts/common/__init__.py
Executable file
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
Common utilities for Trellis workflow scripts.
|
||||||
|
|
||||||
|
This module provides shared functionality used by other Trellis scripts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Windows Encoding Fix (MUST be at top, before any other output)
|
||||||
|
# =============================================================================
|
||||||
|
# On Windows, stdout defaults to the system code page (often GBK/CP936).
|
||||||
|
# This causes UnicodeEncodeError when printing non-ASCII characters.
|
||||||
|
#
|
||||||
|
# Any script that imports from common will automatically get this fix.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_stream(stream: object) -> object:
|
||||||
|
"""Configure a stream for UTF-8 encoding on Windows."""
|
||||||
|
# Try reconfigure() first (Python 3.7+, more reliable)
|
||||||
|
if hasattr(stream, "reconfigure"):
|
||||||
|
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
||||||
|
return stream
|
||||||
|
# Fallback: detach and rewrap with TextIOWrapper
|
||||||
|
elif hasattr(stream, "detach"):
|
||||||
|
return io.TextIOWrapper(
|
||||||
|
stream.detach(), # type: ignore[union-attr]
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
return stream
|
||||||
|
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment]
|
||||||
|
sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment]
|
||||||
|
sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
|
def configure_encoding() -> None:
|
||||||
|
"""
|
||||||
|
Configure stdout/stderr/stdin for UTF-8 encoding on Windows.
|
||||||
|
|
||||||
|
This is automatically called when importing from common,
|
||||||
|
but can be called manually for scripts that don't import common.
|
||||||
|
|
||||||
|
Safe to call multiple times.
|
||||||
|
"""
|
||||||
|
global sys
|
||||||
|
if sys.platform == "win32":
|
||||||
|
sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment]
|
||||||
|
sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment]
|
||||||
|
sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
|
from .paths import (
|
||||||
|
DIR_WORKFLOW,
|
||||||
|
DIR_WORKSPACE,
|
||||||
|
DIR_TASKS,
|
||||||
|
DIR_ARCHIVE,
|
||||||
|
DIR_SPEC,
|
||||||
|
DIR_SCRIPTS,
|
||||||
|
FILE_DEVELOPER,
|
||||||
|
FILE_CURRENT_TASK,
|
||||||
|
FILE_TASK_JSON,
|
||||||
|
FILE_JOURNAL_PREFIX,
|
||||||
|
get_repo_root,
|
||||||
|
get_developer,
|
||||||
|
check_developer,
|
||||||
|
get_tasks_dir,
|
||||||
|
get_workspace_dir,
|
||||||
|
get_active_journal_file,
|
||||||
|
count_lines,
|
||||||
|
get_current_task,
|
||||||
|
get_current_task_abs,
|
||||||
|
normalize_task_ref,
|
||||||
|
resolve_task_ref,
|
||||||
|
set_current_task,
|
||||||
|
clear_current_task,
|
||||||
|
has_current_task,
|
||||||
|
generate_task_date_prefix,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .active_task import (
|
||||||
|
ActiveTask,
|
||||||
|
clear_active_task,
|
||||||
|
resolve_active_task,
|
||||||
|
resolve_context_key,
|
||||||
|
set_active_task,
|
||||||
|
)
|
||||||
765
.trellis/scripts/common/active_task.py
Executable file
765
.trellis/scripts/common/active_task.py
Executable file
@@ -0,0 +1,765 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Session-scoped active task resolution.
|
||||||
|
|
||||||
|
The user-facing concept is a single "active task". Trellis stores that pointer
|
||||||
|
per AI session/window under `.trellis/.runtime/sessions/`; without a stable
|
||||||
|
session key there is no active task.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
DIR_WORKFLOW = ".trellis"
|
||||||
|
DIR_TASKS = "tasks"
|
||||||
|
DIR_RUNTIME = ".runtime"
|
||||||
|
DIR_SESSIONS = "sessions"
|
||||||
|
DIR_SHELL_TICKETS = "shell-tickets"
|
||||||
|
# Pre-0.6.13 name, when the bridge was Cursor-only. Still read so a session that
|
||||||
|
# was mid-command across an upgrade does not silently degrade; never written.
|
||||||
|
# Tickets are 30-second ephemera, so the old directory ages out by itself —
|
||||||
|
# there is nothing to migrate, only a glob on a directory that is normally
|
||||||
|
# absent. The alternative (ignore it) would land its one lost command on the
|
||||||
|
# platform that works today.
|
||||||
|
DIR_LEGACY_CURSOR_SHELL_TICKETS = "cursor-shell"
|
||||||
|
SHELL_TICKET_TTL_SECONDS = 30
|
||||||
|
TASK_SESSION_COMMANDS = {"start", "current", "finish"}
|
||||||
|
|
||||||
|
_SESSION_KEYS = ("session_id", "sessionId", "sessionID")
|
||||||
|
_CONVERSATION_KEYS = ("conversation_id", "conversationId", "conversationID")
|
||||||
|
_TRANSCRIPT_KEYS = ("transcript_path", "transcriptPath", "transcript")
|
||||||
|
_NESTED_KEYS = ("input", "properties", "event", "hook_input", "hookInput")
|
||||||
|
_KNOWN_PLATFORMS = {
|
||||||
|
"claude",
|
||||||
|
"codex",
|
||||||
|
"cursor",
|
||||||
|
"opencode",
|
||||||
|
"gemini",
|
||||||
|
"droid",
|
||||||
|
"qoder",
|
||||||
|
"codebuddy",
|
||||||
|
"kiro",
|
||||||
|
"copilot",
|
||||||
|
"pi",
|
||||||
|
"trae",
|
||||||
|
"grok",
|
||||||
|
"kimi",
|
||||||
|
"zcode",
|
||||||
|
"snow",
|
||||||
|
"dsh",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Every name below records how it was checked. Do NOT add a name by analogy
|
||||||
|
# with a neighbour: a 2026-08-05 audit of all 21 platforms found 12 of the 21
|
||||||
|
# declared names had never existed anywhere — they were pattern-guessed from a
|
||||||
|
# `<PLATFORM>_SESSION_ID` shape no vendor agreed to, and the uniformity was the
|
||||||
|
# only "evidence" behind them. A platform with no verified name belongs in no
|
||||||
|
# table; it resolves through TRELLIS_CONTEXT_ID or its hook/plugin bridge.
|
||||||
|
_ENV_SESSION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||||
|
# REAL (reported 2026-08-13 against DSH 0.1.0-rc.6 by @SajoLuo, from a live
|
||||||
|
# run: DSH exports DSH_SESSION_ID plus DSH_SHELL=1 into its managed shell).
|
||||||
|
# MUST STAY FIRST. A DSH session can inherit an outer host's identity — a
|
||||||
|
# DSH launched from Codex still carries CODEX_THREAD_ID — and the untargeted
|
||||||
|
# lookup below walks this table in order, so any earlier entry would claim
|
||||||
|
# the session and write a foreign `codex_<thread>` pointer for DSH work.
|
||||||
|
# DSH_SESSION_ID is the only name here no other vendor sets, so first place
|
||||||
|
# is safe: it cannot mis-claim a non-DSH session.
|
||||||
|
("dsh", ("DSH_SESSION_ID",)),
|
||||||
|
# REAL, undocumented (verified 2026-08-05 in a live Claude Code 2.1.221 bash
|
||||||
|
# child; absent from code.claude.com/docs/en/env-vars). CLAUDE_SESSION_ID
|
||||||
|
# was removed here — verified absent from that same live environment.
|
||||||
|
("claude", ("CLAUDE_CODE_SESSION_ID",)),
|
||||||
|
# REAL, undocumented (verified 2026-08-05: injected by codex-cli 0.146.0
|
||||||
|
# into shell children, absent from the parent env; openai/codex#19937).
|
||||||
|
# CODEX_SESSION_ID was removed — absent from a live `codex exec` env.
|
||||||
|
("codex", ("CODEX_THREAD_ID",)),
|
||||||
|
# REAL but HOOK-SCOPE ONLY (verified 2026-08-05): set by Gemini's
|
||||||
|
# hookRunner.ts. Its shell tool builds the child env in
|
||||||
|
# shellExecutionService.ts and adds only GEMINI_CLI/TERM/PAGER/GIT_PAGER, so
|
||||||
|
# this never reaches a bash child — it resolves only inside a hook process.
|
||||||
|
("gemini", ("GEMINI_SESSION_ID",)),
|
||||||
|
# REAL but HOOK-SCOPE ONLY (verified 2026-08-05): docs.qoder.com/zh/
|
||||||
|
# extensions/hooks documents it as injected during hook execution by the
|
||||||
|
# Qoder *IDE plugin*. Absent from the Qoder CLI hook docs and from Lingma.
|
||||||
|
("qoder", ("QODER_SESSION_ID",)),
|
||||||
|
# UNVERIFIED (2026-08-05): absent from kiro.dev/docs/hooks/, but Dynatrace
|
||||||
|
# dtctl, oh-my-agent and gastown all key agent detection on it and one notes
|
||||||
|
# it is "set in both interactive and --no-interactive". Kept because that is
|
||||||
|
# absence of evidence, not evidence of absence. To settle: run
|
||||||
|
# `env | grep KIRO` from a Kiro shell-tool call on a machine with Kiro.
|
||||||
|
("kiro", ("KIRO_SESSION_ID",)),
|
||||||
|
# UNVERIFIED (2026-08-05): absent from docs.github.com/en/copilot/reference/
|
||||||
|
# hooks-reference and from the CLI programmatic reference. To settle: run
|
||||||
|
# `copilot help environment` (the authoritative list per those docs) — not
|
||||||
|
# runnable here, the CLI is not installed and copilot-cli ships no source.
|
||||||
|
("copilot", ("COPILOT_SESSION_ID", "COPILOT_SESSIONID")),
|
||||||
|
# REASONED, UNVERIFIED (2026-08-05): ZCode is closed-source and not
|
||||||
|
# installable here. It mirrors Claude's naming elsewhere (CLAUDE_PLUGIN_ROOT
|
||||||
|
# / CLAUDE_PLUGIN_DATA compat aliases are in its docs), and the previously
|
||||||
|
# declared CLAUDE_SESSION_ID does not exist on Claude Code either — so the
|
||||||
|
# name ZCode would actually reuse is CLAUDE_CODE_SESSION_ID. Try that first,
|
||||||
|
# keep the historical name as a fallback: if neither exists nothing changes.
|
||||||
|
# Platform-scoped lookup (_iter_env_keys filters by platform name), so the
|
||||||
|
# entry only fires once the resolver detected "zcode" — no collision with
|
||||||
|
# the claude entry above.
|
||||||
|
("zcode", ("CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID")),
|
||||||
|
# REAL by vendor design (verified 2026-08-05): Snow's sessionIdentityEnv.ts
|
||||||
|
# exports SNOW_SESSION_ID into hook/terminal/sub-agent children and names
|
||||||
|
# Trellis in its source header. TRELLIS_CONTEXT_ID stays the preferred
|
||||||
|
# override — Snow sets that too.
|
||||||
|
("snow", ("SNOW_SESSION_ID",)),
|
||||||
|
)
|
||||||
|
_ENV_CONVERSATION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||||
|
# REAL in cursor-agent (CLI), undocumented (verified 2026-08-05: the value
|
||||||
|
# matches ~/.cursor/chats/<ws>/<id>). The Cursor *IDE* is unverified — a
|
||||||
|
# 2026-05 forum request for it drew no staff reply. The invented
|
||||||
|
# CURSOR_SESSION_ID was removed from the session table: empty in a live
|
||||||
|
# cursor-agent shell. Cursor's other path is the shell ticket below
|
||||||
|
# (_lookup_shell_ticket_context_key), which is not Cursor-specific.
|
||||||
|
("cursor", ("CURSOR_CONVERSATION_ID", "CURSOR_CONVERSATIONID")),
|
||||||
|
)
|
||||||
|
_ENV_TRANSCRIPT_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||||
|
# REAL but HOOK-SCOPE ONLY (verified 2026-08-05): documented for Cursor hook
|
||||||
|
# scripts; empty in the agent's own shell env.
|
||||||
|
("cursor", ("CURSOR_TRANSCRIPT_PATH",)),
|
||||||
|
# UNVERIFIED — never researched. The 2026-08-05 audit covered the session
|
||||||
|
# table only, so do not infer these are real *or* fake from that work
|
||||||
|
# (CLAUDE_/CODEX_TRANSCRIPT_PATH were removed because those two *were*
|
||||||
|
# checked: absent from docs and from live envs). To settle each: run
|
||||||
|
# `env | grep _TRANSCRIPT_PATH` inside a hook and inside a shell-tool call.
|
||||||
|
("gemini", ("GEMINI_TRANSCRIPT_PATH",)),
|
||||||
|
("droid", ("FACTORY_TRANSCRIPT_PATH", "DROID_TRANSCRIPT_PATH")),
|
||||||
|
("qoder", ("QODER_TRANSCRIPT_PATH",)),
|
||||||
|
("codebuddy", ("CODEBUDDY_TRANSCRIPT_PATH",)),
|
||||||
|
)
|
||||||
|
_ENV_PLATFORM_ALIASES = {
|
||||||
|
"claude-code": "claude",
|
||||||
|
"factory": "droid",
|
||||||
|
"factory-ai": "droid",
|
||||||
|
"github-copilot": "copilot",
|
||||||
|
}
|
||||||
|
# ZCode intentionally reuses Claude's session env var name. Hooks know the host
|
||||||
|
# is ZCode, while later shell commands see only the shared env name and resolve
|
||||||
|
# it through the claude entry. Canonicalize both paths to one runtime filename.
|
||||||
|
_CONTEXT_KEY_PLATFORM_ALIASES = {
|
||||||
|
"zcode": "claude",
|
||||||
|
# Factory Droid's config directory is `.factory/`, so a hook that names its
|
||||||
|
# platform after the directory it was installed in reports "factory". Its
|
||||||
|
# sibling hooks report "droid". One runtime filename either way.
|
||||||
|
"factory": "droid",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ActiveTask:
|
||||||
|
"""Resolved active task state."""
|
||||||
|
|
||||||
|
task_path: str | None
|
||||||
|
source_type: str
|
||||||
|
context_key: str | None = None
|
||||||
|
stale: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source(self) -> str:
|
||||||
|
"""Human-readable source label."""
|
||||||
|
if self.source_type == "session" and self.context_key:
|
||||||
|
return f"session:{self.context_key}"
|
||||||
|
if self.source_type == "session-fallback" and self.context_key:
|
||||||
|
return f"session-fallback:{self.context_key}"
|
||||||
|
return self.source_type
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_task_ref(task_ref: str) -> str:
|
||||||
|
"""Normalize a task ref for stable storage and comparison."""
|
||||||
|
normalized = task_ref.strip()
|
||||||
|
if not normalized:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
path_obj = Path(normalized)
|
||||||
|
if path_obj.is_absolute():
|
||||||
|
return str(path_obj)
|
||||||
|
|
||||||
|
normalized = normalized.replace("\\", "/")
|
||||||
|
while normalized.startswith("./"):
|
||||||
|
normalized = normalized[2:]
|
||||||
|
|
||||||
|
if normalized.startswith(f"{DIR_TASKS}/"):
|
||||||
|
return f"{DIR_WORKFLOW}/{normalized}"
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_task_ref(task_ref: str, repo_root: Path) -> Path | None:
|
||||||
|
"""Resolve a task ref to an absolute task directory inside the repo.
|
||||||
|
|
||||||
|
Mirrors `paths.resolve_task_ref` (same containment check). Duplicated
|
||||||
|
rather than imported because this module is loaded standalone — hooks add
|
||||||
|
it to `sys.path` directly — so it stays zero-relative-import on purpose.
|
||||||
|
"""
|
||||||
|
normalized = normalize_task_ref(task_ref)
|
||||||
|
if not normalized:
|
||||||
|
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
|
||||||
|
else:
|
||||||
|
candidate = repo_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()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved.relative_to(root)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_sessions_dir(repo_root: Path) -> Path:
|
||||||
|
return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_SESSIONS
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_key(raw: str) -> str:
|
||||||
|
safe = re.sub(r"[^A-Za-z0-9._-]+", "_", raw.strip())
|
||||||
|
safe = safe.strip("._-")
|
||||||
|
return safe[:160] if safe else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_value(raw: str) -> str:
|
||||||
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24]
|
||||||
|
|
||||||
|
|
||||||
|
def _as_dict(value: Any) -> dict[str, Any] | None:
|
||||||
|
return value if isinstance(value, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _string_value(value: Any) -> str | None:
|
||||||
|
if isinstance(value, str):
|
||||||
|
stripped = value.strip()
|
||||||
|
return stripped or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_string(data: dict[str, Any], keys: tuple[str, ...]) -> str | None:
|
||||||
|
for key in keys:
|
||||||
|
value = _string_value(data.get(key))
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
|
||||||
|
for nested_key in _NESTED_KEYS:
|
||||||
|
nested = _as_dict(data.get(nested_key))
|
||||||
|
if not nested:
|
||||||
|
continue
|
||||||
|
value = _lookup_string(nested, keys)
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_platform(platform_input: dict[str, Any] | None, platform: str | None) -> str:
|
||||||
|
if platform:
|
||||||
|
return _sanitize_key(platform) or "session"
|
||||||
|
if platform_input:
|
||||||
|
for key in ("_trellis_platform", "trellis_platform", "platform", "source"):
|
||||||
|
value = _string_value(platform_input.get(key))
|
||||||
|
if value:
|
||||||
|
return _sanitize_key(value) or "session"
|
||||||
|
if _string_value(platform_input.get("cursor_version")):
|
||||||
|
return "cursor"
|
||||||
|
return "session"
|
||||||
|
|
||||||
|
|
||||||
|
def _context_key(platform_name: str, kind: str, value: str) -> str:
|
||||||
|
platform_name = _CONTEXT_KEY_PLATFORM_ALIASES.get(platform_name, platform_name)
|
||||||
|
if kind == "transcript":
|
||||||
|
return f"{platform_name}_transcript_{_hash_value(value)}"
|
||||||
|
safe_value = _sanitize_key(value)
|
||||||
|
if safe_value:
|
||||||
|
return f"{platform_name}_{safe_value}"
|
||||||
|
return f"{platform_name}_{_hash_value(value)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_env_keys(
|
||||||
|
env_keys: tuple[tuple[str, tuple[str, ...]], ...],
|
||||||
|
platform_name: str | None,
|
||||||
|
) -> tuple[tuple[str, tuple[str, ...]], ...]:
|
||||||
|
"""Narrow an env-key table to one platform, or return all of it.
|
||||||
|
|
||||||
|
A platform with no entry yields an empty tuple, and the caller's `for` loop
|
||||||
|
simply does not run. That is the normal case, not an error: platforms with
|
||||||
|
no verified env var name are deliberately absent from these tables.
|
||||||
|
"""
|
||||||
|
if not platform_name:
|
||||||
|
return env_keys
|
||||||
|
matched = tuple((name, keys) for name, keys in env_keys if name == platform_name)
|
||||||
|
return matched
|
||||||
|
|
||||||
|
|
||||||
|
def _env_platform_name(platform_name: str | None) -> str | None:
|
||||||
|
if not platform_name or platform_name == "session":
|
||||||
|
return None
|
||||||
|
return _ENV_PLATFORM_ALIASES.get(platform_name, platform_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_env_context_key(platform_name: str | None) -> str | None:
|
||||||
|
"""Resolve a context key from platform-provided environment variables.
|
||||||
|
|
||||||
|
Hooks pass `TRELLIS_CONTEXT_ID` to subprocesses they launch, but an AI-run
|
||||||
|
shell command can only see session identity if the host platform exports it
|
||||||
|
in the command environment. These names are best-effort adapters; if none
|
||||||
|
are present, there is no session-scoped active task.
|
||||||
|
"""
|
||||||
|
env_platform_name = _env_platform_name(platform_name)
|
||||||
|
|
||||||
|
for name, keys in _iter_env_keys(_ENV_SESSION_KEYS, env_platform_name):
|
||||||
|
for key in keys:
|
||||||
|
value = _string_value(os.environ.get(key))
|
||||||
|
if value:
|
||||||
|
return _context_key(name, "session", value)
|
||||||
|
|
||||||
|
for name, keys in _iter_env_keys(_ENV_CONVERSATION_KEYS, env_platform_name):
|
||||||
|
for key in keys:
|
||||||
|
value = _string_value(os.environ.get(key))
|
||||||
|
if value:
|
||||||
|
return _context_key(name, "conversation", value)
|
||||||
|
|
||||||
|
for name, keys in _iter_env_keys(_ENV_TRANSCRIPT_KEYS, env_platform_name):
|
||||||
|
for key in keys:
|
||||||
|
value = _string_value(os.environ.get(key))
|
||||||
|
if value:
|
||||||
|
return _context_key(name, "transcript", value)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_repo_root_from_cwd() -> Path | None:
|
||||||
|
current = Path.cwd().resolve()
|
||||||
|
while True:
|
||||||
|
if (current / DIR_WORKFLOW).is_dir():
|
||||||
|
return current
|
||||||
|
if current == current.parent:
|
||||||
|
return None
|
||||||
|
current = current.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _shell_ticket_dirs(repo_root: Path) -> tuple[Path, ...]:
|
||||||
|
runtime_dir = repo_root / DIR_WORKFLOW / DIR_RUNTIME
|
||||||
|
return (
|
||||||
|
runtime_dir / DIR_SHELL_TICKETS,
|
||||||
|
runtime_dir / DIR_LEGACY_CURSOR_SHELL_TICKETS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_file(path: Path) -> bool:
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _task_refs_match(left: str | None, right: str | None, repo_root: Path) -> bool:
|
||||||
|
if not left or not right:
|
||||||
|
return False
|
||||||
|
left_path = resolve_task_ref(left, repo_root)
|
||||||
|
right_path = resolve_task_ref(right, repo_root)
|
||||||
|
if left_path is not None and right_path is not None:
|
||||||
|
return left_path == right_path
|
||||||
|
return normalize_task_ref(left) == normalize_task_ref(right)
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_ticket_matches_args(ticket: dict[str, Any], repo_root: Path) -> bool:
|
||||||
|
if Path(sys.argv[0]).name != "task.py":
|
||||||
|
return False
|
||||||
|
args = tuple(sys.argv[1:])
|
||||||
|
if not args:
|
||||||
|
return False
|
||||||
|
|
||||||
|
command_name = args[0]
|
||||||
|
if command_name not in TASK_SESSION_COMMANDS:
|
||||||
|
return False
|
||||||
|
|
||||||
|
subcommands = ticket.get("subcommands")
|
||||||
|
if not isinstance(subcommands, list):
|
||||||
|
return False
|
||||||
|
|
||||||
|
for subcommand in subcommands:
|
||||||
|
if not isinstance(subcommand, dict):
|
||||||
|
continue
|
||||||
|
if _string_value(subcommand.get("name")) != command_name:
|
||||||
|
continue
|
||||||
|
if command_name != "start":
|
||||||
|
return True
|
||||||
|
task_ref = args[1] if len(args) > 1 else None
|
||||||
|
if _task_refs_match(_string_value(subcommand.get("task_ref")), task_ref, repo_root):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _ticket_is_fresh(ticket: dict[str, Any], ticket_path: Path, now: float) -> bool:
|
||||||
|
expires_at = ticket.get("expires_at_epoch")
|
||||||
|
if isinstance(expires_at, (int, float)) and expires_at < now:
|
||||||
|
_remove_file(ticket_path)
|
||||||
|
return False
|
||||||
|
|
||||||
|
created_at = ticket.get("created_at_epoch")
|
||||||
|
if isinstance(created_at, (int, float)):
|
||||||
|
if now - created_at <= SHELL_TICKET_TTL_SECONDS:
|
||||||
|
return True
|
||||||
|
_remove_file(ticket_path)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _ticket_cwd_matches_repo(ticket: dict[str, Any], repo_root: Path) -> bool:
|
||||||
|
cwd = _string_value(ticket.get("cwd"))
|
||||||
|
if not cwd:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
Path(cwd).resolve().relative_to(repo_root)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_ticket_context_key(
|
||||||
|
ticket_path: Path,
|
||||||
|
repo_root: Path,
|
||||||
|
now: float,
|
||||||
|
) -> str | None:
|
||||||
|
"""Accept a ticket on its merits, never on which platform wrote it.
|
||||||
|
|
||||||
|
The `platform` field a ticket carries is debugging metadata; gating on it
|
||||||
|
was what kept this bridge invisible to every platform but Cursor.
|
||||||
|
"""
|
||||||
|
ticket = _read_json(ticket_path)
|
||||||
|
if ticket is None:
|
||||||
|
return None
|
||||||
|
if not _ticket_is_fresh(ticket, ticket_path, now):
|
||||||
|
return None
|
||||||
|
if not _ticket_cwd_matches_repo(ticket, repo_root):
|
||||||
|
return None
|
||||||
|
if not _pending_ticket_matches_args(ticket, repo_root):
|
||||||
|
return None
|
||||||
|
return _string_value(ticket.get("context_key"))
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_shell_ticket_context_key() -> str | None:
|
||||||
|
"""Resolve session identity from a short-lived shell ticket.
|
||||||
|
|
||||||
|
No researched platform exports its session id into a shell child, but every
|
||||||
|
hook-capable one hands that id to a hook. So the hook that runs just before
|
||||||
|
a shell command writes a ticket, and this reads it back. A ticket counts
|
||||||
|
only when it is fresh, was written for this repo, and matches the `task.py`
|
||||||
|
subcommand now running — and only when exactly one fresh context key
|
||||||
|
matches. Two concurrent windows therefore both degrade rather than one
|
||||||
|
inheriting the other's pointer.
|
||||||
|
"""
|
||||||
|
repo_root = _find_repo_root_from_cwd()
|
||||||
|
if repo_root is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
candidates: set[str] = set()
|
||||||
|
for ticket_dir in _shell_ticket_dirs(repo_root):
|
||||||
|
if not ticket_dir.is_dir():
|
||||||
|
continue
|
||||||
|
for ticket_path in ticket_dir.glob("*.json"):
|
||||||
|
context_key = _matching_ticket_context_key(ticket_path, repo_root, now)
|
||||||
|
if context_key:
|
||||||
|
candidates.add(context_key)
|
||||||
|
|
||||||
|
if len(candidates) == 1:
|
||||||
|
return next(iter(candidates))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_context_key(
|
||||||
|
platform_input: dict[str, Any] | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
*,
|
||||||
|
allow_environment_context: bool = True,
|
||||||
|
) -> str | None:
|
||||||
|
"""Resolve a stable session/window context key, if one is available.
|
||||||
|
|
||||||
|
`TRELLIS_CONTEXT_ID` is an explicit context-key override used by CLI
|
||||||
|
scripts and subprocesses. It does not store the task itself.
|
||||||
|
"""
|
||||||
|
if allow_environment_context:
|
||||||
|
override = _string_value(os.environ.get("TRELLIS_CONTEXT_ID"))
|
||||||
|
if override:
|
||||||
|
return _sanitize_key(override) or _hash_value(override)
|
||||||
|
|
||||||
|
data = _as_dict(platform_input)
|
||||||
|
platform_name = _detect_platform(data, platform) if data or platform else None
|
||||||
|
|
||||||
|
if data:
|
||||||
|
session_id = _lookup_string(data, _SESSION_KEYS)
|
||||||
|
if session_id:
|
||||||
|
return _context_key(platform_name or "session", "session", session_id)
|
||||||
|
|
||||||
|
conversation_id = _lookup_string(data, _CONVERSATION_KEYS)
|
||||||
|
if conversation_id:
|
||||||
|
return _context_key(platform_name or "session", "conversation", conversation_id)
|
||||||
|
|
||||||
|
transcript_path = _lookup_string(data, _TRANSCRIPT_KEYS)
|
||||||
|
if transcript_path:
|
||||||
|
return _context_key(platform_name or "session", "transcript", transcript_path)
|
||||||
|
|
||||||
|
if allow_environment_context:
|
||||||
|
env_context_key = _lookup_env_context_key(platform_name)
|
||||||
|
if env_context_key:
|
||||||
|
return env_context_key
|
||||||
|
|
||||||
|
# Last in the chain on purpose: a platform that genuinely exports identity
|
||||||
|
# into the shell outranks a ticket, and no platform name gates the lookup.
|
||||||
|
if allow_environment_context:
|
||||||
|
return _lookup_shell_ticket_context_key()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
return data if isinstance(data, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_json(path: Path, data: dict[str, Any]) -> bool:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_task_ref(task_path: str, repo_root: Path) -> str | None:
|
||||||
|
normalized = normalize_task_ref(task_path)
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
full_path = resolve_task_ref(normalized, repo_root)
|
||||||
|
if full_path is None or not full_path.is_dir():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return full_path.relative_to(repo_root.resolve()).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
# resolve_task_ref already refused everything outside the repo, so this
|
||||||
|
# is unreachable. Refuse rather than fall back to an absolute path —
|
||||||
|
# that fallback is how an out-of-repo ref used to reach the session
|
||||||
|
# pointer and get replayed on every later turn.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _active_from_ref(
|
||||||
|
task_ref: str | None,
|
||||||
|
repo_root: Path,
|
||||||
|
source_type: str,
|
||||||
|
context_key: str | None = None,
|
||||||
|
) -> ActiveTask | None:
|
||||||
|
if not task_ref:
|
||||||
|
return None
|
||||||
|
resolved = resolve_task_ref(task_ref, repo_root)
|
||||||
|
stale = resolved is None or not resolved.is_dir()
|
||||||
|
return ActiveTask(task_ref, source_type, context_key, stale)
|
||||||
|
|
||||||
|
|
||||||
|
def _context_path(repo_root: Path, context_key: str) -> Path:
|
||||||
|
return _runtime_sessions_dir(repo_root) / f"{context_key}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_active_task(
|
||||||
|
repo_root: Path,
|
||||||
|
platform_input: dict[str, Any] | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
*,
|
||||||
|
allow_single_session_fallback: bool = True,
|
||||||
|
allow_environment_context: bool = True,
|
||||||
|
) -> ActiveTask:
|
||||||
|
"""Resolve the active task from session runtime state only.
|
||||||
|
|
||||||
|
A stale session task is returned as stale. Missing context identity or a
|
||||||
|
missing/empty session context falls back to single-session inference: if
|
||||||
|
exactly one session file exists in the runtime, return its task with
|
||||||
|
source_type="session-fallback" — covers pull-based platform sub-agents
|
||||||
|
(copilot, gemini, qoder) that don't inherit the parent's session id. ≥2
|
||||||
|
files or 0 files yield ActiveTask(None) — refuses to guess across windows.
|
||||||
|
"""
|
||||||
|
context_key = resolve_context_key(
|
||||||
|
platform_input,
|
||||||
|
platform,
|
||||||
|
allow_environment_context=allow_environment_context,
|
||||||
|
)
|
||||||
|
if context_key:
|
||||||
|
context = _read_json(_context_path(repo_root, context_key)) or {}
|
||||||
|
task_ref = _string_value(context.get("current_task"))
|
||||||
|
active = _active_from_ref(task_ref, repo_root, "session", context_key)
|
||||||
|
if active:
|
||||||
|
return active
|
||||||
|
|
||||||
|
if allow_single_session_fallback:
|
||||||
|
fallback = _resolve_single_session_fallback(repo_root)
|
||||||
|
if fallback is not None:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
return ActiveTask(None, "none", context_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_single_session_fallback(repo_root: Path) -> ActiveTask | None:
|
||||||
|
"""Return the task pointed at by the sole session file, if exactly one exists.
|
||||||
|
|
||||||
|
Used when context-key resolution fails (typical for class-2 platform
|
||||||
|
sub-agents). Returns None if 0 or ≥2 session files are present — refuses
|
||||||
|
to pick across windows so 04-21's multi-session isolation contract holds.
|
||||||
|
"""
|
||||||
|
sessions_dir = _runtime_sessions_dir(repo_root)
|
||||||
|
if not sessions_dir.is_dir():
|
||||||
|
return None
|
||||||
|
|
||||||
|
session_files = sorted(sessions_dir.glob("*.json"))
|
||||||
|
if len(session_files) != 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
session_file = session_files[0]
|
||||||
|
context = _read_json(session_file) or {}
|
||||||
|
task_ref = _string_value(context.get("current_task"))
|
||||||
|
if not task_ref:
|
||||||
|
return None
|
||||||
|
|
||||||
|
fallback_key = session_file.stem
|
||||||
|
return _active_from_ref(task_ref, repo_root, "session-fallback", fallback_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _context_metadata(
|
||||||
|
platform_input: dict[str, Any] | None,
|
||||||
|
platform: str | None,
|
||||||
|
context_key: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
data = _as_dict(platform_input) or {}
|
||||||
|
platform_name = _detect_platform(data, platform)
|
||||||
|
if platform_name == "session" and context_key:
|
||||||
|
prefix = context_key.split("_", 1)[0]
|
||||||
|
if prefix in _KNOWN_PLATFORMS:
|
||||||
|
platform_name = prefix
|
||||||
|
metadata: dict[str, Any] = {
|
||||||
|
"platform": platform_name,
|
||||||
|
"last_seen_at": _utc_now(),
|
||||||
|
}
|
||||||
|
for key in (*_SESSION_KEYS, *_CONVERSATION_KEYS, *_TRANSCRIPT_KEYS):
|
||||||
|
value = _lookup_string(data, (key,))
|
||||||
|
if value:
|
||||||
|
metadata[key] = value
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def set_active_task(
|
||||||
|
task_path: str,
|
||||||
|
repo_root: Path,
|
||||||
|
platform_input: dict[str, Any] | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
) -> ActiveTask | None:
|
||||||
|
"""Set the active task in session scope.
|
||||||
|
|
||||||
|
Returns None when no context key is available; callers should surface a
|
||||||
|
user-facing error that explains how to provide session identity.
|
||||||
|
"""
|
||||||
|
canonical = _canonical_task_ref(task_path, repo_root)
|
||||||
|
if canonical is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
context_key = resolve_context_key(platform_input, platform)
|
||||||
|
if not context_key:
|
||||||
|
return None
|
||||||
|
|
||||||
|
context_path = _context_path(repo_root, context_key)
|
||||||
|
context = _read_json(context_path) or {}
|
||||||
|
context.update(_context_metadata(platform_input, platform, context_key))
|
||||||
|
context["current_task"] = canonical
|
||||||
|
context.setdefault("current_run", None)
|
||||||
|
if not _write_json(context_path, context):
|
||||||
|
return None
|
||||||
|
return ActiveTask(canonical, "session", context_key)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_active_task(
|
||||||
|
repo_root: Path,
|
||||||
|
platform_input: dict[str, Any] | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
) -> ActiveTask:
|
||||||
|
"""Clear the active task by deleting its resolved session context file."""
|
||||||
|
context_key = resolve_context_key(platform_input, platform)
|
||||||
|
if not context_key:
|
||||||
|
return ActiveTask(None, "none")
|
||||||
|
|
||||||
|
previous = resolve_active_task(repo_root, platform_input, platform)
|
||||||
|
if not previous.task_path or not previous.context_key:
|
||||||
|
return previous
|
||||||
|
|
||||||
|
context_path = _context_path(repo_root, previous.context_key)
|
||||||
|
if context_path.is_file():
|
||||||
|
_remove_file(context_path)
|
||||||
|
return previous
|
||||||
|
|
||||||
|
|
||||||
|
def clear_task_from_sessions(task_path: str, repo_root: Path) -> int:
|
||||||
|
"""Delete all session runtime files that point at a task."""
|
||||||
|
target = _canonical_task_ref(task_path, repo_root) or normalize_task_ref(task_path)
|
||||||
|
if not target:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
cleared = 0
|
||||||
|
sessions_dir = _runtime_sessions_dir(repo_root)
|
||||||
|
if not sessions_dir.is_dir():
|
||||||
|
return cleared
|
||||||
|
|
||||||
|
for session_path in sessions_dir.glob("*.json"):
|
||||||
|
context = _read_json(session_path) or {}
|
||||||
|
current = _string_value(context.get("current_task"))
|
||||||
|
if not current:
|
||||||
|
continue
|
||||||
|
current_ref = _canonical_task_ref(current, repo_root) or normalize_task_ref(current)
|
||||||
|
if current_ref != target:
|
||||||
|
continue
|
||||||
|
if session_path.is_file() and _remove_file(session_path):
|
||||||
|
cleared += 1
|
||||||
|
|
||||||
|
return cleared
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_task_source(
|
||||||
|
repo_root: Path,
|
||||||
|
platform_input: dict[str, Any] | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
) -> tuple[str, str | None, str | None]:
|
||||||
|
"""Return (`source_type`, `context_key`, `task_path`) for compatibility."""
|
||||||
|
active = resolve_active_task(repo_root, platform_input, platform)
|
||||||
|
return active.source_type, active.context_key, active.task_path
|
||||||
950
.trellis/scripts/common/cli_adapter.py
Executable file
950
.trellis/scripts/common/cli_adapter.py
Executable file
@@ -0,0 +1,950 @@
|
|||||||
|
"""
|
||||||
|
CLI Adapter for Multi-Platform Support.
|
||||||
|
|
||||||
|
Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, Devin, Qoder, CodeBuddy, GitHub Copilot, Factory Droid, and Pi Agent interfaces.
|
||||||
|
|
||||||
|
Supported platforms:
|
||||||
|
- claude: Claude Code (default)
|
||||||
|
- opencode: OpenCode
|
||||||
|
- cursor: Cursor IDE
|
||||||
|
- iflow: iFlow CLI
|
||||||
|
- codex: Codex CLI (skills-based)
|
||||||
|
- kilo: Kilo CLI
|
||||||
|
- kiro: Kiro Code (skills-based)
|
||||||
|
- gemini: Gemini CLI
|
||||||
|
- antigravity: Antigravity (workflow-based)
|
||||||
|
- devin: Devin (formerly Windsurf; workflow-based)
|
||||||
|
- qoder: Qoder
|
||||||
|
- codebuddy: CodeBuddy
|
||||||
|
- copilot: GitHub Copilot (VS Code)
|
||||||
|
- droid: Factory Droid (commands-based)
|
||||||
|
- pi: Pi Agent (extension-backed)
|
||||||
|
- trae: Trae IDE (IDE-only, hooks-based)
|
||||||
|
- omp: Oh My Pi
|
||||||
|
- grok: Grok Build (pull-based skills/agents; no hook context injection)
|
||||||
|
- kimi: Kimi Code (pull-based skills; commands delivered as skills; no hook context injection)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from common.cli_adapter import CLIAdapter
|
||||||
|
|
||||||
|
adapter = CLIAdapter("opencode")
|
||||||
|
cmd = adapter.build_run_command(
|
||||||
|
agent="dispatch",
|
||||||
|
session_id="abc123",
|
||||||
|
prompt="Start the pipeline"
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import ClassVar, Literal
|
||||||
|
|
||||||
|
Platform = Literal[
|
||||||
|
"claude",
|
||||||
|
"opencode",
|
||||||
|
"cursor",
|
||||||
|
"iflow",
|
||||||
|
"codex",
|
||||||
|
"kilo",
|
||||||
|
"kiro",
|
||||||
|
"gemini",
|
||||||
|
"antigravity",
|
||||||
|
"devin",
|
||||||
|
"qoder",
|
||||||
|
"codebuddy",
|
||||||
|
"copilot",
|
||||||
|
"droid",
|
||||||
|
"pi",
|
||||||
|
"trae",
|
||||||
|
"omp",
|
||||||
|
"grok",
|
||||||
|
"kimi",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CLIAdapter:
|
||||||
|
"""Adapter for different AI coding CLI tools."""
|
||||||
|
|
||||||
|
platform: Platform
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Agent Name Mapping
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
# OpenCode has built-in agents that cannot be overridden
|
||||||
|
# See: https://github.com/sst/opencode/issues/4271
|
||||||
|
# Note: Class-level constant, not a dataclass field
|
||||||
|
_AGENT_NAME_MAP: ClassVar[dict[Platform, dict[str, str]]] = {
|
||||||
|
"claude": {}, # No mapping needed
|
||||||
|
"opencode": {
|
||||||
|
"plan": "trellis-plan", # 'plan' is built-in in OpenCode
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_agent_name(self, agent: str) -> str:
|
||||||
|
"""Get platform-specific agent name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent: Original agent name (e.g., 'plan', 'dispatch')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Platform-specific agent name (e.g., 'trellis-plan' for OpenCode)
|
||||||
|
"""
|
||||||
|
mapping = self._AGENT_NAME_MAP.get(self.platform, {})
|
||||||
|
return mapping.get(agent, agent)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Agent Path
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config_dir_name(self) -> str:
|
||||||
|
"""Get platform-specific config directory name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Directory name ('.claude', '.opencode', '.cursor', '.iflow', '.codex', '.kilocode', '.kiro', '.gemini', '.agent', '.devin', '.qoder', '.codebuddy', '.github/copilot', '.factory', '.pi', or '.trae')
|
||||||
|
"""
|
||||||
|
if self.platform == "opencode":
|
||||||
|
return ".opencode"
|
||||||
|
elif self.platform == "cursor":
|
||||||
|
return ".cursor"
|
||||||
|
elif self.platform == "iflow":
|
||||||
|
return ".iflow"
|
||||||
|
elif self.platform == "codex":
|
||||||
|
return ".codex"
|
||||||
|
elif self.platform == "kilo":
|
||||||
|
return ".kilocode"
|
||||||
|
elif self.platform == "kiro":
|
||||||
|
return ".kiro"
|
||||||
|
elif self.platform == "gemini":
|
||||||
|
return ".gemini"
|
||||||
|
elif self.platform == "antigravity":
|
||||||
|
return ".agent"
|
||||||
|
elif self.platform == "devin":
|
||||||
|
return ".devin"
|
||||||
|
elif self.platform == "qoder":
|
||||||
|
return ".qoder"
|
||||||
|
elif self.platform == "codebuddy":
|
||||||
|
return ".codebuddy"
|
||||||
|
elif self.platform == "copilot":
|
||||||
|
return ".github/copilot"
|
||||||
|
elif self.platform == "droid":
|
||||||
|
return ".factory"
|
||||||
|
elif self.platform == "pi":
|
||||||
|
return ".pi"
|
||||||
|
elif self.platform == "trae":
|
||||||
|
return ".trae"
|
||||||
|
elif self.platform == "omp":
|
||||||
|
return ".omp"
|
||||||
|
elif self.platform == "grok":
|
||||||
|
return ".grok"
|
||||||
|
elif self.platform == "kimi":
|
||||||
|
return ".kimi-code"
|
||||||
|
else:
|
||||||
|
return ".claude"
|
||||||
|
|
||||||
|
def get_config_dir(self, project_root: Path) -> Path:
|
||||||
|
"""Get platform-specific config directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_root: Project root directory
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to config directory (.claude, .opencode, .cursor, .iflow, .codex, .kilocode, .kiro, .gemini, .agent, .devin, .qoder, .codebuddy, .github/copilot, .factory, .pi, or .trae)
|
||||||
|
"""
|
||||||
|
return project_root / self.config_dir_name
|
||||||
|
|
||||||
|
def get_agent_path(self, agent: str, project_root: Path) -> Path:
|
||||||
|
"""Get path to agent definition file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent: Agent name (original, before mapping)
|
||||||
|
project_root: Project root directory
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to agent definition file (.md for most platforms, .toml for Codex)
|
||||||
|
"""
|
||||||
|
mapped_name = self.get_agent_name(agent)
|
||||||
|
if self.platform == "codex":
|
||||||
|
return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.toml"
|
||||||
|
return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.md"
|
||||||
|
|
||||||
|
def get_commands_path(self, project_root: Path, *parts: str) -> Path:
|
||||||
|
"""Get path to commands directory or specific command file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_root: Project root directory
|
||||||
|
*parts: Additional path parts (e.g., 'trellis', 'finish-work.md')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to commands directory or file
|
||||||
|
|
||||||
|
Note:
|
||||||
|
Cursor uses prefix naming: .cursor/commands/trellis-<name>.md
|
||||||
|
Antigravity uses workflow directory: .agent/workflows/<name>.md
|
||||||
|
Devin uses workflow directory: .devin/workflows/trellis-<name>.md
|
||||||
|
Copilot uses prompt files: .github/prompts/<name>.prompt.md
|
||||||
|
Pi uses prompt templates: .pi/prompts/trellis-<name>.md
|
||||||
|
Claude/OpenCode use subdirectory: .claude/commands/trellis/<name>.md
|
||||||
|
"""
|
||||||
|
if self.platform == "pi":
|
||||||
|
prompts_dir = self.get_config_dir(project_root) / "prompts"
|
||||||
|
if not parts:
|
||||||
|
return prompts_dir
|
||||||
|
if len(parts) >= 2 and parts[0] == "trellis":
|
||||||
|
filename = parts[-1]
|
||||||
|
if filename.endswith(".md"):
|
||||||
|
filename = filename[:-3]
|
||||||
|
return prompts_dir / f"trellis-{filename}.md"
|
||||||
|
return prompts_dir / Path(*parts)
|
||||||
|
# OMP and Grok: flat slash commands under .{platform}/commands/trellis-<name>.md
|
||||||
|
if self.platform in ("omp", "grok"):
|
||||||
|
commands_dir = self.get_config_dir(project_root) / "commands"
|
||||||
|
if not parts:
|
||||||
|
return commands_dir
|
||||||
|
if len(parts) >= 2 and parts[0] == "trellis":
|
||||||
|
filename = parts[-1]
|
||||||
|
if filename.endswith(".md"):
|
||||||
|
filename = filename[:-3]
|
||||||
|
return commands_dir / f"trellis-{filename}.md"
|
||||||
|
return commands_dir / Path(*parts)
|
||||||
|
|
||||||
|
# Kimi: commands are skills under .kimi-code/skills/trellis-<name>/SKILL.md
|
||||||
|
if self.platform == "kimi":
|
||||||
|
skills_dir = self.get_config_dir(project_root) / "skills"
|
||||||
|
if not parts:
|
||||||
|
return skills_dir
|
||||||
|
if len(parts) >= 2 and parts[0] == "trellis":
|
||||||
|
filename = parts[-1]
|
||||||
|
if filename.endswith(".md"):
|
||||||
|
filename = filename[:-3]
|
||||||
|
return skills_dir / f"trellis-{filename}" / "SKILL.md"
|
||||||
|
return skills_dir / Path(*parts)
|
||||||
|
|
||||||
|
if self.platform == "devin":
|
||||||
|
workflow_dir = self.get_config_dir(project_root) / "workflows"
|
||||||
|
if not parts:
|
||||||
|
return workflow_dir
|
||||||
|
if len(parts) >= 2 and parts[0] == "trellis":
|
||||||
|
filename = parts[-1]
|
||||||
|
return workflow_dir / f"trellis-{filename}"
|
||||||
|
return workflow_dir / Path(*parts)
|
||||||
|
|
||||||
|
if self.platform in ("antigravity", "kilo"):
|
||||||
|
workflow_dir = self.get_config_dir(project_root) / "workflows"
|
||||||
|
if not parts:
|
||||||
|
return workflow_dir
|
||||||
|
if len(parts) >= 2 and parts[0] == "trellis":
|
||||||
|
filename = parts[-1]
|
||||||
|
return workflow_dir / filename
|
||||||
|
return workflow_dir / Path(*parts)
|
||||||
|
|
||||||
|
if self.platform == "copilot":
|
||||||
|
prompts_dir = project_root / ".github" / "prompts"
|
||||||
|
if not parts:
|
||||||
|
return prompts_dir
|
||||||
|
if len(parts) >= 2 and parts[0] == "trellis":
|
||||||
|
filename = parts[-1]
|
||||||
|
if filename.endswith(".md"):
|
||||||
|
filename = filename[:-3]
|
||||||
|
return prompts_dir / f"{filename}.prompt.md"
|
||||||
|
return prompts_dir / Path(*parts)
|
||||||
|
|
||||||
|
if not parts:
|
||||||
|
return self.get_config_dir(project_root) / "commands"
|
||||||
|
|
||||||
|
# Cursor uses prefix naming instead of subdirectory
|
||||||
|
if self.platform == "cursor" and len(parts) >= 2 and parts[0] == "trellis":
|
||||||
|
# Convert trellis/<name>.md to trellis-<name>.md
|
||||||
|
filename = parts[-1]
|
||||||
|
return (
|
||||||
|
self.get_config_dir(project_root) / "commands" / f"trellis-{filename}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.get_config_dir(project_root) / "commands" / Path(*parts)
|
||||||
|
|
||||||
|
def get_trellis_command_path(self, name: str) -> str:
|
||||||
|
"""Get relative path to a trellis command file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Command name without extension (e.g., 'finish-work', 'check')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Relative path string for use in JSONL entries
|
||||||
|
|
||||||
|
Note:
|
||||||
|
Cursor: .cursor/commands/trellis-<name>.md
|
||||||
|
Codex: .agents/skills/trellis-<name>/SKILL.md
|
||||||
|
Kiro: .kiro/skills/trellis-<name>/SKILL.md
|
||||||
|
Gemini: .gemini/commands/trellis/<name>.toml
|
||||||
|
Antigravity: .agent/workflows/<name>.md
|
||||||
|
Devin: .devin/workflows/trellis-<name>.md
|
||||||
|
Pi: .pi/prompts/trellis-<name>.md
|
||||||
|
Others: .{platform}/commands/trellis/<name>.md
|
||||||
|
"""
|
||||||
|
if self.platform == "cursor":
|
||||||
|
return f".cursor/commands/trellis-{name}.md"
|
||||||
|
elif self.platform == "codex":
|
||||||
|
# 0.5.0-beta.0 renamed all skill dirs to add the `trellis-` prefix
|
||||||
|
# (see that release's manifest for the 60+ rename entries).
|
||||||
|
return f".agents/skills/trellis-{name}/SKILL.md"
|
||||||
|
elif self.platform == "kiro":
|
||||||
|
return f".kiro/skills/trellis-{name}/SKILL.md"
|
||||||
|
elif self.platform == "gemini":
|
||||||
|
return f".gemini/commands/trellis/{name}.toml"
|
||||||
|
elif self.platform == "antigravity":
|
||||||
|
return f".agent/workflows/{name}.md"
|
||||||
|
elif self.platform == "devin":
|
||||||
|
return f".devin/workflows/trellis-{name}.md"
|
||||||
|
elif self.platform == "kilo":
|
||||||
|
return f".kilocode/workflows/{name}.md"
|
||||||
|
elif self.platform == "copilot":
|
||||||
|
return f".github/prompts/{name}.prompt.md"
|
||||||
|
elif self.platform == "droid":
|
||||||
|
return f".factory/commands/trellis/{name}.md"
|
||||||
|
elif self.platform == "pi":
|
||||||
|
return f".pi/prompts/trellis-{name}.md"
|
||||||
|
elif self.platform in ("omp", "grok"):
|
||||||
|
return f"{self.config_dir_name}/commands/trellis-{name}.md"
|
||||||
|
elif self.platform == "kimi":
|
||||||
|
return f".kimi-code/skills/trellis-{name}/SKILL.md"
|
||||||
|
else:
|
||||||
|
return f"{self.config_dir_name}/commands/trellis/{name}.md"
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Environment Variables
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def get_non_interactive_env(self) -> dict[str, str]:
|
||||||
|
"""Get environment variables for non-interactive mode.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict of environment variables to set
|
||||||
|
"""
|
||||||
|
if self.platform == "opencode":
|
||||||
|
return {"OPENCODE_NON_INTERACTIVE": "1"}
|
||||||
|
elif self.platform == "iflow":
|
||||||
|
return {"IFLOW_NON_INTERACTIVE": "1"}
|
||||||
|
elif self.platform == "codex":
|
||||||
|
return {"CODEX_NON_INTERACTIVE": "1"}
|
||||||
|
elif self.platform == "kiro":
|
||||||
|
return {"KIRO_NON_INTERACTIVE": "1"}
|
||||||
|
elif self.platform == "gemini":
|
||||||
|
return {} # Gemini CLI doesn't have a non-interactive env var
|
||||||
|
elif self.platform == "antigravity":
|
||||||
|
return {}
|
||||||
|
elif self.platform == "devin":
|
||||||
|
return {}
|
||||||
|
elif self.platform == "qoder":
|
||||||
|
return {}
|
||||||
|
elif self.platform == "codebuddy":
|
||||||
|
return {}
|
||||||
|
elif self.platform == "copilot":
|
||||||
|
return {}
|
||||||
|
elif self.platform == "droid":
|
||||||
|
return {}
|
||||||
|
elif self.platform == "pi":
|
||||||
|
return {}
|
||||||
|
elif self.platform == "trae":
|
||||||
|
return {}
|
||||||
|
elif self.platform == "omp":
|
||||||
|
return {}
|
||||||
|
elif self.platform == "grok":
|
||||||
|
return {}
|
||||||
|
elif self.platform == "kimi":
|
||||||
|
return {}
|
||||||
|
else:
|
||||||
|
return {"CLAUDE_NON_INTERACTIVE": "1"}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# CLI Command Building
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def build_run_command(
|
||||||
|
self,
|
||||||
|
agent: str,
|
||||||
|
prompt: str,
|
||||||
|
session_id: str | None = None,
|
||||||
|
skip_permissions: bool = True,
|
||||||
|
verbose: bool = True,
|
||||||
|
json_output: bool = True,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build CLI command for running an agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent: Agent name (will be mapped if needed)
|
||||||
|
prompt: Prompt to send to the agent
|
||||||
|
session_id: Optional session ID (Claude Code only for creation)
|
||||||
|
skip_permissions: Whether to skip permission prompts
|
||||||
|
verbose: Whether to enable verbose output
|
||||||
|
json_output: Whether to use JSON output format
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of command arguments
|
||||||
|
"""
|
||||||
|
mapped_agent = self.get_agent_name(agent)
|
||||||
|
|
||||||
|
if self.platform == "opencode":
|
||||||
|
cmd = ["opencode", "run"]
|
||||||
|
cmd.extend(["--agent", mapped_agent])
|
||||||
|
|
||||||
|
# Note: OpenCode 'run' mode is non-interactive by default
|
||||||
|
# No equivalent to Claude Code's --dangerously-skip-permissions
|
||||||
|
# See: https://github.com/anomalyco/opencode/issues/9070
|
||||||
|
|
||||||
|
if json_output:
|
||||||
|
cmd.extend(["--format", "json"])
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
cmd.extend(["--log-level", "DEBUG", "--print-logs"])
|
||||||
|
|
||||||
|
# Note: OpenCode doesn't support --session-id on creation
|
||||||
|
# Session ID must be extracted from logs after startup
|
||||||
|
|
||||||
|
cmd.append(prompt)
|
||||||
|
|
||||||
|
elif self.platform == "iflow":
|
||||||
|
cmd = ["iflow", "-y", "-p"]
|
||||||
|
cmd.append(f"${mapped_agent} {prompt}")
|
||||||
|
elif self.platform == "codex":
|
||||||
|
cmd = ["codex", "exec"]
|
||||||
|
cmd.append(prompt)
|
||||||
|
elif self.platform == "kiro":
|
||||||
|
cmd = ["kiro", "run", prompt]
|
||||||
|
elif self.platform == "gemini":
|
||||||
|
cmd = ["gemini"]
|
||||||
|
cmd.append(prompt)
|
||||||
|
elif self.platform == "antigravity":
|
||||||
|
raise ValueError(
|
||||||
|
"Antigravity workflows are UI slash commands; CLI agent run is not supported."
|
||||||
|
)
|
||||||
|
elif self.platform == "devin":
|
||||||
|
raise ValueError(
|
||||||
|
"Devin workflows are UI slash commands; CLI agent run is not supported."
|
||||||
|
)
|
||||||
|
elif self.platform == "qoder":
|
||||||
|
cmd = ["qodercli", "-p", prompt]
|
||||||
|
elif self.platform == "codebuddy":
|
||||||
|
raise ValueError(
|
||||||
|
"CodeBuddy does not support non-interactive mode (no CLI agent)"
|
||||||
|
)
|
||||||
|
elif self.platform == "copilot":
|
||||||
|
raise ValueError(
|
||||||
|
"GitHub Copilot is IDE-only; CLI agent run is not supported."
|
||||||
|
)
|
||||||
|
elif self.platform == "droid":
|
||||||
|
raise ValueError(
|
||||||
|
"Factory Droid CLI agent run is not yet supported."
|
||||||
|
)
|
||||||
|
elif self.platform == "pi":
|
||||||
|
cmd = ["pi", "-p", prompt]
|
||||||
|
elif self.platform == "trae":
|
||||||
|
raise ValueError(
|
||||||
|
"Trae is IDE-only; CLI agent run is not supported."
|
||||||
|
)
|
||||||
|
elif self.platform == "omp":
|
||||||
|
raise ValueError(
|
||||||
|
"OMP uses native task tool for agent runs; CLI agent run is not supported."
|
||||||
|
)
|
||||||
|
elif self.platform == "grok":
|
||||||
|
# Headless single-prompt; sub-agents use in-process spawn_subagent.
|
||||||
|
cmd = ["grok", "-p", prompt, "--yolo"]
|
||||||
|
elif self.platform == "kimi":
|
||||||
|
# Headless single-prompt with auto-approval; sub-agents are the
|
||||||
|
# built-in coder/explore/plan agents dispatched in-session.
|
||||||
|
cmd = ["kimi", "-p", prompt, "--yolo"]
|
||||||
|
|
||||||
|
else: # claude
|
||||||
|
cmd = ["claude", "-p"]
|
||||||
|
cmd.extend(["--agent", mapped_agent])
|
||||||
|
|
||||||
|
if session_id:
|
||||||
|
cmd.extend(["--session-id", session_id])
|
||||||
|
|
||||||
|
if skip_permissions:
|
||||||
|
cmd.append("--dangerously-skip-permissions")
|
||||||
|
|
||||||
|
if json_output:
|
||||||
|
cmd.extend(["--output-format", "stream-json"])
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
cmd.append("--verbose")
|
||||||
|
|
||||||
|
cmd.append(prompt)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
def build_resume_command(self, session_id: str) -> list[str]:
|
||||||
|
"""Build CLI command for resuming a session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID to resume (ignored for iFlow)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of command arguments
|
||||||
|
"""
|
||||||
|
if self.platform == "opencode":
|
||||||
|
return ["opencode", "run", "--session", session_id]
|
||||||
|
elif self.platform == "iflow":
|
||||||
|
# iFlow uses -c to continue most recent conversation
|
||||||
|
# session_id is ignored as iFlow doesn't support session IDs
|
||||||
|
return ["iflow", "-c"]
|
||||||
|
elif self.platform == "codex":
|
||||||
|
return ["codex", "resume", session_id]
|
||||||
|
elif self.platform == "kiro":
|
||||||
|
return ["kiro", "resume", session_id]
|
||||||
|
elif self.platform == "gemini":
|
||||||
|
return ["gemini", "--resume", session_id]
|
||||||
|
elif self.platform == "antigravity":
|
||||||
|
raise ValueError(
|
||||||
|
"Antigravity workflows are UI slash commands; CLI resume is not supported."
|
||||||
|
)
|
||||||
|
elif self.platform == "devin":
|
||||||
|
raise ValueError(
|
||||||
|
"Devin workflows are UI slash commands; CLI resume is not supported."
|
||||||
|
)
|
||||||
|
elif self.platform == "qoder":
|
||||||
|
return ["qodercli", "--resume", session_id]
|
||||||
|
elif self.platform == "codebuddy":
|
||||||
|
raise ValueError(
|
||||||
|
"CodeBuddy does not support non-interactive mode (no CLI agent)"
|
||||||
|
)
|
||||||
|
elif self.platform == "copilot":
|
||||||
|
raise ValueError(
|
||||||
|
"GitHub Copilot is IDE-only; CLI resume is not supported."
|
||||||
|
)
|
||||||
|
elif self.platform == "droid":
|
||||||
|
raise ValueError(
|
||||||
|
"Factory Droid CLI resume is not yet supported."
|
||||||
|
)
|
||||||
|
elif self.platform == "pi":
|
||||||
|
return ["pi", "-c", session_id]
|
||||||
|
elif self.platform == "trae":
|
||||||
|
raise ValueError(
|
||||||
|
"Trae is IDE-only; CLI resume is not supported."
|
||||||
|
)
|
||||||
|
elif self.platform == "omp":
|
||||||
|
raise ValueError(
|
||||||
|
"OMP uses native task tool for agent runs; CLI resume is not supported."
|
||||||
|
)
|
||||||
|
elif self.platform == "grok":
|
||||||
|
return ["grok", "-c"]
|
||||||
|
elif self.platform == "kimi":
|
||||||
|
return ["kimi", "--session", session_id]
|
||||||
|
else:
|
||||||
|
return ["claude", "--resume", session_id]
|
||||||
|
|
||||||
|
def get_resume_command_str(self, session_id: str, cwd: str | None = None) -> str:
|
||||||
|
"""Get human-readable resume command string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID to resume
|
||||||
|
cwd: Optional working directory to cd into
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Command string for display
|
||||||
|
"""
|
||||||
|
cmd = self.build_resume_command(session_id)
|
||||||
|
cmd_str = " ".join(cmd)
|
||||||
|
|
||||||
|
if cwd:
|
||||||
|
return f"cd {cwd} && {cmd_str}"
|
||||||
|
return cmd_str
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Platform Detection Helpers
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_opencode(self) -> bool:
|
||||||
|
"""Check if platform is OpenCode."""
|
||||||
|
return self.platform == "opencode"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_claude(self) -> bool:
|
||||||
|
"""Check if platform is Claude Code."""
|
||||||
|
return self.platform == "claude"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_cursor(self) -> bool:
|
||||||
|
"""Check if platform is Cursor."""
|
||||||
|
return self.platform == "cursor"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_iflow(self) -> bool:
|
||||||
|
"""Check if platform is iFlow CLI."""
|
||||||
|
return self.platform == "iflow"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cli_name(self) -> str:
|
||||||
|
"""Get CLI executable name.
|
||||||
|
|
||||||
|
Note: Cursor doesn't have a CLI tool, returns None-like value.
|
||||||
|
"""
|
||||||
|
if self.is_opencode:
|
||||||
|
return "opencode"
|
||||||
|
elif self.is_cursor:
|
||||||
|
return "cursor" # Note: Cursor is IDE-only, no CLI
|
||||||
|
elif self.platform == "iflow":
|
||||||
|
return "iflow"
|
||||||
|
elif self.platform == "kiro":
|
||||||
|
return "kiro"
|
||||||
|
elif self.platform == "gemini":
|
||||||
|
return "gemini"
|
||||||
|
elif self.platform == "antigravity":
|
||||||
|
return "agy"
|
||||||
|
elif self.platform == "devin":
|
||||||
|
return "devin"
|
||||||
|
elif self.platform == "qoder":
|
||||||
|
return "qodercli"
|
||||||
|
elif self.platform == "codebuddy":
|
||||||
|
return "codebuddy"
|
||||||
|
elif self.platform == "copilot":
|
||||||
|
return "copilot"
|
||||||
|
elif self.platform == "droid":
|
||||||
|
return "droid"
|
||||||
|
elif self.platform == "pi":
|
||||||
|
return "pi"
|
||||||
|
elif self.platform == "trae":
|
||||||
|
return "trae"
|
||||||
|
elif self.platform == "omp":
|
||||||
|
return "omp"
|
||||||
|
elif self.platform == "grok":
|
||||||
|
return "grok"
|
||||||
|
elif self.platform == "kimi":
|
||||||
|
return "kimi"
|
||||||
|
else:
|
||||||
|
return "claude"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_cli_agents(self) -> bool:
|
||||||
|
"""Check if platform supports running agents via CLI.
|
||||||
|
|
||||||
|
Claude Code, OpenCode, iFlow, and Codex support CLI agent execution.
|
||||||
|
Cursor is IDE-only and doesn't support CLI agents.
|
||||||
|
"""
|
||||||
|
return self.platform in (
|
||||||
|
"claude",
|
||||||
|
"opencode",
|
||||||
|
"iflow",
|
||||||
|
"codex",
|
||||||
|
"pi",
|
||||||
|
"grok",
|
||||||
|
"kimi",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def requires_agent_definition_file(self) -> bool:
|
||||||
|
"""Check if platform requires an agent definition file (.md/.toml) to run.
|
||||||
|
|
||||||
|
Claude Code, OpenCode, iFlow: require agent .md files (--agent flag).
|
||||||
|
Codex: auto-discovers agents from .codex/agents/*.toml, no --agent flag.
|
||||||
|
"""
|
||||||
|
return self.platform in ("claude", "opencode", "iflow")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Session ID Handling
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_session_id_on_create(self) -> bool:
|
||||||
|
"""Check if platform supports specifying session ID on creation.
|
||||||
|
|
||||||
|
Claude Code: Yes (--session-id)
|
||||||
|
OpenCode: No (auto-generated, extract from logs)
|
||||||
|
iFlow: No (no session ID support)
|
||||||
|
"""
|
||||||
|
return self.platform == "claude"
|
||||||
|
|
||||||
|
def extract_session_id_from_log(self, log_content: str) -> str | None:
|
||||||
|
"""Extract session ID from log output (OpenCode only).
|
||||||
|
|
||||||
|
OpenCode generates session IDs in format: ses_xxx
|
||||||
|
|
||||||
|
Args:
|
||||||
|
log_content: Log file content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Session ID if found, None otherwise
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# OpenCode session ID pattern
|
||||||
|
match = re.search(r"ses_[a-zA-Z0-9]+", log_content)
|
||||||
|
if match:
|
||||||
|
return match.group(0)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Factory Function
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def get_cli_adapter(platform: str = "claude") -> CLIAdapter:
|
||||||
|
"""Get CLI adapter for the specified platform.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
platform: Platform name ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'devin', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', or 'trae')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CLIAdapter instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If platform is not supported
|
||||||
|
|
||||||
|
Note:
|
||||||
|
'windsurf' is accepted as a deprecated alias for 'devin' (Windsurf was
|
||||||
|
renamed to Devin) and normalized before validation.
|
||||||
|
"""
|
||||||
|
# Deprecated alias: Windsurf was renamed to Devin.
|
||||||
|
if platform == "windsurf":
|
||||||
|
platform = "devin"
|
||||||
|
if platform not in (
|
||||||
|
"claude",
|
||||||
|
"opencode",
|
||||||
|
"cursor",
|
||||||
|
"iflow",
|
||||||
|
"codex",
|
||||||
|
"kilo",
|
||||||
|
"kiro",
|
||||||
|
"gemini",
|
||||||
|
"antigravity",
|
||||||
|
"devin",
|
||||||
|
"qoder",
|
||||||
|
"codebuddy",
|
||||||
|
"copilot",
|
||||||
|
"droid",
|
||||||
|
"pi",
|
||||||
|
"trae",
|
||||||
|
"omp",
|
||||||
|
"grok",
|
||||||
|
"kimi",
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'devin', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', 'trae', 'omp', 'grok', or 'kimi')"
|
||||||
|
)
|
||||||
|
|
||||||
|
return CLIAdapter(platform=platform) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
_ALL_PLATFORM_CONFIG_DIRS = (
|
||||||
|
".claude",
|
||||||
|
".cursor",
|
||||||
|
".iflow",
|
||||||
|
".opencode",
|
||||||
|
".codex",
|
||||||
|
".kilocode",
|
||||||
|
".kiro",
|
||||||
|
".gemini",
|
||||||
|
".agent",
|
||||||
|
".devin",
|
||||||
|
".windsurf", # deprecated: pre-rename Devin config dir (still a platform signal)
|
||||||
|
".qoder",
|
||||||
|
".codebuddy",
|
||||||
|
".github/copilot",
|
||||||
|
".factory",
|
||||||
|
".pi",
|
||||||
|
".trae",
|
||||||
|
".omp",
|
||||||
|
".grok",
|
||||||
|
".kimi-code",
|
||||||
|
)
|
||||||
|
"""Platform-specific config directory names used by detect_platform exclusion
|
||||||
|
checks. `.agents/skills/` is NOT listed here: it is a shared cross-platform
|
||||||
|
layer (written by Codex, also consumed by Amp/Cline/Warp/etc. via the
|
||||||
|
agentskills.io standard), not a single-platform signal. Its presence must not
|
||||||
|
block detection of Kiro, Antigravity, Devin, or other platforms."""
|
||||||
|
|
||||||
|
|
||||||
|
def _has_other_platform_dir(project_root: Path, exclude: set[str]) -> bool:
|
||||||
|
"""Check if any platform config dir exists besides those in *exclude*."""
|
||||||
|
return any(
|
||||||
|
(project_root / d).is_dir()
|
||||||
|
for d in _ALL_PLATFORM_CONFIG_DIRS
|
||||||
|
if d not in exclude
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_platform(project_root: Path) -> Platform:
|
||||||
|
"""Auto-detect platform based on existing config directories.
|
||||||
|
|
||||||
|
Detection order:
|
||||||
|
1. TRELLIS_PLATFORM environment variable (if set)
|
||||||
|
2. .opencode directory exists → opencode
|
||||||
|
3. .iflow directory exists → iflow
|
||||||
|
4. .cursor directory exists (without .claude) → cursor
|
||||||
|
5. .gemini directory exists → gemini
|
||||||
|
6. .codex exists and no other platform dirs → codex
|
||||||
|
7. .kilocode directory exists → kilo
|
||||||
|
8. .kiro/skills exists and no other platform dirs → kiro
|
||||||
|
9. .agent/workflows exists and no other platform dirs → antigravity
|
||||||
|
10. .devin/workflows (or legacy .windsurf/workflows) exists and no other platform dirs → devin
|
||||||
|
11. .codebuddy directory exists → codebuddy
|
||||||
|
12. .qoder directory exists → qoder
|
||||||
|
13. .github/copilot directory exists → copilot
|
||||||
|
14. .factory directory exists → droid
|
||||||
|
15. .pi directory exists → pi
|
||||||
|
16. .trae directory exists → trae
|
||||||
|
17. Default → claude
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_root: Project root directory
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Detected platform ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'devin', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', 'trae', or default 'claude')
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Check environment variable first
|
||||||
|
env_platform = os.environ.get("TRELLIS_PLATFORM", "").lower()
|
||||||
|
# Deprecated alias: Windsurf was renamed to Devin.
|
||||||
|
if env_platform == "windsurf":
|
||||||
|
env_platform = "devin"
|
||||||
|
if env_platform in (
|
||||||
|
"claude",
|
||||||
|
"opencode",
|
||||||
|
"cursor",
|
||||||
|
"iflow",
|
||||||
|
"codex",
|
||||||
|
"kilo",
|
||||||
|
"kiro",
|
||||||
|
"gemini",
|
||||||
|
"antigravity",
|
||||||
|
"devin",
|
||||||
|
"qoder",
|
||||||
|
"codebuddy",
|
||||||
|
"copilot",
|
||||||
|
"droid",
|
||||||
|
"pi",
|
||||||
|
"trae",
|
||||||
|
"omp",
|
||||||
|
"grok",
|
||||||
|
"kimi",
|
||||||
|
):
|
||||||
|
return env_platform # type: ignore
|
||||||
|
|
||||||
|
# Check for .opencode directory (OpenCode-specific)
|
||||||
|
if (project_root / ".opencode").is_dir():
|
||||||
|
return "opencode"
|
||||||
|
|
||||||
|
# Check for .iflow directory (iFlow-specific)
|
||||||
|
if (project_root / ".iflow").is_dir():
|
||||||
|
return "iflow"
|
||||||
|
|
||||||
|
# Check for .cursor directory (Cursor-specific)
|
||||||
|
# Only detect as cursor if .claude doesn't exist (to avoid confusion)
|
||||||
|
if (project_root / ".cursor").is_dir() and not (project_root / ".claude").is_dir():
|
||||||
|
return "cursor"
|
||||||
|
|
||||||
|
# Check for .gemini directory (Gemini CLI-specific)
|
||||||
|
if (project_root / ".gemini").is_dir():
|
||||||
|
return "gemini"
|
||||||
|
|
||||||
|
# Check for .codex directory (Codex-specific)
|
||||||
|
# .agents/skills/ alone does NOT trigger codex detection (it's a shared standard)
|
||||||
|
if (project_root / ".codex").is_dir() and not _has_other_platform_dir(
|
||||||
|
project_root, {".codex", ".agents"}
|
||||||
|
):
|
||||||
|
return "codex"
|
||||||
|
|
||||||
|
# Check for .kilocode directory (Kilo-specific)
|
||||||
|
if (project_root / ".kilocode").is_dir():
|
||||||
|
return "kilo"
|
||||||
|
|
||||||
|
# Check for Kiro skills directory only when no other platform config exists
|
||||||
|
if (project_root / ".kiro" / "skills").is_dir() and not _has_other_platform_dir(
|
||||||
|
project_root, {".kiro"}
|
||||||
|
):
|
||||||
|
return "kiro"
|
||||||
|
|
||||||
|
# Check for Antigravity workflow directory only when no other platform config exists
|
||||||
|
if (
|
||||||
|
project_root / ".agent" / "workflows"
|
||||||
|
).is_dir() and not _has_other_platform_dir(
|
||||||
|
project_root, {".agent", ".gemini"}
|
||||||
|
):
|
||||||
|
return "antigravity"
|
||||||
|
|
||||||
|
# Check for Devin workflow directory only when no other platform config
|
||||||
|
# exists. `.windsurf/workflows` is the legacy pre-rename path (still detected
|
||||||
|
# as devin for back-compat until users migrate via `trellis update --migrate`).
|
||||||
|
if (
|
||||||
|
(project_root / ".devin" / "workflows").is_dir()
|
||||||
|
or (project_root / ".windsurf" / "workflows").is_dir()
|
||||||
|
) and not _has_other_platform_dir(
|
||||||
|
project_root, {".devin", ".windsurf"}
|
||||||
|
):
|
||||||
|
return "devin"
|
||||||
|
|
||||||
|
# Check for .codebuddy directory (CodeBuddy-specific)
|
||||||
|
if (project_root / ".codebuddy").is_dir():
|
||||||
|
return "codebuddy"
|
||||||
|
|
||||||
|
# Check for .qoder directory (Qoder-specific)
|
||||||
|
if (project_root / ".qoder").is_dir():
|
||||||
|
return "qoder"
|
||||||
|
|
||||||
|
# Check for .github/copilot directory (GitHub Copilot-specific)
|
||||||
|
if (project_root / ".github" / "copilot").is_dir():
|
||||||
|
return "copilot"
|
||||||
|
|
||||||
|
# Check for .factory directory (Factory Droid-specific)
|
||||||
|
if (project_root / ".factory").is_dir():
|
||||||
|
return "droid"
|
||||||
|
|
||||||
|
# Check for .pi directory (Pi Agent-specific)
|
||||||
|
if (project_root / ".pi").is_dir():
|
||||||
|
return "pi"
|
||||||
|
|
||||||
|
# Check for .trae directory (Trae IDE-specific)
|
||||||
|
if (project_root / ".trae").is_dir():
|
||||||
|
return "trae"
|
||||||
|
|
||||||
|
# Check for .omp directory (OMP-specific)
|
||||||
|
if (project_root / ".omp").is_dir():
|
||||||
|
return "omp"
|
||||||
|
|
||||||
|
# Check for .grok directory (Grok Build-specific)
|
||||||
|
if (project_root / ".grok").is_dir():
|
||||||
|
return "grok"
|
||||||
|
|
||||||
|
# Check for .kimi-code directory (Kimi Code-specific)
|
||||||
|
if (project_root / ".kimi-code").is_dir():
|
||||||
|
return "kimi"
|
||||||
|
|
||||||
|
# Fallback: checkout only has the Codex shared-skills layer
|
||||||
|
# (.agents/skills/trellis-* dirs) and no explicit platform config dir.
|
||||||
|
# Happens on fresh clones where .codex/ is gitignored/absent but the
|
||||||
|
# shared skills were committed to git. Must guard against the case
|
||||||
|
# where .claude/ or any other platform dir also exists — .agents/skills/
|
||||||
|
# can legitimately coexist with any platform as a shared consumption
|
||||||
|
# layer for Amp/Cline/Warp/etc.
|
||||||
|
agents_skills = project_root / ".agents" / "skills"
|
||||||
|
if agents_skills.is_dir() and not _has_other_platform_dir(
|
||||||
|
project_root, set()
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
for entry in agents_skills.iterdir():
|
||||||
|
if entry.is_dir() and entry.name.startswith("trellis-"):
|
||||||
|
return "codex"
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return "claude"
|
||||||
|
|
||||||
|
|
||||||
|
def get_cli_adapter_auto(project_root: Path) -> CLIAdapter:
|
||||||
|
"""Get CLI adapter with auto-detected platform.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_root: Project root directory
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CLIAdapter instance for detected platform
|
||||||
|
"""
|
||||||
|
platform = detect_platform(project_root)
|
||||||
|
return CLIAdapter(platform=platform)
|
||||||
569
.trellis/scripts/common/config.py
Executable file
569
.trellis/scripts/common/config.py
Executable file
@@ -0,0 +1,569 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Trellis configuration reader.
|
||||||
|
|
||||||
|
Reads settings from .trellis/config.yaml with sensible defaults.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .paths import DIR_WORKFLOW, get_repo_root
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 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, ""
|
||||||
|
|
||||||
|
|
||||||
|
# Defaults
|
||||||
|
DEFAULT_SESSION_COMMIT_MESSAGE = "chore: record journal"
|
||||||
|
DEFAULT_MAX_JOURNAL_LINES = 2000
|
||||||
|
DEFAULT_SESSION_AUTO_COMMIT = True
|
||||||
|
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."""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip().lower() == "true"
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_config_path(repo_root: Path | None = None) -> Path:
|
||||||
|
"""Get path to config.yaml."""
|
||||||
|
root = repo_root or get_repo_root()
|
||||||
|
return root / DIR_WORKFLOW / CONFIG_FILE
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config(repo_root: Path | None = None) -> dict:
|
||||||
|
"""Load and parse config.yaml. Returns empty dict on any error."""
|
||||||
|
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 {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_commit_message(repo_root: Path | None = None) -> str:
|
||||||
|
"""Get the commit message for auto-committing session records."""
|
||||||
|
config = _load_config(repo_root)
|
||||||
|
return config.get("session_commit_message", DEFAULT_SESSION_COMMIT_MESSAGE)
|
||||||
|
|
||||||
|
|
||||||
|
def get_max_journal_lines(repo_root: Path | None = None) -> int:
|
||||||
|
"""Get the maximum lines per journal file."""
|
||||||
|
config = _load_config(repo_root)
|
||||||
|
value = config.get("max_journal_lines", DEFAULT_MAX_JOURNAL_LINES)
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return DEFAULT_MAX_JOURNAL_LINES
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_auto_commit(repo_root: Path | None = None) -> bool:
|
||||||
|
"""Whether scripts should auto-stage + auto-commit session/task changes.
|
||||||
|
|
||||||
|
Governs both ``add_session.py:_auto_commit_workspace`` and
|
||||||
|
``task_store.py:_auto_commit_archive``.
|
||||||
|
|
||||||
|
Default: ``True`` (existing behavior — auto-stage + auto-commit).
|
||||||
|
Set ``session_auto_commit: false`` in ``.trellis/config.yaml`` to skip
|
||||||
|
auto-staging entirely; the journal/archive files are still written to
|
||||||
|
disk, but the user manages ``git add`` / ``git commit`` themselves.
|
||||||
|
|
||||||
|
Accepts native YAML booleans (``true`` / ``false``) and the string
|
||||||
|
aliases ``true / false / yes / no / 1 / 0 / on / off`` (case-insensitive).
|
||||||
|
Invalid values fall back to ``True`` with a stderr warning.
|
||||||
|
"""
|
||||||
|
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 DEFAULT_SESSION_AUTO_COMMIT
|
||||||
|
|
||||||
|
|
||||||
|
def get_codex_dispatch_mode(repo_root: Path | None = None) -> str:
|
||||||
|
"""Return Codex dispatch mode.
|
||||||
|
|
||||||
|
Default is ``auto``, which dispatches Trellis sub-agents and uses native
|
||||||
|
context injection with a child-side fallback. ``inline`` is an explicit
|
||||||
|
opt-out. ``sub-agent`` remains a backwards-compatible alias for ``auto``.
|
||||||
|
|
||||||
|
Invalid explicit configuration falls back to ``inline`` rather than
|
||||||
|
unexpectedly dispatching a sub-agent. This CLI-facing parser is the only
|
||||||
|
place that emits a warning for invalid values; hook readers fail safely
|
||||||
|
without producing per-turn warning noise.
|
||||||
|
"""
|
||||||
|
config = _load_config(repo_root)
|
||||||
|
codex = config.get("codex")
|
||||||
|
if codex is None:
|
||||||
|
return DEFAULT_CODEX_DISPATCH_MODE
|
||||||
|
if not isinstance(codex, dict):
|
||||||
|
print(
|
||||||
|
f"[WARN] invalid codex config: {codex!r}; using inline",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return "inline"
|
||||||
|
|
||||||
|
raw = codex.get("dispatch_mode", DEFAULT_CODEX_DISPATCH_MODE)
|
||||||
|
mode = str(raw).strip().lower()
|
||||||
|
if mode in ("auto", "inline"):
|
||||||
|
return mode
|
||||||
|
if mode == "sub-agent":
|
||||||
|
return "auto"
|
||||||
|
print(
|
||||||
|
f"[WARN] invalid codex.dispatch_mode value: {raw!r}; using inline",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return "inline"
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CONTEXT_INJECTION_MAX_FILE_BYTES = 32768
|
||||||
|
DEFAULT_CONTEXT_INJECTION_MAX_ARTIFACT_BYTES = 65536
|
||||||
|
DEFAULT_CONTEXT_INJECTION_MAX_TOTAL_BYTES = 131072
|
||||||
|
|
||||||
|
|
||||||
|
def get_context_injection_limits(repo_root: Path | None = None) -> dict[str, int]:
|
||||||
|
"""Return sub-agent context injection byte limits.
|
||||||
|
|
||||||
|
Reads the ``context_injection:`` section of ``.trellis/config.yaml``:
|
||||||
|
|
||||||
|
context_injection:
|
||||||
|
max_file_bytes: 32768
|
||||||
|
max_artifact_bytes: 65536
|
||||||
|
max_total_bytes: 131072
|
||||||
|
|
||||||
|
``0`` disables the corresponding limit. Missing keys use their default;
|
||||||
|
invalid (non-int or negative) values fall back to the default for that
|
||||||
|
key with a stderr warning.
|
||||||
|
"""
|
||||||
|
defaults = {
|
||||||
|
"max_file_bytes": DEFAULT_CONTEXT_INJECTION_MAX_FILE_BYTES,
|
||||||
|
"max_artifact_bytes": DEFAULT_CONTEXT_INJECTION_MAX_ARTIFACT_BYTES,
|
||||||
|
"max_total_bytes": DEFAULT_CONTEXT_INJECTION_MAX_TOTAL_BYTES,
|
||||||
|
}
|
||||||
|
|
||||||
|
config = _load_config(repo_root)
|
||||||
|
section = config.get("context_injection")
|
||||||
|
if not isinstance(section, dict):
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
result = dict(defaults)
|
||||||
|
for key, default_value in defaults.items():
|
||||||
|
if key not in section:
|
||||||
|
continue
|
||||||
|
raw = section[key]
|
||||||
|
try:
|
||||||
|
value = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
print(
|
||||||
|
f"[WARN] invalid context_injection.{key} value: {raw!r}; "
|
||||||
|
f"using default {default_value}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if value < 0:
|
||||||
|
print(
|
||||||
|
f"[WARN] invalid context_injection.{key} value: {raw!r}; "
|
||||||
|
f"using default {default_value}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
result[key] = value
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD = "no-trellis"
|
||||||
|
|
||||||
|
|
||||||
|
def get_prompt_injection_config(repo_root: Path | None = None) -> dict[str, str]:
|
||||||
|
"""Return per-turn prompt injection config.
|
||||||
|
|
||||||
|
Reads the ``prompt_injection:`` section of ``.trellis/config.yaml``:
|
||||||
|
|
||||||
|
prompt_injection:
|
||||||
|
skip_keyword: "no-trellis" # "" disables the escape hatch entirely
|
||||||
|
|
||||||
|
``skip_keyword`` is the word-boundary, case-insensitive keyword that, when
|
||||||
|
present in the user's prompt, makes the per-turn workflow-state injection
|
||||||
|
emit nothing for that turn. Defaults to ``"no-trellis"``. A non-string
|
||||||
|
value falls back to the default.
|
||||||
|
"""
|
||||||
|
defaults = {"skip_keyword": DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD}
|
||||||
|
|
||||||
|
config = _load_config(repo_root)
|
||||||
|
section = config.get("prompt_injection")
|
||||||
|
if not isinstance(section, dict):
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
result = dict(defaults)
|
||||||
|
raw = section.get("skip_keyword", DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD)
|
||||||
|
if isinstance(raw, str):
|
||||||
|
result["skip_keyword"] = raw
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_hooks(event: str, repo_root: Path | None = None) -> list[str]:
|
||||||
|
"""Get hook commands for a lifecycle event.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: Event name (e.g. "after_create", "after_archive").
|
||||||
|
repo_root: Repository root path.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of shell commands to execute, empty if none configured.
|
||||||
|
"""
|
||||||
|
config = _load_config(repo_root)
|
||||||
|
hooks = config.get("hooks")
|
||||||
|
if not isinstance(hooks, dict):
|
||||||
|
return []
|
||||||
|
commands = hooks.get(event)
|
||||||
|
if isinstance(commands, list):
|
||||||
|
return [str(c) for c in commands]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Monorepo / Packages
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def get_packages(repo_root: Path | None = None) -> dict[str, dict] | None:
|
||||||
|
"""Get monorepo package declarations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping package name to its config (path, type, etc.),
|
||||||
|
or None if not configured (single-repo mode).
|
||||||
|
|
||||||
|
Example return:
|
||||||
|
{"cli": {"path": "packages/cli"}, "docs-site": {"path": "docs-site", "type": "submodule"}}
|
||||||
|
"""
|
||||||
|
config = _load_config(repo_root)
|
||||||
|
packages = config.get("packages")
|
||||||
|
if not isinstance(packages, dict):
|
||||||
|
return None
|
||||||
|
# Ensure each value is a dict (filter out scalar entries)
|
||||||
|
filtered = {k: v for k, v in packages.items() if isinstance(v, dict)}
|
||||||
|
if not filtered:
|
||||||
|
return None
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_package(repo_root: Path | None = None) -> str | None:
|
||||||
|
"""Get the default package name from config.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Package name string, or None if not configured.
|
||||||
|
"""
|
||||||
|
config = _load_config(repo_root)
|
||||||
|
value = config.get("default_package")
|
||||||
|
return str(value) if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_submodule_packages(repo_root: Path | None = None) -> dict[str, str]:
|
||||||
|
"""Get packages that are git submodules.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping package name to its path for submodule-type packages.
|
||||||
|
Empty dict if none configured.
|
||||||
|
|
||||||
|
Example return:
|
||||||
|
{"docs-site": "docs-site"}
|
||||||
|
"""
|
||||||
|
packages = get_packages(repo_root)
|
||||||
|
if packages is None:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
name: cfg.get("path", name)
|
||||||
|
for name, cfg in packages.items()
|
||||||
|
if cfg.get("type") == "submodule"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_git_packages(repo_root: Path | None = None) -> dict[str, str]:
|
||||||
|
"""Get packages that have their own independent git repository.
|
||||||
|
|
||||||
|
These are sub-directories with their own .git (not submodules),
|
||||||
|
marked with ``git: true`` in config.yaml.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping package name to its path for git-repo packages.
|
||||||
|
Empty dict if none configured.
|
||||||
|
|
||||||
|
Example config::
|
||||||
|
|
||||||
|
packages:
|
||||||
|
backend:
|
||||||
|
path: iqs
|
||||||
|
git: true
|
||||||
|
|
||||||
|
Example return::
|
||||||
|
|
||||||
|
{"backend": "iqs"}
|
||||||
|
"""
|
||||||
|
packages = get_packages(repo_root)
|
||||||
|
if packages is None:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
name: cfg.get("path", name)
|
||||||
|
for name, cfg in packages.items()
|
||||||
|
if _is_true_config_value(cfg.get("git"))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_monorepo(repo_root: Path | None = None) -> bool:
|
||||||
|
"""Check if the project is configured as a monorepo (has packages in config)."""
|
||||||
|
return get_packages(repo_root) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def get_spec_base(package: str | None = None, repo_root: Path | None = None) -> str:
|
||||||
|
"""Get the spec directory base path relative to .trellis/.
|
||||||
|
|
||||||
|
Single-repo: returns "spec"
|
||||||
|
Monorepo with package: returns "spec/<package>"
|
||||||
|
Monorepo without package: returns "spec" (caller should specify package)
|
||||||
|
"""
|
||||||
|
if package and is_monorepo(repo_root):
|
||||||
|
return f"spec/{package}"
|
||||||
|
return "spec"
|
||||||
|
|
||||||
|
|
||||||
|
def validate_package(package: str, repo_root: Path | None = None) -> bool:
|
||||||
|
"""Check if a package name is valid in this project.
|
||||||
|
|
||||||
|
Single-repo (no packages configured): always returns True.
|
||||||
|
Monorepo: returns True only if package exists in config.yaml packages.
|
||||||
|
"""
|
||||||
|
packages = get_packages(repo_root)
|
||||||
|
if packages is None:
|
||||||
|
return True # Single-repo, no validation needed
|
||||||
|
return package in packages
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_package(
|
||||||
|
task_package: str | None = None,
|
||||||
|
repo_root: Path | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Resolve package from inferred sources with validation.
|
||||||
|
|
||||||
|
Checks in order: task_package → default_package.
|
||||||
|
Invalid inferred values print a warning to stderr and are skipped.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Resolved package name, or None if no valid package found.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
CLI --package should be validated separately by the caller
|
||||||
|
(fail-fast with available packages list on error).
|
||||||
|
"""
|
||||||
|
packages = get_packages(repo_root)
|
||||||
|
if packages is None:
|
||||||
|
return None # Single-repo, no package needed
|
||||||
|
|
||||||
|
# Try task_package (guard against non-string values from malformed JSON)
|
||||||
|
if task_package and isinstance(task_package, str):
|
||||||
|
if task_package in packages:
|
||||||
|
return task_package
|
||||||
|
print(
|
||||||
|
f"Warning: task.json package '{task_package}' not found in config, skipping",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try default_package
|
||||||
|
default = get_default_package(repo_root)
|
||||||
|
if default:
|
||||||
|
if default in packages:
|
||||||
|
return default
|
||||||
|
print(
|
||||||
|
f"Warning: default_package '{default}' not found in config, skipping",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_spec_scope(repo_root: Path | None = None) -> list[str] | str | None:
|
||||||
|
"""Get session.spec_scope configuration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[str]: Package names to include in spec scanning.
|
||||||
|
str: "active_task" to use current task's package.
|
||||||
|
None: No scope configured (scan all packages).
|
||||||
|
"""
|
||||||
|
config = _load_config(repo_root)
|
||||||
|
session = config.get("session")
|
||||||
|
if not isinstance(session, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
scope = session.get("spec_scope")
|
||||||
|
if scope is None:
|
||||||
|
return None
|
||||||
|
if isinstance(scope, str):
|
||||||
|
return scope # e.g. "active_task"
|
||||||
|
if isinstance(scope, list):
|
||||||
|
return [str(s) for s in scope]
|
||||||
|
return None
|
||||||
190
.trellis/scripts/common/developer.py
Executable file
190
.trellis/scripts/common/developer.py
Executable file
@@ -0,0 +1,190 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Developer management utilities.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
init_developer - Initialize developer
|
||||||
|
ensure_developer - Ensure developer is initialized (exit if not)
|
||||||
|
show_developer_info - Show developer information
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .paths import (
|
||||||
|
DIR_WORKFLOW,
|
||||||
|
DIR_WORKSPACE,
|
||||||
|
DIR_TASKS,
|
||||||
|
FILE_DEVELOPER,
|
||||||
|
FILE_JOURNAL_PREFIX,
|
||||||
|
get_repo_root,
|
||||||
|
get_developer,
|
||||||
|
check_developer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Developer Initialization
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def init_developer(name: str, repo_root: Path | None = None) -> bool:
|
||||||
|
"""Initialize developer.
|
||||||
|
|
||||||
|
Creates:
|
||||||
|
- .trellis/.developer file with developer info
|
||||||
|
- .trellis/workspace/<name>/ directory structure
|
||||||
|
- Initial journal file and index.md
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Developer name.
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True on success, False on error.
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
print("Error: developer name is required", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
|
||||||
|
workspace_dir = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / name
|
||||||
|
|
||||||
|
# Create .developer file
|
||||||
|
initialized_at = datetime.now().isoformat()
|
||||||
|
try:
|
||||||
|
dev_file.write_text(
|
||||||
|
f"name={name}\ninitialized_at={initialized_at}\n",
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
except (OSError, IOError) as e:
|
||||||
|
print(f"Error: Failed to create .developer file: {e}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Create workspace directory structure
|
||||||
|
try:
|
||||||
|
workspace_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
except (OSError, IOError) as e:
|
||||||
|
print(f"Error: Failed to create workspace directory: {e}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Create initial journal file
|
||||||
|
journal_file = workspace_dir / f"{FILE_JOURNAL_PREFIX}1.md"
|
||||||
|
if not journal_file.exists():
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
journal_content = f"""# Journal - {name} (Part 1)
|
||||||
|
|
||||||
|
> AI development session journal
|
||||||
|
> Started: {today}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
journal_file.write_text(journal_content, encoding="utf-8")
|
||||||
|
except (OSError, IOError) as e:
|
||||||
|
print(f"Error: Failed to create journal file: {e}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Create index.md with markers for auto-update
|
||||||
|
index_file = workspace_dir / "index.md"
|
||||||
|
if not index_file.exists():
|
||||||
|
index_content = f"""# Workspace Index - {name}
|
||||||
|
|
||||||
|
> Journal tracking for AI development sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
<!-- @@@auto:current-status -->
|
||||||
|
- **Active File**: `journal-1.md`
|
||||||
|
- **Total Sessions**: 0
|
||||||
|
- **Last Active**: -
|
||||||
|
<!-- @@@/auto:current-status -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Active Documents
|
||||||
|
|
||||||
|
<!-- @@@auto:active-documents -->
|
||||||
|
| File | Lines | Status |
|
||||||
|
|------|-------|--------|
|
||||||
|
| `journal-1.md` | ~0 | Active |
|
||||||
|
<!-- @@@/auto:active-documents -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session History
|
||||||
|
|
||||||
|
<!-- @@@auto:session-history -->
|
||||||
|
| # | Date | Title | Commits | Branch |
|
||||||
|
|---|------|-------|---------|--------|
|
||||||
|
<!-- @@@/auto:session-history -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Sessions are appended to journal files
|
||||||
|
- New journal file created when current exceeds 2000 lines
|
||||||
|
- Use `add_session.py` to record sessions
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
index_file.write_text(index_content, encoding="utf-8")
|
||||||
|
except (OSError, IOError) as e:
|
||||||
|
print(f"Error: Failed to create index.md: {e}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"Developer initialized: {name}")
|
||||||
|
print(f" .developer file: {dev_file}")
|
||||||
|
print(f" Workspace dir: {workspace_dir}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_developer(repo_root: Path | None = None) -> None:
|
||||||
|
"""Ensure developer is initialized, exit if not.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
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)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def show_developer_info(repo_root: Path | None = None) -> None:
|
||||||
|
"""Show developer information.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
developer = get_developer(repo_root)
|
||||||
|
|
||||||
|
if not developer:
|
||||||
|
print("Developer: (not initialized)")
|
||||||
|
else:
|
||||||
|
print(f"Developer: {developer}")
|
||||||
|
print(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/")
|
||||||
|
print(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Main Entry (for testing)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
show_developer_info()
|
||||||
74
.trellis/scripts/common/git.py
Executable file
74
.trellis/scripts/common/git.py
Executable file
@@ -0,0 +1,74 @@
|
|||||||
|
"""
|
||||||
|
Git command execution utility.
|
||||||
|
|
||||||
|
Single source of truth for running git commands across all Trellis scripts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def run_git(
|
||||||
|
args: list[str],
|
||||||
|
cwd: Path | None = None,
|
||||||
|
timeout: float | None = None,
|
||||||
|
) -> tuple[int, str, str]:
|
||||||
|
"""Run a git command and return (returncode, stdout, stderr).
|
||||||
|
|
||||||
|
Uses UTF-8 encoding with -c i18n.logOutputEncoding=UTF-8 to ensure
|
||||||
|
consistent output across all platforms (Windows, macOS, Linux). Callers
|
||||||
|
may provide a timeout for best-effort probes; normal Git operations remain
|
||||||
|
unbounded by default.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
git_args = ["git", "-c", "i18n.logOutputEncoding=UTF-8"] + args
|
||||||
|
result = subprocess.run(
|
||||||
|
git_args,
|
||||||
|
cwd=cwd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
return result.returncode, result.stdout, result.stderr
|
||||||
|
except Exception as e:
|
||||||
|
return 1, "", str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_default_branch(repo_root: Path) -> str | None:
|
||||||
|
"""Resolve the repository's default branch (origin/HEAD target).
|
||||||
|
|
||||||
|
Tries the local `refs/remotes/origin/HEAD` symbolic ref first (no
|
||||||
|
network access), then falls back to `git remote show origin` (which
|
||||||
|
may hit the network but also repairs a missing/stale symbolic-ref).
|
||||||
|
Returns None when neither resolves, so callers can fall back to their
|
||||||
|
own pre-existing behavior.
|
||||||
|
"""
|
||||||
|
rc, out, _ = run_git(["symbolic-ref", "refs/remotes/origin/HEAD"], cwd=repo_root)
|
||||||
|
if rc == 0 and out.strip():
|
||||||
|
return out.strip().rsplit("/", 1)[-1]
|
||||||
|
|
||||||
|
rc, out, _ = run_git(["remote", "show", "origin"], cwd=repo_root)
|
||||||
|
if rc == 0:
|
||||||
|
for line in out.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("HEAD branch:"):
|
||||||
|
branch = line.split(":", 1)[1].strip()
|
||||||
|
if branch and branch != "(unknown)":
|
||||||
|
return branch
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def branch_exists_locally(branch: str, repo_root: Path) -> bool:
|
||||||
|
"""Check whether a local branch ref exists in the repository."""
|
||||||
|
if not branch:
|
||||||
|
return False
|
||||||
|
rc, _, _ = run_git(
|
||||||
|
["rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"],
|
||||||
|
cwd=repo_root,
|
||||||
|
)
|
||||||
|
return rc == 0
|
||||||
106
.trellis/scripts/common/git_context.py
Executable file
106
.trellis/scripts/common/git_context.py
Executable file
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Git and Session Context utilities.
|
||||||
|
|
||||||
|
Entry shim — delegates to session_context and packages_context.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
output_json - Output context in JSON format
|
||||||
|
output_text - Output context in text format
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from .git import run_git
|
||||||
|
from .session_context import (
|
||||||
|
get_context_json,
|
||||||
|
get_context_text,
|
||||||
|
get_context_record_json,
|
||||||
|
get_context_text_record,
|
||||||
|
output_json,
|
||||||
|
output_text,
|
||||||
|
)
|
||||||
|
from .packages_context import (
|
||||||
|
get_context_packages_text,
|
||||||
|
get_context_packages_json,
|
||||||
|
)
|
||||||
|
from .trellis_config import read_trellis_config
|
||||||
|
from .workflow_phase import (
|
||||||
|
filter_platform,
|
||||||
|
get_phase_index,
|
||||||
|
get_step,
|
||||||
|
resolve_effective_platform,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backward-compatible alias — external modules import this name
|
||||||
|
_run_git_command = run_git
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Main Entry
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""CLI entry point."""
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Get Session Context for AI Agent")
|
||||||
|
parser.add_argument(
|
||||||
|
"--json",
|
||||||
|
"-j",
|
||||||
|
action="store_true",
|
||||||
|
help="Output in JSON format (works with any --mode)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mode",
|
||||||
|
"-m",
|
||||||
|
choices=["default", "record", "packages", "phase"],
|
||||||
|
default="default",
|
||||||
|
help="Output mode: default (full context), record (for record-session), packages (package info only), phase (workflow step extraction)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--step",
|
||||||
|
help="Step id for --mode phase, e.g. 1.1, 2.2. Omit to get the Phase Index.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--platform",
|
||||||
|
help="Platform name for --mode phase, e.g. cursor, claude-code. Filters platform-tagged blocks.",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.mode == "record":
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(get_context_record_json(), indent=2, ensure_ascii=False))
|
||||||
|
else:
|
||||||
|
print(get_context_text_record())
|
||||||
|
elif args.mode == "packages":
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(get_context_packages_json(), indent=2, ensure_ascii=False))
|
||||||
|
else:
|
||||||
|
print(get_context_packages_text())
|
||||||
|
elif args.mode == "phase":
|
||||||
|
content = get_step(args.step) if args.step else get_phase_index()
|
||||||
|
if not content.strip():
|
||||||
|
if args.step:
|
||||||
|
parser.exit(2, f"Step not found: {args.step}\n")
|
||||||
|
else:
|
||||||
|
parser.exit(2, "Phase Index section not found in workflow.md\n")
|
||||||
|
if args.platform:
|
||||||
|
effective = resolve_effective_platform(
|
||||||
|
args.platform, read_trellis_config()
|
||||||
|
)
|
||||||
|
content = filter_platform(content, effective)
|
||||||
|
print(content, end="")
|
||||||
|
else:
|
||||||
|
if args.json:
|
||||||
|
output_json()
|
||||||
|
else:
|
||||||
|
output_text()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
61
.trellis/scripts/common/io.py
Executable file
61
.trellis/scripts/common/io.py
Executable file
@@ -0,0 +1,61 @@
|
|||||||
|
"""
|
||||||
|
JSON file I/O utilities.
|
||||||
|
|
||||||
|
Provides read_json and write_json as the single source of truth
|
||||||
|
for JSON file operations across all Trellis scripts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(path: Path, data: dict) -> bool:
|
||||||
|
"""Write dict to JSON file with pretty formatting.
|
||||||
|
|
||||||
|
The write is atomic: content goes to a temp file in the same directory
|
||||||
|
and is then renamed over the target. A crash or Ctrl-C mid-write leaves
|
||||||
|
the existing file intact rather than truncated, so a corrupted task.json
|
||||||
|
can never make a task silently vanish from `task.py list`.
|
||||||
|
|
||||||
|
Returns True on success, False on error.
|
||||||
|
"""
|
||||||
|
payload = json.dumps(data, indent=2, ensure_ascii=False)
|
||||||
|
try:
|
||||||
|
fd, tmp = tempfile.mkstemp(
|
||||||
|
dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp"
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
f = os.fdopen(fd, "w", encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
# fdopen never took ownership of fd; close it ourselves.
|
||||||
|
os.close(fd)
|
||||||
|
raise
|
||||||
|
with f:
|
||||||
|
f.write(payload)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
45
.trellis/scripts/common/log.py
Executable file
45
.trellis/scripts/common/log.py
Executable file
@@ -0,0 +1,45 @@
|
|||||||
|
"""
|
||||||
|
Terminal output utilities: colors and structured logging.
|
||||||
|
|
||||||
|
Single source of truth for Colors and log_* functions
|
||||||
|
used across all Trellis scripts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class Colors:
|
||||||
|
"""ANSI color codes for terminal output."""
|
||||||
|
|
||||||
|
RED = "\033[0;31m"
|
||||||
|
GREEN = "\033[0;32m"
|
||||||
|
YELLOW = "\033[1;33m"
|
||||||
|
BLUE = "\033[0;34m"
|
||||||
|
CYAN = "\033[0;36m"
|
||||||
|
DIM = "\033[2m"
|
||||||
|
NC = "\033[0m" # No Color / Reset
|
||||||
|
|
||||||
|
|
||||||
|
def colored(text: str, color: str) -> str:
|
||||||
|
"""Apply ANSI color to text."""
|
||||||
|
return f"{color}{text}{Colors.NC}"
|
||||||
|
|
||||||
|
|
||||||
|
def log_info(msg: str) -> None:
|
||||||
|
"""Print info-level message with [INFO] prefix."""
|
||||||
|
print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def log_success(msg: str) -> None:
|
||||||
|
"""Print success message with [SUCCESS] prefix."""
|
||||||
|
print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def log_warn(msg: str) -> None:
|
||||||
|
"""Print warning message with [WARN] prefix."""
|
||||||
|
print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def log_error(msg: str) -> None:
|
||||||
|
"""Print error message with [ERROR] prefix."""
|
||||||
|
print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}")
|
||||||
238
.trellis/scripts/common/packages_context.py
Executable file
238
.trellis/scripts/common/packages_context.py
Executable file
@@ -0,0 +1,238 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Package discovery and context output.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
get_packages_info - Get structured package info
|
||||||
|
get_packages_section - Build PACKAGES text section
|
||||||
|
get_context_packages_text - Full packages text output (--mode packages)
|
||||||
|
get_context_packages_json - Full packages JSON output (--mode packages --json)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .config import _is_true_config_value, get_default_package, get_packages, get_spec_scope
|
||||||
|
from .paths import (
|
||||||
|
DIR_SPEC,
|
||||||
|
DIR_WORKFLOW,
|
||||||
|
get_current_task,
|
||||||
|
get_repo_root,
|
||||||
|
)
|
||||||
|
from .tasks import load_task
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Internal Helpers
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _scan_spec_layers(spec_dir: Path, package: str | None = None) -> list[str]:
|
||||||
|
"""Scan spec directory for available layers (subdirectories).
|
||||||
|
|
||||||
|
For monorepo: scans spec/<package>/
|
||||||
|
For single-repo: scans spec/
|
||||||
|
"""
|
||||||
|
target = spec_dir / package if package else spec_dir
|
||||||
|
if not target.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(
|
||||||
|
d.name for d in target.iterdir() if d.is_dir() and d.name != "guides"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_active_task_package(repo_root: Path) -> str | None:
|
||||||
|
"""Get the package field from the active task's task.json."""
|
||||||
|
current = get_current_task(repo_root)
|
||||||
|
if not current:
|
||||||
|
return None
|
||||||
|
ct = load_task(repo_root / current)
|
||||||
|
return ct.package if ct and ct.package else None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_scope_set(
|
||||||
|
packages: dict,
|
||||||
|
spec_scope,
|
||||||
|
task_pkg: str | None,
|
||||||
|
default_pkg: str | None,
|
||||||
|
) -> set | None:
|
||||||
|
"""Resolve spec_scope to a set of allowed package names, or None for full scan."""
|
||||||
|
if not packages:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if spec_scope is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(spec_scope, str) and spec_scope == "active_task":
|
||||||
|
if task_pkg and task_pkg in packages:
|
||||||
|
return {task_pkg}
|
||||||
|
if default_pkg and default_pkg in packages:
|
||||||
|
return {default_pkg}
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(spec_scope, list):
|
||||||
|
valid = {e for e in spec_scope if e in packages}
|
||||||
|
if valid:
|
||||||
|
return valid
|
||||||
|
# All invalid: fallback
|
||||||
|
if task_pkg and task_pkg in packages:
|
||||||
|
return {task_pkg}
|
||||||
|
if default_pkg and default_pkg in packages:
|
||||||
|
return {default_pkg}
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Public Functions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_packages_info(repo_root: Path) -> list[dict]:
|
||||||
|
"""Get structured package info for monorepo projects.
|
||||||
|
|
||||||
|
Returns list of dicts with keys: name, path, type, default, specLayers,
|
||||||
|
isSubmodule, isGitRepo.
|
||||||
|
Returns empty list for single-repo projects.
|
||||||
|
"""
|
||||||
|
packages = get_packages(repo_root)
|
||||||
|
if not packages:
|
||||||
|
return []
|
||||||
|
|
||||||
|
default_pkg = get_default_package(repo_root)
|
||||||
|
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for pkg_name, pkg_config in packages.items():
|
||||||
|
pkg_path = pkg_config.get("path", pkg_name) if isinstance(pkg_config, dict) else str(pkg_config)
|
||||||
|
pkg_type = pkg_config.get("type", "local") if isinstance(pkg_config, dict) else "local"
|
||||||
|
pkg_git = pkg_config.get("git", False) if isinstance(pkg_config, dict) else False
|
||||||
|
layers = _scan_spec_layers(spec_dir, pkg_name)
|
||||||
|
|
||||||
|
result.append({
|
||||||
|
"name": pkg_name,
|
||||||
|
"path": pkg_path,
|
||||||
|
"type": pkg_type,
|
||||||
|
"default": pkg_name == default_pkg,
|
||||||
|
"specLayers": layers,
|
||||||
|
"isSubmodule": pkg_type == "submodule",
|
||||||
|
"isGitRepo": _is_true_config_value(pkg_git),
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_packages_section(repo_root: Path) -> str:
|
||||||
|
"""Build the PACKAGES section for text output."""
|
||||||
|
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||||
|
pkg_info = get_packages_info(repo_root)
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append("## PACKAGES")
|
||||||
|
|
||||||
|
if not pkg_info:
|
||||||
|
lines.append("(single-repo mode)")
|
||||||
|
layers = _scan_spec_layers(spec_dir)
|
||||||
|
if layers:
|
||||||
|
lines.append(f"Spec layers: {', '.join(layers)}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
default_pkg = get_default_package(repo_root)
|
||||||
|
|
||||||
|
for pkg in pkg_info:
|
||||||
|
layers_str = f" [{', '.join(pkg['specLayers'])}]" if pkg["specLayers"] else ""
|
||||||
|
submodule_tag = " (submodule)" if pkg["isSubmodule"] else ""
|
||||||
|
git_repo_tag = " (git repo)" if pkg["isGitRepo"] else ""
|
||||||
|
default_tag = " *" if pkg["default"] else ""
|
||||||
|
lines.append(
|
||||||
|
f"- {pkg['name']:<16} {pkg['path']:<20}{layers_str}{submodule_tag}{git_repo_tag}{default_tag}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if default_pkg:
|
||||||
|
lines.append(f"Default package: {default_pkg}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def get_context_packages_text(repo_root: Path | None = None) -> str:
|
||||||
|
"""Get packages context as formatted text (for --mode packages)."""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
pkg_info = get_packages_info(repo_root)
|
||||||
|
lines: list[str] = []
|
||||||
|
|
||||||
|
if not pkg_info:
|
||||||
|
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||||
|
lines.append("Single-repo project (no packages configured)")
|
||||||
|
lines.append("")
|
||||||
|
layers = _scan_spec_layers(spec_dir)
|
||||||
|
if layers:
|
||||||
|
lines.append(f"Spec layers: {', '.join(layers)}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
# Resolve scope for annotations
|
||||||
|
packages_dict = get_packages(repo_root) or {}
|
||||||
|
default_pkg = get_default_package(repo_root)
|
||||||
|
spec_scope = get_spec_scope(repo_root)
|
||||||
|
task_pkg = _get_active_task_package(repo_root)
|
||||||
|
scope_set = _resolve_scope_set(packages_dict, spec_scope, task_pkg, default_pkg)
|
||||||
|
|
||||||
|
lines.append("## PACKAGES")
|
||||||
|
lines.append("")
|
||||||
|
for pkg in pkg_info:
|
||||||
|
default_tag = " (default)" if pkg["default"] else ""
|
||||||
|
type_tag = f" [{pkg['type']}]" if pkg["type"] != "local" else ""
|
||||||
|
git_tag = " [git repo]" if pkg["isGitRepo"] else ""
|
||||||
|
|
||||||
|
# Scope annotation
|
||||||
|
scope_tag = ""
|
||||||
|
if scope_set is not None and pkg["name"] not in scope_set:
|
||||||
|
scope_tag = " (out of scope)"
|
||||||
|
|
||||||
|
lines.append(f"### {pkg['name']}{default_tag}{type_tag}{git_tag}{scope_tag}")
|
||||||
|
lines.append(f"Path: {pkg['path']}")
|
||||||
|
if pkg["specLayers"]:
|
||||||
|
lines.append(f"Spec layers: {', '.join(pkg['specLayers'])}")
|
||||||
|
for layer in pkg["specLayers"]:
|
||||||
|
lines.append(f" - .trellis/spec/{pkg['name']}/{layer}/index.md")
|
||||||
|
else:
|
||||||
|
lines.append("Spec: not configured")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Also show shared guides
|
||||||
|
guides_dir = repo_root / DIR_WORKFLOW / DIR_SPEC / "guides"
|
||||||
|
if guides_dir.is_dir():
|
||||||
|
lines.append("### Shared Guides (always included)")
|
||||||
|
lines.append("Path: .trellis/spec/guides/index.md")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def get_context_packages_json(repo_root: Path | None = None) -> dict:
|
||||||
|
"""Get packages context as a dictionary (for --mode packages --json)."""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
pkg_info = get_packages_info(repo_root)
|
||||||
|
|
||||||
|
if not pkg_info:
|
||||||
|
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||||
|
layers = _scan_spec_layers(spec_dir)
|
||||||
|
return {
|
||||||
|
"mode": "single-repo",
|
||||||
|
"specLayers": layers,
|
||||||
|
}
|
||||||
|
|
||||||
|
default_pkg = get_default_package(repo_root)
|
||||||
|
spec_scope = get_spec_scope(repo_root)
|
||||||
|
task_pkg = _get_active_task_package(repo_root)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mode": "monorepo",
|
||||||
|
"packages": pkg_info,
|
||||||
|
"defaultPackage": default_pkg,
|
||||||
|
"specScope": spec_scope,
|
||||||
|
"activeTaskPackage": task_pkg,
|
||||||
|
}
|
||||||
480
.trellis/scripts/common/paths.py
Executable file
480
.trellis/scripts/common/paths.py
Executable file
@@ -0,0 +1,480 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Common path utilities for Trellis workflow.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
get_repo_root - Get repository root directory
|
||||||
|
get_developer - Get developer name
|
||||||
|
get_workspace_dir - Get developer workspace directory
|
||||||
|
get_tasks_dir - Get tasks directory
|
||||||
|
get_active_journal_file - Get current journal file
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Path Constants (change here to rename directories)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Directory names
|
||||||
|
DIR_WORKFLOW = ".trellis"
|
||||||
|
DIR_WORKSPACE = "workspace"
|
||||||
|
DIR_TASKS = "tasks"
|
||||||
|
DIR_ARCHIVE = "archive"
|
||||||
|
DIR_SPEC = "spec"
|
||||||
|
DIR_SCRIPTS = "scripts"
|
||||||
|
|
||||||
|
# File names
|
||||||
|
FILE_DEVELOPER = ".developer"
|
||||||
|
FILE_CURRENT_TASK = ".current-task"
|
||||||
|
FILE_TASK_JSON = "task.json"
|
||||||
|
FILE_JOURNAL_PREFIX = "journal-"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Repository Root
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_repo_root(start_path: Path | None = None) -> Path:
|
||||||
|
"""Find the nearest directory containing .trellis/ folder.
|
||||||
|
|
||||||
|
This handles nested git repos correctly (e.g., test project inside another repo).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_path: Starting directory to search from. Defaults to current directory.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to repository root, or current directory if no .trellis/ found.
|
||||||
|
"""
|
||||||
|
current = (start_path or Path.cwd()).resolve()
|
||||||
|
|
||||||
|
while current != current.parent:
|
||||||
|
if (current / DIR_WORKFLOW).is_dir():
|
||||||
|
return current
|
||||||
|
current = current.parent
|
||||||
|
|
||||||
|
# Fallback to current directory if no .trellis/ found
|
||||||
|
return Path.cwd().resolve()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Developer
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_developer(repo_root: Path | None = None) -> str | None:
|
||||||
|
"""Get developer name from .developer file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Developer name or None if not initialized.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
|
||||||
|
|
||||||
|
if not dev_file.is_file():
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def check_developer(repo_root: Path | None = None) -> bool:
|
||||||
|
"""Check if developer is initialized.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if developer is initialized.
|
||||||
|
"""
|
||||||
|
return get_developer(repo_root) is not None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tasks Directory
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_tasks_dir(repo_root: Path | None = None) -> Path:
|
||||||
|
"""Get tasks directory path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to tasks directory.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
return repo_root / DIR_WORKFLOW / DIR_TASKS
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Workspace Directory
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_workspace_dir(repo_root: Path | None = None) -> Path | None:
|
||||||
|
"""Get developer workspace directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to workspace directory or None if developer not set.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
developer = get_developer(repo_root)
|
||||||
|
if developer:
|
||||||
|
return repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Journal File
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_active_journal_file(repo_root: Path | None = None) -> Path | None:
|
||||||
|
"""Get the current active journal file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to active journal file or None if not found.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
workspace_dir = get_workspace_dir(repo_root)
|
||||||
|
if workspace_dir is None or not workspace_dir.is_dir():
|
||||||
|
return None
|
||||||
|
|
||||||
|
latest: Path | None = None
|
||||||
|
highest = 0
|
||||||
|
|
||||||
|
for f in workspace_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"):
|
||||||
|
if not f.is_file():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract number from filename
|
||||||
|
name = f.stem # e.g., "journal-1"
|
||||||
|
match = re.search(r"(\d+)$", name)
|
||||||
|
if match:
|
||||||
|
num = int(match.group(1))
|
||||||
|
if num > highest:
|
||||||
|
highest = num
|
||||||
|
latest = f
|
||||||
|
|
||||||
|
return latest
|
||||||
|
|
||||||
|
|
||||||
|
def count_lines(file_path: Path) -> int:
|
||||||
|
"""Count lines in a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of lines, or 0 if file doesn't exist.
|
||||||
|
"""
|
||||||
|
if not file_path.is_file():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
return len(file_path.read_text(encoding="utf-8").splitlines())
|
||||||
|
except (OSError, IOError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Current Task Management
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def normalize_task_ref(task_ref: str) -> str:
|
||||||
|
"""Normalize a task ref for stable runtime storage.
|
||||||
|
|
||||||
|
Stored refs should prefer repo-relative POSIX paths like
|
||||||
|
`.trellis/tasks/03-27-my-task`, even on Windows. Absolute paths are preserved
|
||||||
|
unless they can later be converted back to repo-relative form by callers.
|
||||||
|
"""
|
||||||
|
normalized = task_ref.strip()
|
||||||
|
if not normalized:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
path_obj = Path(normalized)
|
||||||
|
if path_obj.is_absolute():
|
||||||
|
return str(path_obj)
|
||||||
|
|
||||||
|
normalized = normalized.replace("\\", "/")
|
||||||
|
while normalized.startswith("./"):
|
||||||
|
normalized = normalized[2:]
|
||||||
|
|
||||||
|
if normalized.startswith(f"{DIR_TASKS}/"):
|
||||||
|
return f"{DIR_WORKFLOW}/{normalized}"
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_task_ref(task_ref: str, repo_root: Path | None = None) -> Path | None:
|
||||||
|
"""Resolve a task ref to an absolute task directory path inside the repo.
|
||||||
|
|
||||||
|
Returns None when the ref resolves outside `repo_root`. Every reader of the
|
||||||
|
active task — `task.py`, the shared hooks, the platform extensions — comes
|
||||||
|
through here, so containment is enforced at this one point rather than at
|
||||||
|
each call site.
|
||||||
|
|
||||||
|
It matters because a ref is not always something the user typed. It round
|
||||||
|
trips through the session pointer under `.trellis/.runtime/sessions/`, and
|
||||||
|
`..` segments used to survive that trip intact: `_canonical_task_ref`
|
||||||
|
compares lexically, and a lexical `relative_to` accepts
|
||||||
|
`<root>/.trellis/tasks/../../../elsewhere` because the string does start
|
||||||
|
with the root. The ref was then stored verbatim and replayed on every later
|
||||||
|
turn, so `task.py start .trellis/tasks/../../../elsewhere` both rewrote that
|
||||||
|
directory's `task.json` and fed its files to the model.
|
||||||
|
|
||||||
|
Resolving here also normalises the path, so callers get a ref without `..`
|
||||||
|
to store.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
normalized = normalize_task_ref(task_ref)
|
||||||
|
if not normalized:
|
||||||
|
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
|
||||||
|
else:
|
||||||
|
candidate = repo_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()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved.relative_to(root)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_task(
|
||||||
|
repo_root: Path | None = None,
|
||||||
|
platform_input: dict | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Get current task directory path (relative to repo_root).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Relative path to current task directory or None.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
from .active_task import resolve_active_task
|
||||||
|
|
||||||
|
return resolve_active_task(repo_root, platform_input, platform).task_path
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_task_abs(
|
||||||
|
repo_root: Path | None = None,
|
||||||
|
platform_input: dict | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
) -> Path | None:
|
||||||
|
"""Get current task directory absolute path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Absolute path to current task directory or None.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
relative = get_current_task(repo_root, platform_input, platform)
|
||||||
|
if relative:
|
||||||
|
return resolve_task_ref(relative, repo_root)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_task_source(
|
||||||
|
repo_root: Path | None = None,
|
||||||
|
platform_input: dict | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
) -> tuple[str, str | None, str | None]:
|
||||||
|
"""Get active task source as (`source`, `context_key`, `task_path`)."""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
from .active_task import get_current_task_source as _get_source
|
||||||
|
|
||||||
|
return _get_source(repo_root, platform_input, platform)
|
||||||
|
|
||||||
|
|
||||||
|
def set_current_task(
|
||||||
|
task_path: str,
|
||||||
|
repo_root: Path | None = None,
|
||||||
|
platform_input: dict | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Set current task in session scope.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_path: Task directory path (relative to repo_root).
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True on success, False on error.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
from .active_task import set_active_task
|
||||||
|
|
||||||
|
return set_active_task(
|
||||||
|
task_path,
|
||||||
|
repo_root,
|
||||||
|
platform_input=platform_input,
|
||||||
|
platform=platform,
|
||||||
|
) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def clear_current_task(
|
||||||
|
repo_root: Path | None = None,
|
||||||
|
platform_input: dict | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Clear current task in session scope.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True on success.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
from .active_task import clear_active_task
|
||||||
|
|
||||||
|
clear_active_task(
|
||||||
|
repo_root,
|
||||||
|
platform_input=platform_input,
|
||||||
|
platform=platform,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def has_current_task(repo_root: Path | None = None) -> bool:
|
||||||
|
"""Check if has current task.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if current task is set.
|
||||||
|
"""
|
||||||
|
return get_current_task(repo_root) is not None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Task ID Generation
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def generate_task_date_prefix() -> str:
|
||||||
|
"""Generate task ID based on date (MM-DD format).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Date prefix string (e.g., "01-21").
|
||||||
|
"""
|
||||||
|
return datetime.now().strftime("%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Monorepo / Package Paths
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def get_spec_dir(package: str | None = None, repo_root: Path | None = None) -> Path:
|
||||||
|
"""Get the spec directory path.
|
||||||
|
|
||||||
|
Single-repo: .trellis/spec
|
||||||
|
Monorepo with package: .trellis/spec/<package>
|
||||||
|
|
||||||
|
Uses lazy import to avoid circular dependency with config.py.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
from .config import get_spec_base
|
||||||
|
|
||||||
|
base = get_spec_base(package, repo_root)
|
||||||
|
return repo_root / DIR_WORKFLOW / base
|
||||||
|
|
||||||
|
|
||||||
|
def get_package_path(package: str, repo_root: Path | None = None) -> Path | None:
|
||||||
|
"""Get a package's source directory absolute path from config.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Absolute path to the package directory, or None if not found.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
from .config import get_packages
|
||||||
|
|
||||||
|
packages = get_packages(repo_root)
|
||||||
|
if not packages or package not in packages:
|
||||||
|
return None
|
||||||
|
|
||||||
|
info = packages[package]
|
||||||
|
if isinstance(info, dict):
|
||||||
|
rel_path = info.get("path", package)
|
||||||
|
else:
|
||||||
|
rel_path = str(info)
|
||||||
|
|
||||||
|
return repo_root / rel_path
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Main Entry (for testing)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
repo = get_repo_root()
|
||||||
|
print(f"Repository root: {repo}")
|
||||||
|
print(f"Developer: {get_developer(repo)}")
|
||||||
|
print(f"Tasks dir: {get_tasks_dir(repo)}")
|
||||||
|
print(f"Workspace dir: {get_workspace_dir(repo)}")
|
||||||
|
print(f"Journal file: {get_active_journal_file(repo)}")
|
||||||
|
print(f"Current task: {get_current_task(repo)}")
|
||||||
315
.trellis/scripts/common/safe_commit.py
Executable file
315
.trellis/scripts/common/safe_commit.py
Executable file
@@ -0,0 +1,315 @@
|
|||||||
|
"""
|
||||||
|
Safe git-add helpers for Trellis-owned paths.
|
||||||
|
|
||||||
|
Why this module exists
|
||||||
|
----------------------
|
||||||
|
A real user incident: a project's `.gitignore` listed `.trellis/` (company-wide
|
||||||
|
template / personal habit). When `add_session.py` and `task.py archive` ran
|
||||||
|
their auto-commit and `git add` failed with `ignored by .gitignore`, the AI
|
||||||
|
agent driving the workflow "fixed" it by retrying with
|
||||||
|
`git add -f .trellis/` — which fan-out-included every ignored subtree
|
||||||
|
(`.trellis/.backup-*/`, `.trellis/worktrees/`, `.trellis/.template-hashes.json`,
|
||||||
|
`.trellis/.runtime/`), committing 548 files / 83474 lines of caches/backups.
|
||||||
|
|
||||||
|
Design
|
||||||
|
------
|
||||||
|
- Scripts only stage SPECIFIC product paths (journal files, index.md, the
|
||||||
|
current task dir, the archive dir). Never the whole `.trellis/` tree.
|
||||||
|
- If plain `git add <specific>` fails with "ignored by", DO NOT retry with
|
||||||
|
``-f``. The presence of `.trellis/` in `.gitignore` is treated as user
|
||||||
|
intent ("keep .trellis/ local-only"). The script warns and skips the
|
||||||
|
auto-commit; users who want auto-staging can either fix their `.gitignore`
|
||||||
|
or set ``session_auto_commit: false`` and manage git themselves.
|
||||||
|
- The warning includes a negative example: ``Do NOT use `git add -f .trellis/` ...``
|
||||||
|
so any AI rereading the log doesn't reinvent the bug.
|
||||||
|
|
||||||
|
History note: 0.5.10 introduced an automatic ``git add -f`` retry on the
|
||||||
|
specific paths. That was reverted in 0.5.11 — auto-forcing into a tree the
|
||||||
|
user had gitignored violates user intent even when the path list is narrow.
|
||||||
|
The wider-grain forbidden command stays forbidden, and the narrow-grain auto
|
||||||
|
``-f`` is gone too.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .git import run_git
|
||||||
|
from .paths import (
|
||||||
|
DIR_ARCHIVE,
|
||||||
|
DIR_TASKS,
|
||||||
|
DIR_WORKFLOW,
|
||||||
|
DIR_WORKSPACE,
|
||||||
|
FILE_JOURNAL_PREFIX,
|
||||||
|
get_developer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Paths under .trellis/ that must NEVER be auto-staged. Listed here so the
|
||||||
|
# warning to the user can show concrete subpaths to ignore individually
|
||||||
|
# instead of ignoring the whole `.trellis/` tree.
|
||||||
|
TRELLIS_IGNORED_SUBPATHS = (
|
||||||
|
".trellis/.backup-*",
|
||||||
|
".trellis/worktrees/",
|
||||||
|
".trellis/.template-hashes.json",
|
||||||
|
".trellis/.runtime/",
|
||||||
|
".trellis/.cache/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_trellis_paths_to_add(
|
||||||
|
repo_root: Path,
|
||||||
|
task_name: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return the list of repo-relative paths the auto-commit should stage.
|
||||||
|
|
||||||
|
Only includes paths that exist on disk so callers don't pass non-existent
|
||||||
|
arguments to git. The caller is responsible for `git diff --cached`
|
||||||
|
checking afterwards.
|
||||||
|
|
||||||
|
Included:
|
||||||
|
- .trellis/workspace/<developer>/journal-*.md
|
||||||
|
- .trellis/workspace/<developer>/index.md
|
||||||
|
- .trellis/tasks/<task_name>/ (ONLY the current task dir when
|
||||||
|
``task_name`` is passed; plus its archive location if the task
|
||||||
|
already lives under archive/)
|
||||||
|
|
||||||
|
Excluded (intentionally — these must not be staged):
|
||||||
|
- .trellis/.backup-*, .trellis/worktrees/,
|
||||||
|
.trellis/.template-hashes.json, .trellis/.runtime/, .trellis/.cache/
|
||||||
|
|
||||||
|
Scope contract (see #303 / break-loop analysis): when ``task_name`` is
|
||||||
|
passed, the task segment stages ONLY that task directory — it never walks
|
||||||
|
``tasks_dir.iterdir()`` over all active tasks. This mirrors
|
||||||
|
:func:`safe_archive_paths_to_add` and prevents dirty changes in OTHER
|
||||||
|
parallel-window task dirs from being bundled into the session auto-commit.
|
||||||
|
|
||||||
|
Backwards-compat: with no ``task_name``, the function walks every active
|
||||||
|
task directory (+ the archive subtree) the old wide way. New callers
|
||||||
|
should always pass ``task_name``.
|
||||||
|
"""
|
||||||
|
paths: list[str] = []
|
||||||
|
|
||||||
|
# Workspace journal files + index.md
|
||||||
|
developer = get_developer(repo_root)
|
||||||
|
if developer:
|
||||||
|
ws = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer
|
||||||
|
if ws.is_dir():
|
||||||
|
for f in sorted(ws.glob(f"{FILE_JOURNAL_PREFIX}*.md")):
|
||||||
|
if f.is_file():
|
||||||
|
paths.append(
|
||||||
|
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{f.name}"
|
||||||
|
)
|
||||||
|
index_md = ws / "index.md"
|
||||||
|
if index_md.is_file():
|
||||||
|
paths.append(
|
||||||
|
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/index.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS
|
||||||
|
if not tasks_dir.is_dir():
|
||||||
|
return paths
|
||||||
|
|
||||||
|
if task_name is not None:
|
||||||
|
# Narrow scope — ONLY the current task directory (active or archived).
|
||||||
|
# Never iterdir() all tasks: parallel-window dirty task dirs must not
|
||||||
|
# leak into the session auto-commit.
|
||||||
|
active_task = tasks_dir / task_name
|
||||||
|
if active_task.is_dir():
|
||||||
|
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}")
|
||||||
|
archived_task = tasks_dir / DIR_ARCHIVE / task_name
|
||||||
|
if archived_task.is_dir():
|
||||||
|
paths.append(
|
||||||
|
f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{task_name}"
|
||||||
|
)
|
||||||
|
return paths
|
||||||
|
|
||||||
|
# Legacy wide scope (no task_name): each direct child of tasks/ that is a
|
||||||
|
# directory and not the archive root, plus the whole archive subtree.
|
||||||
|
for child in sorted(tasks_dir.iterdir()):
|
||||||
|
if not child.is_dir():
|
||||||
|
continue
|
||||||
|
if child.name == DIR_ARCHIVE:
|
||||||
|
continue
|
||||||
|
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child.name}")
|
||||||
|
|
||||||
|
archive_dir = tasks_dir / DIR_ARCHIVE
|
||||||
|
if archive_dir.is_dir():
|
||||||
|
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}")
|
||||||
|
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def safe_archive_paths_to_add(
|
||||||
|
repo_root: Path,
|
||||||
|
task_name: str | None = None,
|
||||||
|
modified_children: list[str] | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return paths to stage after `task.py archive`.
|
||||||
|
|
||||||
|
Scoped to ONLY the paths the archive operation actually touched:
|
||||||
|
|
||||||
|
- the archive subtree (where the freshly-moved task lives)
|
||||||
|
- the source task directory (for source-side deletes; caller pairs
|
||||||
|
this with `git rm --cached` since `git add` won't stage deletes
|
||||||
|
for a path that no longer exists in the working tree)
|
||||||
|
- any child task directories whose `task.json` was edited to drop
|
||||||
|
the archived parent (parent-children relationship update)
|
||||||
|
|
||||||
|
This narrow scope avoids "scope creep" — dirty changes in OTHER
|
||||||
|
active task dirs (parallel-window edits) are NOT bundled into the
|
||||||
|
archive commit. Callers handle each kind of change in its own
|
||||||
|
commit boundary.
|
||||||
|
|
||||||
|
Backwards-compat: with no arguments, the function walks the whole
|
||||||
|
`.trellis/tasks/` subtree the old way (active tasks + archive). New
|
||||||
|
callers should always pass `task_name`.
|
||||||
|
"""
|
||||||
|
paths: list[str] = []
|
||||||
|
tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS
|
||||||
|
if not tasks_dir.is_dir():
|
||||||
|
return paths
|
||||||
|
|
||||||
|
archive_dir = tasks_dir / DIR_ARCHIVE
|
||||||
|
|
||||||
|
if task_name is not None:
|
||||||
|
# Narrow scope — only paths that still exist on disk (so
|
||||||
|
# `git add` doesn't choke on the moved-away source). The caller
|
||||||
|
# handles the source-side deletes via `git rm --cached`
|
||||||
|
# explicitly.
|
||||||
|
if archive_dir.is_dir():
|
||||||
|
paths.append(
|
||||||
|
f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}"
|
||||||
|
)
|
||||||
|
for child_name in modified_children or []:
|
||||||
|
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child_name}")
|
||||||
|
return paths
|
||||||
|
|
||||||
|
# Legacy wide scope (no task_name): preserve old behavior so callers
|
||||||
|
# that have not been updated keep working.
|
||||||
|
if archive_dir.is_dir():
|
||||||
|
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}")
|
||||||
|
for child in sorted(tasks_dir.iterdir()):
|
||||||
|
if not child.is_dir():
|
||||||
|
continue
|
||||||
|
if child.name == DIR_ARCHIVE:
|
||||||
|
continue
|
||||||
|
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child.name}")
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def _stderr_indicates_ignored(stderr: str) -> bool:
|
||||||
|
"""git add error indicates the path is excluded by .gitignore."""
|
||||||
|
if not stderr:
|
||||||
|
return False
|
||||||
|
lowered = stderr.lower()
|
||||||
|
return "ignored by" in lowered
|
||||||
|
|
||||||
|
|
||||||
|
def safe_git_add(
|
||||||
|
paths: list[str], repo_root: Path
|
||||||
|
) -> tuple[bool, bool, str]:
|
||||||
|
"""Run `git add` on specific paths; never retry with -f.
|
||||||
|
|
||||||
|
Returns ``(success, used_force, stderr)``. The ``used_force`` field is
|
||||||
|
kept for signature compatibility with the 0.5.10 implementation but is
|
||||||
|
always ``False`` — we never auto-force.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- No paths passed → success, no force, empty stderr.
|
||||||
|
- Plain ``git add -- <paths>`` succeeds → return success.
|
||||||
|
- 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.
|
||||||
|
"""
|
||||||
|
if not paths:
|
||||||
|
return True, False, ""
|
||||||
|
|
||||||
|
rc, _, err = run_git(["add", "--", *paths], cwd=repo_root)
|
||||||
|
if rc == 0:
|
||||||
|
return True, False, ""
|
||||||
|
return False, False, err
|
||||||
|
|
||||||
|
|
||||||
|
def print_gitignore_warning(paths: list[str]) -> None:
|
||||||
|
"""Explain to the user (and any AI reading the log) what to do.
|
||||||
|
|
||||||
|
CRITICAL: includes the negative example
|
||||||
|
``Do NOT use `git add -f .trellis/``` — agents reading the warning are
|
||||||
|
known to invent that command, which fans out to ignored caches/backups.
|
||||||
|
"""
|
||||||
|
print(
|
||||||
|
"[WARN] git add failed because .trellis/ paths are ignored by your .gitignore.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"[WARN] Skipping auto-commit. The journal/task files were still written to disk;",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"[WARN] git was not touched.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print("[WARN]", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
"[WARN] Trellis manages these specific paths and they should be tracked:",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
if paths:
|
||||||
|
for p in paths:
|
||||||
|
print(f"[WARN] {p}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
"[WARN] .trellis/workspace/<developer>/{journal-*.md,index.md}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"[WARN] .trellis/tasks/<task-dir>/",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"[WARN] .trellis/tasks/archive/",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print("[WARN]", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
"[WARN] Recommended: change your .gitignore from `.trellis/` to specific",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"[WARN] subpaths that should remain ignored, e.g.:",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
for sub in TRELLIS_IGNORED_SUBPATHS:
|
||||||
|
print(f"[WARN] {sub}", file=sys.stderr)
|
||||||
|
print("[WARN]", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
"[WARN] Or, if you intentionally keep .trellis/ local-only, set in",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"[WARN] .trellis/config.yaml:",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"[WARN] session_auto_commit: false",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"[WARN] so the scripts skip git entirely and you can review / commit",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"[WARN] manually with `git status` / `git add` / `git commit`.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print("[WARN]", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
"[WARN] Do NOT use `git add -f .trellis/` — it pulls in backups, worktrees,",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"[WARN] and runtime caches that should never be committed.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
892
.trellis/scripts/common/session_context.py
Executable file
892
.trellis/scripts/common/session_context.py
Executable file
@@ -0,0 +1,892 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Session context generation (default + record modes).
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
get_context_json - JSON output for default mode
|
||||||
|
get_context_text - Text output for default mode
|
||||||
|
get_context_record_json - JSON for record mode
|
||||||
|
get_context_text_record - Text for record mode
|
||||||
|
output_json - Print JSON
|
||||||
|
output_text - Print text
|
||||||
|
get_update_hint - Once-per-session "update available" line
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .active_task import resolve_context_key
|
||||||
|
from .config import get_git_packages
|
||||||
|
from .git import run_git
|
||||||
|
from .packages_context import get_packages_section
|
||||||
|
from .tasks import iter_active_tasks, load_task, get_all_statuses, children_progress
|
||||||
|
from .paths import (
|
||||||
|
DIR_SCRIPTS,
|
||||||
|
DIR_SPEC,
|
||||||
|
DIR_TASKS,
|
||||||
|
DIR_WORKFLOW,
|
||||||
|
DIR_WORKSPACE,
|
||||||
|
count_lines,
|
||||||
|
get_active_journal_file,
|
||||||
|
get_current_task,
|
||||||
|
get_current_task_source,
|
||||||
|
get_developer,
|
||||||
|
get_repo_root,
|
||||||
|
get_tasks_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Helpers
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
_PACKAGE_NAME = "@mindfoldhq/trellis"
|
||||||
|
_UPDATE_CHECK_TIMEOUT_SECONDS = 1.0
|
||||||
|
_VERSION_RE = re.compile(
|
||||||
|
r"^\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?\s*$"
|
||||||
|
)
|
||||||
|
_VERSION_TOKEN_RE = re.compile(r"\b\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?\b")
|
||||||
|
_POLYREPO_IGNORED_DIRS = {
|
||||||
|
"node_modules",
|
||||||
|
"target",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
"out",
|
||||||
|
"bin",
|
||||||
|
"obj",
|
||||||
|
"vendor",
|
||||||
|
"coverage",
|
||||||
|
"tmp",
|
||||||
|
"__pycache__",
|
||||||
|
}
|
||||||
|
_POLYREPO_SCAN_MAX_DEPTH = 2
|
||||||
|
_POLYREPO_SCAN_MAX_REPOS = 8
|
||||||
|
_GIT_PROBE_TIMEOUT_SECONDS = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def _is_git_worktree(path: Path) -> bool:
|
||||||
|
"""Return True when path is inside a Git worktree."""
|
||||||
|
rc, out, _ = run_git(
|
||||||
|
["rev-parse", "--is-inside-work-tree"],
|
||||||
|
cwd=path,
|
||||||
|
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
return rc == 0 and out.strip().lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_recent_commits(log_output: str) -> list[dict]:
|
||||||
|
"""Parse `git log --oneline` output into structured commit entries."""
|
||||||
|
commits = []
|
||||||
|
for line in log_output.splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = line.split(" ", 1)
|
||||||
|
if len(parts) >= 2:
|
||||||
|
commits.append({"hash": parts[0], "message": parts[1]})
|
||||||
|
elif len(parts) == 1:
|
||||||
|
commits.append({"hash": parts[0], "message": ""})
|
||||||
|
return commits
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_git_repo_info(name: str, rel_path: str, repo_dir: Path) -> dict | None:
|
||||||
|
"""Collect Git status for one known repository directory."""
|
||||||
|
if not (repo_dir / ".git").exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
status_rc, status_out, _ = run_git(
|
||||||
|
["status", "--porcelain"],
|
||||||
|
cwd=repo_dir,
|
||||||
|
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
if status_rc != 0:
|
||||||
|
return None
|
||||||
|
changes = len([line for line in status_out.splitlines() if line.strip()])
|
||||||
|
|
||||||
|
_, branch_out, _ = run_git(
|
||||||
|
["branch", "--show-current"],
|
||||||
|
cwd=repo_dir,
|
||||||
|
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
branch = branch_out.strip() or "unknown"
|
||||||
|
|
||||||
|
_, log_out, _ = run_git(
|
||||||
|
["log", "--oneline", "-5"],
|
||||||
|
cwd=repo_dir,
|
||||||
|
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"path": rel_path,
|
||||||
|
"branch": branch,
|
||||||
|
"isClean": changes == 0,
|
||||||
|
"uncommittedChanges": changes,
|
||||||
|
"recentCommits": _parse_recent_commits(log_out),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_root_git_info(repo_root: Path) -> dict:
|
||||||
|
"""Collect root Git info without pretending a non-Git root is clean."""
|
||||||
|
if not _is_git_worktree(repo_root):
|
||||||
|
return {
|
||||||
|
"isRepo": False,
|
||||||
|
"branch": "",
|
||||||
|
"isClean": False,
|
||||||
|
"uncommittedChanges": 0,
|
||||||
|
"recentCommits": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
_, branch_out, _ = run_git(
|
||||||
|
["branch", "--show-current"],
|
||||||
|
cwd=repo_root,
|
||||||
|
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
branch = branch_out.strip() or "unknown"
|
||||||
|
|
||||||
|
status_rc, status_out, _ = run_git(
|
||||||
|
["status", "--porcelain"],
|
||||||
|
cwd=repo_root,
|
||||||
|
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
status_lines = [line for line in status_out.splitlines() if line.strip()]
|
||||||
|
|
||||||
|
_, short_out, _ = run_git(
|
||||||
|
["status", "--short"],
|
||||||
|
cwd=repo_root,
|
||||||
|
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
_, log_out, _ = run_git(
|
||||||
|
["log", "--oneline", "-5"],
|
||||||
|
cwd=repo_root,
|
||||||
|
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"isRepo": True,
|
||||||
|
"branch": branch,
|
||||||
|
"isClean": status_rc == 0 and len(status_lines) == 0,
|
||||||
|
"uncommittedChanges": len(status_lines),
|
||||||
|
"statusShort": short_out.splitlines(),
|
||||||
|
"recentCommits": _parse_recent_commits(log_out),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_child_git_repos(repo_root: Path) -> list[tuple[str, str]]:
|
||||||
|
"""Discover child Git repositories using the init-time polyrepo heuristic."""
|
||||||
|
found: list[str] = []
|
||||||
|
overflow = False
|
||||||
|
|
||||||
|
def is_candidate_dir(path: Path) -> bool:
|
||||||
|
name = path.name
|
||||||
|
return not name.startswith(".") and name not in _POLYREPO_IGNORED_DIRS
|
||||||
|
|
||||||
|
def scan(rel_dir: Path, depth: int) -> None:
|
||||||
|
nonlocal overflow
|
||||||
|
if overflow:
|
||||||
|
return
|
||||||
|
if depth >= _POLYREPO_SCAN_MAX_DEPTH:
|
||||||
|
return
|
||||||
|
abs_dir = repo_root / rel_dir
|
||||||
|
try:
|
||||||
|
children = sorted(abs_dir.iterdir(), key=lambda p: p.name)
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
for child in children:
|
||||||
|
if not child.is_dir() or not is_candidate_dir(child):
|
||||||
|
continue
|
||||||
|
|
||||||
|
child_rel = (
|
||||||
|
rel_dir / child.name if rel_dir != Path(".") else Path(child.name)
|
||||||
|
)
|
||||||
|
if (child / ".git").exists():
|
||||||
|
if len(found) >= _POLYREPO_SCAN_MAX_REPOS:
|
||||||
|
overflow = True
|
||||||
|
return
|
||||||
|
found.append(child_rel.as_posix())
|
||||||
|
continue
|
||||||
|
scan(child_rel, depth + 1)
|
||||||
|
|
||||||
|
scan(Path("."), 0)
|
||||||
|
if overflow:
|
||||||
|
print(
|
||||||
|
"warning: found more than "
|
||||||
|
f"{_POLYREPO_SCAN_MAX_REPOS} child Git repositories; "
|
||||||
|
"skipping automatic Git status collection. Configure explicit "
|
||||||
|
"packages entries with path and git: true in .trellis/config.yaml.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
if len(found) < 2:
|
||||||
|
return []
|
||||||
|
return [(path.replace("/", "_"), path) for path in sorted(found)]
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_package_git_info(
|
||||||
|
repo_root: Path,
|
||||||
|
discover_unconfigured: bool = False,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Collect Git status for independent package repositories.
|
||||||
|
|
||||||
|
Packages marked with ``git: true`` in config.yaml are authoritative.
|
||||||
|
When the Trellis root is not a Git repo and no configured package repos are
|
||||||
|
available, optionally fall back to the bounded polyrepo child scan.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with keys: name, path, branch, isClean,
|
||||||
|
uncommittedChanges, recentCommits.
|
||||||
|
Empty list if no git-repo packages are configured.
|
||||||
|
"""
|
||||||
|
git_pkgs = get_git_packages(repo_root)
|
||||||
|
result = []
|
||||||
|
for pkg_name, pkg_path in git_pkgs.items():
|
||||||
|
pkg_dir = repo_root / pkg_path
|
||||||
|
info = _collect_git_repo_info(pkg_name, pkg_path, pkg_dir)
|
||||||
|
if info is not None:
|
||||||
|
result.append(info)
|
||||||
|
|
||||||
|
if result or not discover_unconfigured:
|
||||||
|
return result
|
||||||
|
|
||||||
|
discovered = []
|
||||||
|
for pkg_name, pkg_path in _discover_child_git_repos(repo_root):
|
||||||
|
info = _collect_git_repo_info(pkg_name, pkg_path, repo_root / pkg_path)
|
||||||
|
if info is not None:
|
||||||
|
discovered.append(info)
|
||||||
|
return discovered
|
||||||
|
|
||||||
|
|
||||||
|
def _append_root_git_context(lines: list[str], root_git_info: dict) -> None:
|
||||||
|
"""Append root Git status without misleading non-Git roots."""
|
||||||
|
lines.append("## GIT STATUS")
|
||||||
|
if not root_git_info["isRepo"]:
|
||||||
|
lines.append("Root is not a Git repository.")
|
||||||
|
lines.append("Run Git commands from the package repository paths listed below.")
|
||||||
|
else:
|
||||||
|
lines.append(f"Branch: {root_git_info['branch']}")
|
||||||
|
if root_git_info["isClean"]:
|
||||||
|
lines.append("Working directory: Clean")
|
||||||
|
else:
|
||||||
|
lines.append(
|
||||||
|
f"Working directory: {root_git_info['uncommittedChanges']} "
|
||||||
|
"uncommitted change(s)"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Changes:")
|
||||||
|
for line in root_git_info.get("statusShort", [])[:10]:
|
||||||
|
lines.append(line)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("## RECENT COMMITS")
|
||||||
|
if not root_git_info["isRepo"]:
|
||||||
|
lines.append(
|
||||||
|
"Root has no Git commit history because it is not a Git repository."
|
||||||
|
)
|
||||||
|
elif root_git_info["recentCommits"]:
|
||||||
|
for commit in root_git_info["recentCommits"]:
|
||||||
|
lines.append(f"{commit['hash']} {commit['message']}")
|
||||||
|
else:
|
||||||
|
lines.append("(no commits)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
|
||||||
|
def _append_package_git_context(lines: list[str], package_git_info: list[dict]) -> None:
|
||||||
|
"""Append Git status and recent commits for package repositories."""
|
||||||
|
for pkg in package_git_info:
|
||||||
|
lines.append(f"## GIT STATUS ({pkg['name']}: {pkg['path']})")
|
||||||
|
lines.append(f"Branch: {pkg['branch']}")
|
||||||
|
if pkg["isClean"]:
|
||||||
|
lines.append("Working directory: Clean")
|
||||||
|
else:
|
||||||
|
lines.append(
|
||||||
|
f"Working directory: {pkg['uncommittedChanges']} uncommitted change(s)"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"## RECENT COMMITS ({pkg['name']}: {pkg['path']})")
|
||||||
|
if pkg["recentCommits"]:
|
||||||
|
for commit in pkg["recentCommits"]:
|
||||||
|
lines.append(f"{commit['hash']} {commit['message']}")
|
||||||
|
else:
|
||||||
|
lines.append("(no commits)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_project_version(repo_root: Path) -> str | None:
|
||||||
|
try:
|
||||||
|
version = (repo_root / DIR_WORKFLOW / ".version").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
).strip()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return version or None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_trellis_version_output() -> str | None:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["trellis", "--version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
timeout=_UPDATE_CHECK_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError, TimeoutError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
output = f"{result.stdout}\n{result.stderr}".strip()
|
||||||
|
return output or None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_available_update_version(output: str) -> str | None:
|
||||||
|
update_match = re.search(
|
||||||
|
r"Trellis update available:\s*"
|
||||||
|
r"(?P<current>\S+)\s*(?:→|->)\s*(?P<latest>\S+)",
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
if update_match:
|
||||||
|
return update_match.group("latest").strip()
|
||||||
|
candidates = _VERSION_TOKEN_RE.findall(output)
|
||||||
|
return candidates[-1] if candidates else None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_available_update_version() -> str | None:
|
||||||
|
output = _fetch_trellis_version_output()
|
||||||
|
if not output:
|
||||||
|
return None
|
||||||
|
return _extract_available_update_version(output)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_version(version: str) -> tuple[tuple[int, int, int], tuple[str, ...] | None] | None:
|
||||||
|
match = _VERSION_RE.match(version)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
major, minor, patch, prerelease = match.groups()
|
||||||
|
numbers = (int(major), int(minor or "0"), int(patch or "0"))
|
||||||
|
prerelease_parts = tuple(prerelease.split(".")) if prerelease else None
|
||||||
|
return numbers, prerelease_parts
|
||||||
|
|
||||||
|
|
||||||
|
def _compare_prerelease(
|
||||||
|
left: tuple[str, ...] | None,
|
||||||
|
right: tuple[str, ...] | None,
|
||||||
|
) -> int:
|
||||||
|
if left is None and right is None:
|
||||||
|
return 0
|
||||||
|
if left is None:
|
||||||
|
return 1
|
||||||
|
if right is None:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
for left_part, right_part in zip(left, right):
|
||||||
|
if left_part == right_part:
|
||||||
|
continue
|
||||||
|
left_numeric = left_part.isdigit()
|
||||||
|
right_numeric = right_part.isdigit()
|
||||||
|
if left_numeric and right_numeric:
|
||||||
|
left_int = int(left_part)
|
||||||
|
right_int = int(right_part)
|
||||||
|
return (left_int > right_int) - (left_int < right_int)
|
||||||
|
if left_numeric:
|
||||||
|
return -1
|
||||||
|
if right_numeric:
|
||||||
|
return 1
|
||||||
|
return (left_part > right_part) - (left_part < right_part)
|
||||||
|
|
||||||
|
return (len(left) > len(right)) - (len(left) < len(right))
|
||||||
|
|
||||||
|
|
||||||
|
def _compare_versions(left: str, right: str) -> int | None:
|
||||||
|
parsed_left = _parse_version(left)
|
||||||
|
parsed_right = _parse_version(right)
|
||||||
|
if parsed_left is None or parsed_right is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
left_numbers, left_prerelease = parsed_left
|
||||||
|
right_numbers, right_prerelease = parsed_right
|
||||||
|
if left_numbers != right_numbers:
|
||||||
|
return (left_numbers > right_numbers) - (left_numbers < right_numbers)
|
||||||
|
return _compare_prerelease(left_prerelease, right_prerelease)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_marker_path(repo_root: Path, context_key: str | None = None) -> Path:
|
||||||
|
"""Path of the once-per-session marker that throttles the update check.
|
||||||
|
|
||||||
|
`context_key` lets a caller that already resolved session identity pass it
|
||||||
|
in — the SessionStart hook reads the session id from hook stdin, which is
|
||||||
|
more reliable than this function's environment-only fallback chain. Shell
|
||||||
|
entry points leave it None and keep the previous behavior.
|
||||||
|
"""
|
||||||
|
if not context_key:
|
||||||
|
context_key = resolve_context_key()
|
||||||
|
if not context_key:
|
||||||
|
terminal_key = os.environ.get("TERM_SESSION_ID", "").strip()
|
||||||
|
context_key = terminal_key or f"ppid-{os.getppid()}"
|
||||||
|
safe_key = re.sub(r"[^A-Za-z0-9._-]+", "_", context_key).strip("._-")
|
||||||
|
if not safe_key:
|
||||||
|
safe_key = "session"
|
||||||
|
return (
|
||||||
|
repo_root
|
||||||
|
/ DIR_WORKFLOW
|
||||||
|
/ ".runtime"
|
||||||
|
/ f"update-check-{safe_key[:160]}.marker"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_update_check_attempted(
|
||||||
|
repo_root: Path,
|
||||||
|
context_key: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
marker_path = _update_marker_path(repo_root, context_key)
|
||||||
|
if marker_path.exists():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
marker_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
marker_path.write_text("checked\n", encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_update_hint(repo_root: Path, context_key: str | None = None) -> str | None:
|
||||||
|
"""Return the "update available" line for this session, at most once.
|
||||||
|
|
||||||
|
Public because the SessionStart hook imports it: the text-mode CLI path
|
||||||
|
(`get_context.py`) used to be the only caller, so hook-driven platforms —
|
||||||
|
Claude Code included — never saw the reminder at all.
|
||||||
|
"""
|
||||||
|
marker_path = _update_marker_path(repo_root, context_key)
|
||||||
|
if marker_path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
current_version = _read_project_version(repo_root)
|
||||||
|
if not current_version:
|
||||||
|
return None
|
||||||
|
|
||||||
|
latest_version = _resolve_available_update_version()
|
||||||
|
if not latest_version:
|
||||||
|
return None
|
||||||
|
|
||||||
|
_mark_update_check_attempted(repo_root, context_key)
|
||||||
|
comparison = _compare_versions(current_version, latest_version)
|
||||||
|
if comparison is None or comparison >= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"Trellis update available: {current_version} -> {latest_version}, "
|
||||||
|
"run trellis update"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# JSON Output
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_context_json(repo_root: Path | None = None) -> dict:
|
||||||
|
"""Get context as a dictionary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Context dictionary.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
developer = get_developer(repo_root)
|
||||||
|
tasks_dir = get_tasks_dir(repo_root)
|
||||||
|
journal_file = get_active_journal_file(repo_root)
|
||||||
|
|
||||||
|
journal_lines = 0
|
||||||
|
journal_relative = ""
|
||||||
|
if journal_file and developer:
|
||||||
|
journal_lines = count_lines(journal_file)
|
||||||
|
journal_relative = (
|
||||||
|
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
root_git_info = _collect_root_git_info(repo_root)
|
||||||
|
|
||||||
|
# Tasks
|
||||||
|
tasks = [
|
||||||
|
{
|
||||||
|
"dir": t.dir_name,
|
||||||
|
"name": t.name,
|
||||||
|
"status": t.status,
|
||||||
|
"children": list(t.children),
|
||||||
|
"parent": t.parent,
|
||||||
|
}
|
||||||
|
for t in iter_active_tasks(tasks_dir)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Package git repos (independent sub-repositories)
|
||||||
|
pkg_git_info = _collect_package_git_info(
|
||||||
|
repo_root,
|
||||||
|
discover_unconfigured=not root_git_info["isRepo"],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"developer": developer or "",
|
||||||
|
"git": {
|
||||||
|
"isRepo": root_git_info["isRepo"],
|
||||||
|
"branch": root_git_info["branch"],
|
||||||
|
"isClean": root_git_info["isClean"],
|
||||||
|
"uncommittedChanges": root_git_info["uncommittedChanges"],
|
||||||
|
"recentCommits": root_git_info["recentCommits"],
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"active": tasks,
|
||||||
|
"directory": f"{DIR_WORKFLOW}/{DIR_TASKS}",
|
||||||
|
},
|
||||||
|
"journal": {
|
||||||
|
"file": journal_relative,
|
||||||
|
"lines": journal_lines,
|
||||||
|
"nearLimit": journal_lines > 1800,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if pkg_git_info:
|
||||||
|
result["packageGit"] = pkg_git_info
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def output_json(repo_root: Path | None = None) -> None:
|
||||||
|
"""Output context in JSON format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
"""
|
||||||
|
context = get_context_json(repo_root)
|
||||||
|
print(json.dumps(context, indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Text Output
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_context_text(repo_root: Path | None = None) -> str:
|
||||||
|
"""Get context as formatted text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted text output.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
lines.append("========================================")
|
||||||
|
lines.append("SESSION CONTEXT")
|
||||||
|
lines.append("========================================")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
developer = get_developer(repo_root)
|
||||||
|
|
||||||
|
# Developer section
|
||||||
|
lines.append("## DEVELOPER")
|
||||||
|
if not developer:
|
||||||
|
lines.append(
|
||||||
|
f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
lines.append(f"Name: {developer}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
root_git_info = _collect_root_git_info(repo_root)
|
||||||
|
_append_root_git_context(lines, root_git_info)
|
||||||
|
|
||||||
|
# Package git repos — independent sub-repositories
|
||||||
|
_append_package_git_context(
|
||||||
|
lines,
|
||||||
|
_collect_package_git_info(
|
||||||
|
repo_root,
|
||||||
|
discover_unconfigured=not root_git_info["isRepo"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Current task
|
||||||
|
lines.append("## CURRENT TASK")
|
||||||
|
current_task = get_current_task(repo_root)
|
||||||
|
if current_task:
|
||||||
|
current_task_dir = repo_root / current_task
|
||||||
|
source_type, context_key, _ = get_current_task_source(repo_root)
|
||||||
|
lines.append(f"Path: {current_task}")
|
||||||
|
lines.append(
|
||||||
|
f"Source: {source_type}" + (f":{context_key}" if context_key else "")
|
||||||
|
)
|
||||||
|
|
||||||
|
ct = load_task(current_task_dir)
|
||||||
|
if ct:
|
||||||
|
lines.append(f"Name: {ct.name}")
|
||||||
|
lines.append(f"Status: {ct.status}")
|
||||||
|
lines.append(f"Created: {ct.raw.get('createdAt', 'unknown')}")
|
||||||
|
if ct.description:
|
||||||
|
lines.append(f"Description: {ct.description}")
|
||||||
|
|
||||||
|
# Check for prd.md
|
||||||
|
prd_file = current_task_dir / "prd.md"
|
||||||
|
if prd_file.is_file():
|
||||||
|
lines.append("")
|
||||||
|
lines.append("[!] This task has prd.md - read it for task details")
|
||||||
|
else:
|
||||||
|
lines.append("(none)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Active tasks
|
||||||
|
lines.append("## ACTIVE TASKS")
|
||||||
|
tasks_dir = get_tasks_dir(repo_root)
|
||||||
|
task_count = 0
|
||||||
|
|
||||||
|
# Collect all task data for hierarchy display
|
||||||
|
all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)}
|
||||||
|
all_statuses = {name: t.status for name, t in all_tasks.items()}
|
||||||
|
|
||||||
|
def _print_task_tree(name: str, indent: int = 0) -> None:
|
||||||
|
nonlocal task_count
|
||||||
|
t = all_tasks[name]
|
||||||
|
progress = children_progress(t.children, all_statuses)
|
||||||
|
prefix = " " * indent
|
||||||
|
lines.append(f"{prefix}- {name}/ ({t.status}){progress} @{t.assignee or '-'}")
|
||||||
|
task_count += 1
|
||||||
|
for child in t.children:
|
||||||
|
if child in all_tasks:
|
||||||
|
_print_task_tree(child, indent + 1)
|
||||||
|
|
||||||
|
for dir_name in sorted(all_tasks.keys()):
|
||||||
|
if not all_tasks[dir_name].parent:
|
||||||
|
_print_task_tree(dir_name)
|
||||||
|
|
||||||
|
if task_count == 0:
|
||||||
|
lines.append("(no active tasks)")
|
||||||
|
lines.append(f"Total: {task_count} active task(s)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# My tasks
|
||||||
|
lines.append("## MY TASKS (Assigned to me)")
|
||||||
|
my_task_count = 0
|
||||||
|
|
||||||
|
for t in all_tasks.values():
|
||||||
|
if t.assignee == developer and t.status != "done":
|
||||||
|
progress = children_progress(t.children, all_statuses)
|
||||||
|
lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress}")
|
||||||
|
my_task_count += 1
|
||||||
|
|
||||||
|
if my_task_count == 0:
|
||||||
|
lines.append("(no tasks assigned to you)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Journal file
|
||||||
|
lines.append("## JOURNAL FILE")
|
||||||
|
journal_file = get_active_journal_file(repo_root)
|
||||||
|
if journal_file:
|
||||||
|
journal_lines = count_lines(journal_file)
|
||||||
|
relative = f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}"
|
||||||
|
lines.append(f"Active file: {relative}")
|
||||||
|
lines.append(f"Line count: {journal_lines} / 2000")
|
||||||
|
if journal_lines > 1800:
|
||||||
|
lines.append("[!] WARNING: Approaching 2000 line limit!")
|
||||||
|
else:
|
||||||
|
lines.append("No journal file found")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Packages
|
||||||
|
packages_text = get_packages_section(repo_root)
|
||||||
|
if packages_text:
|
||||||
|
lines.append(packages_text)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Paths
|
||||||
|
lines.append("## PATHS")
|
||||||
|
lines.append(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/")
|
||||||
|
lines.append(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/")
|
||||||
|
lines.append(f"Spec: {DIR_WORKFLOW}/{DIR_SPEC}/")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("========================================")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Record Mode
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_context_record_json(repo_root: Path | None = None) -> dict:
|
||||||
|
"""Get record-mode context as a dictionary.
|
||||||
|
|
||||||
|
Focused on: my active tasks, git status, current task.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
developer = get_developer(repo_root)
|
||||||
|
tasks_dir = get_tasks_dir(repo_root)
|
||||||
|
|
||||||
|
root_git_info = _collect_root_git_info(repo_root)
|
||||||
|
|
||||||
|
# My tasks (single pass — collect statuses and filter by assignee)
|
||||||
|
all_tasks_list = list(iter_active_tasks(tasks_dir))
|
||||||
|
all_statuses = {t.dir_name: t.status for t in all_tasks_list}
|
||||||
|
|
||||||
|
my_tasks = []
|
||||||
|
for t in all_tasks_list:
|
||||||
|
if t.assignee == developer:
|
||||||
|
done = sum(
|
||||||
|
1 for c in t.children
|
||||||
|
if all_statuses.get(c) in ("completed", "done")
|
||||||
|
)
|
||||||
|
my_tasks.append({
|
||||||
|
"dir": t.dir_name,
|
||||||
|
"title": t.title,
|
||||||
|
"status": t.status,
|
||||||
|
"priority": t.priority,
|
||||||
|
"children": list(t.children),
|
||||||
|
"childrenDone": done,
|
||||||
|
"parent": t.parent,
|
||||||
|
"meta": t.meta,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Current task
|
||||||
|
current_task_info = None
|
||||||
|
current_task = get_current_task(repo_root)
|
||||||
|
if current_task:
|
||||||
|
source_type, context_key, _ = get_current_task_source(repo_root)
|
||||||
|
ct = load_task(repo_root / current_task)
|
||||||
|
if ct:
|
||||||
|
current_task_info = {
|
||||||
|
"path": current_task,
|
||||||
|
"name": ct.name,
|
||||||
|
"status": ct.status,
|
||||||
|
"source": source_type,
|
||||||
|
"contextKey": context_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Package git repos
|
||||||
|
pkg_git_info = _collect_package_git_info(
|
||||||
|
repo_root,
|
||||||
|
discover_unconfigured=not root_git_info["isRepo"],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"developer": developer or "",
|
||||||
|
"git": {
|
||||||
|
"isRepo": root_git_info["isRepo"],
|
||||||
|
"branch": root_git_info["branch"],
|
||||||
|
"isClean": root_git_info["isClean"],
|
||||||
|
"uncommittedChanges": root_git_info["uncommittedChanges"],
|
||||||
|
"recentCommits": root_git_info["recentCommits"],
|
||||||
|
},
|
||||||
|
"myTasks": my_tasks,
|
||||||
|
"currentTask": current_task_info,
|
||||||
|
}
|
||||||
|
|
||||||
|
if pkg_git_info:
|
||||||
|
result["packageGit"] = pkg_git_info
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_context_text_record(repo_root: Path | None = None) -> str:
|
||||||
|
"""Get context as formatted text for record-session mode.
|
||||||
|
|
||||||
|
Focused output: MY ACTIVE TASKS first (with [!!!] emphasis),
|
||||||
|
then GIT STATUS, RECENT COMMITS, CURRENT TASK.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append("========================================")
|
||||||
|
lines.append("SESSION CONTEXT (RECORD MODE)")
|
||||||
|
lines.append("========================================")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
developer = get_developer(repo_root)
|
||||||
|
if not developer:
|
||||||
|
lines.append(
|
||||||
|
f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
# MY ACTIVE TASKS — first and prominent
|
||||||
|
lines.append(f"## [!!!] MY ACTIVE TASKS (Assigned to {developer})")
|
||||||
|
lines.append("[!] Review whether any should be archived before recording this session.")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
tasks_dir = get_tasks_dir(repo_root)
|
||||||
|
my_task_count = 0
|
||||||
|
|
||||||
|
# Single pass — collect all tasks and filter by assignee
|
||||||
|
all_statuses = get_all_statuses(tasks_dir)
|
||||||
|
|
||||||
|
for t in iter_active_tasks(tasks_dir):
|
||||||
|
if t.assignee == developer:
|
||||||
|
progress = children_progress(t.children, all_statuses)
|
||||||
|
lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress} — {t.dir_name}")
|
||||||
|
my_task_count += 1
|
||||||
|
|
||||||
|
if my_task_count == 0:
|
||||||
|
lines.append("(no active tasks assigned to you)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
root_git_info = _collect_root_git_info(repo_root)
|
||||||
|
_append_root_git_context(lines, root_git_info)
|
||||||
|
|
||||||
|
# Package git repos — independent sub-repositories
|
||||||
|
_append_package_git_context(
|
||||||
|
lines,
|
||||||
|
_collect_package_git_info(
|
||||||
|
repo_root,
|
||||||
|
discover_unconfigured=not root_git_info["isRepo"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# CURRENT TASK
|
||||||
|
lines.append("## CURRENT TASK")
|
||||||
|
current_task = get_current_task(repo_root)
|
||||||
|
if current_task:
|
||||||
|
source_type, context_key, _ = get_current_task_source(repo_root)
|
||||||
|
lines.append(f"Path: {current_task}")
|
||||||
|
lines.append(
|
||||||
|
f"Source: {source_type}" + (f":{context_key}" if context_key else "")
|
||||||
|
)
|
||||||
|
ct = load_task(repo_root / current_task)
|
||||||
|
if ct:
|
||||||
|
lines.append(f"Name: {ct.name}")
|
||||||
|
lines.append(f"Status: {ct.status}")
|
||||||
|
else:
|
||||||
|
lines.append("(none)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("========================================")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def output_text(repo_root: Path | None = None) -> None:
|
||||||
|
"""Output context in text format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
update_hint = get_update_hint(repo_root)
|
||||||
|
if update_hint:
|
||||||
|
print(update_hint)
|
||||||
|
print("")
|
||||||
|
print(get_context_text(repo_root))
|
||||||
372
.trellis/scripts/common/task_context.py
Executable file
372
.trellis/scripts/common/task_context.py
Executable file
@@ -0,0 +1,372 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Task JSONL context management.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
cmd_add_context - Add entry to JSONL context file
|
||||||
|
cmd_validate - Validate JSONL context files
|
||||||
|
cmd_list_context - List JSONL context entries
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .config import get_context_injection_limits
|
||||||
|
from .git import branch_exists_locally
|
||||||
|
from .io import read_json
|
||||||
|
from .log import Colors, colored
|
||||||
|
from .paths import DIR_ARCHIVE, DIR_TASKS, DIR_WORKFLOW, FILE_TASK_JSON, get_repo_root
|
||||||
|
from .task_utils import resolve_task_dir
|
||||||
|
|
||||||
|
# Extensions that look like code rather than spec/research docs. Entries with
|
||||||
|
# one of these extensions outside .trellis/spec/, docs/docs-site, or the
|
||||||
|
# task's own directory get a hygiene warning in `task.py validate` — the
|
||||||
|
# reader is a sub-agent, not a human, so code paths belong in the diff the
|
||||||
|
# agent reads itself, not in implement.jsonl / check.jsonl.
|
||||||
|
_CODE_FILE_EXTENSIONS = {
|
||||||
|
".ts",
|
||||||
|
".tsx",
|
||||||
|
".js",
|
||||||
|
".jsx",
|
||||||
|
".mjs",
|
||||||
|
".cjs",
|
||||||
|
".py",
|
||||||
|
".go",
|
||||||
|
".rs",
|
||||||
|
".java",
|
||||||
|
".rb",
|
||||||
|
".c",
|
||||||
|
".cc",
|
||||||
|
".cpp",
|
||||||
|
".h",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Command: add-context
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
jsonl_name = args.file
|
||||||
|
path = args.path
|
||||||
|
reason = args.reason or "Added manually"
|
||||||
|
|
||||||
|
if not target_dir or not target_dir.is_dir():
|
||||||
|
print(colored(f"Error: Directory not found: {target_dir}", Colors.RED))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Support shorthand
|
||||||
|
if not jsonl_name.endswith(".jsonl"):
|
||||||
|
jsonl_name = f"{jsonl_name}.jsonl"
|
||||||
|
|
||||||
|
jsonl_file = target_dir / jsonl_name
|
||||||
|
full_path = repo_root / path
|
||||||
|
|
||||||
|
entry_type = "file"
|
||||||
|
if full_path.is_dir():
|
||||||
|
entry_type = "directory"
|
||||||
|
if not path.endswith("/"):
|
||||||
|
path = f"{path}/"
|
||||||
|
elif not full_path.is_file():
|
||||||
|
print(colored(f"Error: Path not found: {path}", Colors.RED))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Check if already exists
|
||||||
|
if jsonl_file.is_file():
|
||||||
|
content = jsonl_file.read_text(encoding="utf-8")
|
||||||
|
if f'"{path}"' in content:
|
||||||
|
print(colored(f"Warning: Entry already exists for {path}", Colors.YELLOW))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Add entry
|
||||||
|
entry: dict
|
||||||
|
if entry_type == "directory":
|
||||||
|
entry = {"file": path, "type": "directory", "reason": reason}
|
||||||
|
else:
|
||||||
|
entry = {"file": path, "reason": reason}
|
||||||
|
|
||||||
|
with jsonl_file.open("a", encoding="utf-8") as f:
|
||||||
|
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||||
|
|
||||||
|
print(colored(f"Added {entry_type}: {path}", Colors.GREEN))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Command: validate
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
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():
|
||||||
|
print(colored("Error: task directory required", Colors.RED))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(colored("=== Validating Context Files ===", Colors.BLUE))
|
||||||
|
print(f"Target dir: {target_dir}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Warn (don't fail validation) when the recorded branch is stale — it
|
||||||
|
# was likely already merged and deleted (#399 item 2).
|
||||||
|
task_json_path = target_dir / FILE_TASK_JSON
|
||||||
|
if task_json_path.is_file():
|
||||||
|
task_data = read_json(task_json_path)
|
||||||
|
stored_branch = task_data.get("branch") if task_data else None
|
||||||
|
if stored_branch and not branch_exists_locally(stored_branch, repo_root):
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
f"Warning: recorded branch '{stored_branch}' no longer exists locally "
|
||||||
|
"(likely merged and deleted).",
|
||||||
|
Colors.YELLOW,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print()
|
||||||
|
|
||||||
|
total_errors = 0
|
||||||
|
for jsonl_name in ["implement.jsonl", "check.jsonl"]:
|
||||||
|
jsonl_file = target_dir / jsonl_name
|
||||||
|
errors = _validate_jsonl(jsonl_file, repo_root, target_dir)
|
||||||
|
total_errors += errors
|
||||||
|
|
||||||
|
print()
|
||||||
|
if total_errors == 0:
|
||||||
|
print(colored("✓ All validations passed", Colors.GREEN))
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
print(colored(f"✗ Validation failed ({total_errors} errors)", Colors.RED))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _is_exempt_from_code_file_warning(file_path: str, task_rel: str) -> bool:
|
||||||
|
"""Whether a jsonl entry path is exempt from the code-file hygiene warning.
|
||||||
|
|
||||||
|
Exempt: spec docs (``.trellis/spec/``), documentation (``docs``,
|
||||||
|
``docs-site``), and the task's own directory (execution plans, generated
|
||||||
|
artifacts, etc. legitimately live there).
|
||||||
|
"""
|
||||||
|
posix_path = file_path.replace("\\", "/").lstrip("/")
|
||||||
|
exempt_prefixes = (".trellis/spec/", "docs/", "docs-site/")
|
||||||
|
if posix_path.startswith(exempt_prefixes):
|
||||||
|
return True
|
||||||
|
if task_rel and (posix_path == task_rel or posix_path.startswith(f"{task_rel}/")):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_context_entry_path(
|
||||||
|
file_path: str, repo_root: Path, task_dir: Path | None
|
||||||
|
) -> Path | None:
|
||||||
|
"""Resolve a JSONL entry, binding archived self-references to the archive copy.
|
||||||
|
|
||||||
|
Exact historical self-references are remapped only for archived tasks.
|
||||||
|
``None`` means the remapped path traversed or resolved outside that archive.
|
||||||
|
"""
|
||||||
|
repo_path = repo_root / file_path
|
||||||
|
if task_dir is None:
|
||||||
|
return repo_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
task_parts = task_dir.resolve().relative_to(repo_root.resolve()).parts
|
||||||
|
except ValueError:
|
||||||
|
return repo_path
|
||||||
|
|
||||||
|
archive_prefix = (DIR_WORKFLOW, DIR_TASKS, DIR_ARCHIVE)
|
||||||
|
if len(task_parts) != 5 or task_parts[:3] != archive_prefix:
|
||||||
|
return repo_path
|
||||||
|
|
||||||
|
year_month = task_parts[3]
|
||||||
|
if (
|
||||||
|
len(year_month) != 7
|
||||||
|
or year_month[4] != "-"
|
||||||
|
or not year_month[:4].isdigit()
|
||||||
|
or not year_month[5:].isdigit()
|
||||||
|
):
|
||||||
|
return repo_path
|
||||||
|
|
||||||
|
historical_root = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_dir.name}"
|
||||||
|
posix_path = file_path.replace("\\", "/")
|
||||||
|
if posix_path == historical_root:
|
||||||
|
relative_parts: tuple[str, ...] = ()
|
||||||
|
elif posix_path.startswith(f"{historical_root}/"):
|
||||||
|
relative_path = posix_path[len(historical_root) + 1 :]
|
||||||
|
if relative_path.endswith("/"):
|
||||||
|
relative_path = relative_path[:-1]
|
||||||
|
relative_parts = tuple(relative_path.split("/")) if relative_path else ()
|
||||||
|
if any(part in ("", ".", "..") for part in relative_parts):
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return repo_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
archive_root = task_dir.resolve()
|
||||||
|
resolved_path = task_dir.joinpath(*relative_parts).resolve()
|
||||||
|
resolved_path.relative_to(archive_root)
|
||||||
|
except (OSError, RuntimeError, ValueError):
|
||||||
|
return None
|
||||||
|
return resolved_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.
|
||||||
|
|
||||||
|
Beyond hard errors (missing file/dir, invalid JSON), this also prints
|
||||||
|
non-blocking hygiene warnings (never counted in ``errors``, never change
|
||||||
|
the exit code): entries that look like code files rather than
|
||||||
|
spec/research docs, and entries whose file size exceeds the configured
|
||||||
|
sub-agent context injection cap (``context_injection.max_file_bytes``).
|
||||||
|
"""
|
||||||
|
file_name = jsonl_file.name
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
if not jsonl_file.is_file():
|
||||||
|
print(f" {colored(f'{file_name}: not found (skipped)', Colors.YELLOW)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
task_rel = ""
|
||||||
|
if task_dir is not None:
|
||||||
|
try:
|
||||||
|
task_rel = task_dir.resolve().relative_to(repo_root.resolve()).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
task_rel = ""
|
||||||
|
|
||||||
|
max_file_bytes = get_context_injection_limits(repo_root).get("max_file_bytes", 0)
|
||||||
|
|
||||||
|
line_num = 0
|
||||||
|
real_entries = 0
|
||||||
|
for line in jsonl_file.read_text(encoding="utf-8").splitlines():
|
||||||
|
line_num += 1
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print(f" {colored(f'{file_name}:{line_num}: Invalid JSON', 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
|
||||||
|
continue
|
||||||
|
|
||||||
|
real_entries += 1
|
||||||
|
full_path = _resolve_context_entry_path(file_path, repo_root, task_dir)
|
||||||
|
if entry_type == "directory":
|
||||||
|
if full_path is None or not full_path.is_dir():
|
||||||
|
print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}")
|
||||||
|
errors += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if full_path is None or not full_path.is_file():
|
||||||
|
print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}")
|
||||||
|
errors += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
extension = Path(file_path).suffix.lower()
|
||||||
|
if extension in _CODE_FILE_EXTENSIONS and not _is_exempt_from_code_file_warning(
|
||||||
|
file_path, task_rel
|
||||||
|
):
|
||||||
|
warning_message = (
|
||||||
|
f"{file_name}:{line_num}: Warning: {file_path} looks like a code file — "
|
||||||
|
"implement/check.jsonl should reference spec/research docs; "
|
||||||
|
"agents read code themselves"
|
||||||
|
)
|
||||||
|
print(f" {colored(warning_message, Colors.YELLOW)}")
|
||||||
|
|
||||||
|
if max_file_bytes:
|
||||||
|
size = full_path.stat().st_size
|
||||||
|
if 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}); "
|
||||||
|
"injection will truncate it"
|
||||||
|
)
|
||||||
|
print(f" {colored(warning_message, Colors.YELLOW)}")
|
||||||
|
|
||||||
|
if errors == 0:
|
||||||
|
print(f" {colored(f'{file_name}: ✓ ({real_entries} entries)', Colors.GREEN)}")
|
||||||
|
else:
|
||||||
|
print(f" {colored(f'{file_name}: ✗ ({errors} errors)', Colors.RED)}")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Command: list-context
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def cmd_list_context(args: argparse.Namespace) -> int:
|
||||||
|
"""List JSONL context entries."""
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||||
|
|
||||||
|
if not target_dir or not target_dir.is_dir():
|
||||||
|
print(colored("Error: task directory required", Colors.RED))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(colored("=== Context Files ===", Colors.BLUE))
|
||||||
|
print()
|
||||||
|
|
||||||
|
for jsonl_name in ["implement.jsonl", "check.jsonl"]:
|
||||||
|
jsonl_file = target_dir / jsonl_name
|
||||||
|
if not jsonl_file.is_file():
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(colored(f"[{jsonl_name}]", Colors.CYAN))
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
seed_only = True
|
||||||
|
for line in jsonl_file.read_text(encoding="utf-8").splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_path = data.get("file")
|
||||||
|
if not file_path:
|
||||||
|
# Seed / comment row — don't count as a real entry
|
||||||
|
continue
|
||||||
|
seed_only = False
|
||||||
|
|
||||||
|
count += 1
|
||||||
|
entry_type = data.get("type", "file")
|
||||||
|
reason = data.get("reason", "-")
|
||||||
|
|
||||||
|
if entry_type == "directory":
|
||||||
|
print(f" {colored(f'{count}.', Colors.GREEN)} [DIR] {file_path}")
|
||||||
|
else:
|
||||||
|
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)}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
return 0
|
||||||
188
.trellis/scripts/common/task_queue.py
Executable file
188
.trellis/scripts/common/task_queue.py
Executable file
@@ -0,0 +1,188 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Task queue utility functions.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
list_tasks_by_status - List tasks by status
|
||||||
|
list_pending_tasks - List tasks with pending status
|
||||||
|
list_tasks_by_assignee - List tasks by assignee
|
||||||
|
list_my_tasks - List tasks assigned to current developer
|
||||||
|
get_task_stats - Get P0/P1/P2/P3 counts
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .paths import (
|
||||||
|
get_repo_root,
|
||||||
|
get_developer,
|
||||||
|
get_tasks_dir,
|
||||||
|
)
|
||||||
|
from .tasks import iter_active_tasks
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Internal helper
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _task_to_dict(t) -> dict:
|
||||||
|
"""Convert TaskInfo to the dict format callers expect."""
|
||||||
|
return {
|
||||||
|
"priority": t.priority,
|
||||||
|
"id": t.raw.get("id", ""),
|
||||||
|
"title": t.title,
|
||||||
|
"status": t.status,
|
||||||
|
"assignee": t.assignee or "-",
|
||||||
|
"dir": t.dir_name,
|
||||||
|
"children": list(t.children),
|
||||||
|
"parent": t.parent,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Public Functions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def list_tasks_by_status(
|
||||||
|
filter_status: str | None = None,
|
||||||
|
repo_root: Path | None = None
|
||||||
|
) -> list[dict]:
|
||||||
|
"""List tasks by status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filter_status: Optional status filter.
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of task info dicts with keys: priority, id, title, status, assignee.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
tasks_dir = get_tasks_dir(repo_root)
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for t in iter_active_tasks(tasks_dir):
|
||||||
|
if filter_status and t.status != filter_status:
|
||||||
|
continue
|
||||||
|
results.append(_task_to_dict(t))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def list_pending_tasks(repo_root: Path | None = None) -> list[dict]:
|
||||||
|
"""List pending tasks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of task info dicts.
|
||||||
|
"""
|
||||||
|
return list_tasks_by_status("planning", repo_root)
|
||||||
|
|
||||||
|
|
||||||
|
def list_tasks_by_assignee(
|
||||||
|
assignee: str,
|
||||||
|
filter_status: str | None = None,
|
||||||
|
repo_root: Path | None = None
|
||||||
|
) -> list[dict]:
|
||||||
|
"""List tasks assigned to a specific developer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
assignee: Developer name.
|
||||||
|
filter_status: Optional status filter.
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of task info dicts.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
tasks_dir = get_tasks_dir(repo_root)
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for t in iter_active_tasks(tasks_dir):
|
||||||
|
if (t.assignee or "-") != assignee:
|
||||||
|
continue
|
||||||
|
if filter_status and t.status != filter_status:
|
||||||
|
continue
|
||||||
|
results.append(_task_to_dict(t))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def list_my_tasks(
|
||||||
|
filter_status: str | None = None,
|
||||||
|
repo_root: Path | None = None
|
||||||
|
) -> list[dict]:
|
||||||
|
"""List tasks assigned to current developer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filter_status: Optional status filter.
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of task info dicts.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If developer not set.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
developer = get_developer(repo_root)
|
||||||
|
if not developer:
|
||||||
|
raise ValueError("Developer not set")
|
||||||
|
|
||||||
|
return list_tasks_by_assignee(developer, filter_status, repo_root)
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_stats(repo_root: Path | None = None) -> dict[str, int]:
|
||||||
|
"""Get task statistics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with keys: P0, P1, P2, P3, Total.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
tasks_dir = get_tasks_dir(repo_root)
|
||||||
|
stats = {"P0": 0, "P1": 0, "P2": 0, "P3": 0, "Total": 0}
|
||||||
|
|
||||||
|
for t in iter_active_tasks(tasks_dir):
|
||||||
|
if t.priority in stats:
|
||||||
|
stats[t.priority] += 1
|
||||||
|
stats["Total"] += 1
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def format_task_stats(stats: dict[str, int]) -> str:
|
||||||
|
"""Format task stats as string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stats: Stats dict from get_task_stats.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string like "P0:0 P1:1 P2:2 P3:0 Total:3".
|
||||||
|
"""
|
||||||
|
return f"P0:{stats['P0']} P1:{stats['P1']} P2:{stats['P2']} P3:{stats['P3']} Total:{stats['Total']}"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Main Entry (for testing)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
stats = get_task_stats()
|
||||||
|
print(format_task_stats(stats))
|
||||||
|
print()
|
||||||
|
print("Pending tasks:")
|
||||||
|
for task in list_pending_tasks():
|
||||||
|
print(f" {task['priority']}|{task['id']}|{task['title']}|{task['status']}|{task['assignee']}")
|
||||||
985
.trellis/scripts/common/task_store.py
Executable file
985
.trellis/scripts/common/task_store.py
Executable file
@@ -0,0 +1,985 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Task CRUD operations.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
ensure_tasks_dir - Ensure tasks directory exists
|
||||||
|
cmd_create - Create a new task
|
||||||
|
cmd_archive - Archive completed task
|
||||||
|
cmd_set_branch - Set git branch for task
|
||||||
|
cmd_set_base_branch - Set PR target branch
|
||||||
|
cmd_set_scope - Set scope for PR title
|
||||||
|
cmd_set_meta - Set/overwrite a task metadata key
|
||||||
|
cmd_add_subtask - Link child task to parent
|
||||||
|
cmd_remove_subtask - Unlink child task from parent
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .config import (
|
||||||
|
get_codex_dispatch_mode,
|
||||||
|
get_packages,
|
||||||
|
get_session_auto_commit,
|
||||||
|
is_monorepo,
|
||||||
|
resolve_package,
|
||||||
|
validate_package,
|
||||||
|
)
|
||||||
|
from .git import branch_exists_locally, resolve_default_branch, run_git
|
||||||
|
from .io import read_json, write_json
|
||||||
|
from .log import Colors, colored
|
||||||
|
from .paths import (
|
||||||
|
DIR_ARCHIVE,
|
||||||
|
DIR_TASKS,
|
||||||
|
DIR_WORKFLOW,
|
||||||
|
FILE_TASK_JSON,
|
||||||
|
generate_task_date_prefix,
|
||||||
|
get_developer,
|
||||||
|
get_repo_root,
|
||||||
|
get_tasks_dir,
|
||||||
|
)
|
||||||
|
from .safe_commit import (
|
||||||
|
print_gitignore_warning,
|
||||||
|
safe_archive_paths_to_add,
|
||||||
|
safe_git_add,
|
||||||
|
)
|
||||||
|
from .task_utils import (
|
||||||
|
archive_task_complete,
|
||||||
|
find_task_by_name,
|
||||||
|
is_within_tasks_dir,
|
||||||
|
resolve_task_dir,
|
||||||
|
run_task_hooks,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Helper Functions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _slugify(title: str) -> str:
|
||||||
|
"""Convert title to slug (only works with ASCII)."""
|
||||||
|
result = title.lower()
|
||||||
|
result = re.sub(r"[^a-z0-9]", "-", result)
|
||||||
|
result = re.sub(r"-+", "-", result)
|
||||||
|
result = result.strip("-")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_tasks_dir(repo_root: Path) -> Path:
|
||||||
|
"""Ensure tasks directory exists."""
|
||||||
|
tasks_dir = get_tasks_dir(repo_root)
|
||||||
|
archive_dir = tasks_dir / "archive"
|
||||||
|
|
||||||
|
if not tasks_dir.exists():
|
||||||
|
tasks_dir.mkdir(parents=True)
|
||||||
|
print(colored(f"Created tasks directory: {tasks_dir}", Colors.GREEN), file=sys.stderr)
|
||||||
|
|
||||||
|
if not archive_dir.exists():
|
||||||
|
archive_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
return tasks_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _find_archived_task_by_dir_name(tasks_dir: Path, dir_name: str) -> Path | None:
|
||||||
|
"""Find an archived task directory with the exact active-task dir name."""
|
||||||
|
archive_dir = tasks_dir / DIR_ARCHIVE
|
||||||
|
if not archive_dir.is_dir():
|
||||||
|
return None
|
||||||
|
|
||||||
|
for month_dir in sorted(archive_dir.iterdir()):
|
||||||
|
if not month_dir.is_dir():
|
||||||
|
continue
|
||||||
|
candidate = month_dir / dir_name
|
||||||
|
if candidate.is_dir():
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_relative_path(path: Path, repo_root: Path) -> str:
|
||||||
|
"""Format a path relative to the repo root when possible."""
|
||||||
|
try:
|
||||||
|
return path.relative_to(repo_root).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Sub-agent platform detection + JSONL seeding
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Config directories of platforms that consume implement.jsonl / check.jsonl.
|
||||||
|
# Keep in sync with src/types/ai-tools.ts AI_TOOLS entries — these are the
|
||||||
|
# platforms listed in workflow.md's "agent-capable" Skill Routing block.
|
||||||
|
# Codex is checked separately because explicit inline mode does not consume
|
||||||
|
# JSONL. Kilo / Antigravity / Devin are NOT in this list either: they load
|
||||||
|
# specs through skills instead of JSONL.
|
||||||
|
_SUBAGENT_CONFIG_DIRS: tuple[str, ...] = (
|
||||||
|
".claude",
|
||||||
|
".cursor",
|
||||||
|
".kiro",
|
||||||
|
".gemini",
|
||||||
|
".opencode",
|
||||||
|
".qoder",
|
||||||
|
".codebuddy",
|
||||||
|
".factory", # Factory Droid
|
||||||
|
".github/copilot",
|
||||||
|
".pi", # Pi Agent
|
||||||
|
".trae", # Trae IDE
|
||||||
|
".omp", # Oh My Pi
|
||||||
|
".zcode", # ZCode
|
||||||
|
".grok", # Grok Build
|
||||||
|
".kimi-code", # Kimi Code
|
||||||
|
)
|
||||||
|
_CODEX_CONFIG_DIR = ".codex"
|
||||||
|
|
||||||
|
_SEED_EXAMPLE = (
|
||||||
|
"Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. "
|
||||||
|
"Put spec/research files only — no code paths. "
|
||||||
|
"Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. "
|
||||||
|
"Delete this line once real entries are added."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_subagent_platform(repo_root: Path) -> bool:
|
||||||
|
"""Return True if any sub-agent-capable platform is configured.
|
||||||
|
|
||||||
|
Detected by probing well-known config directories at the repo root. Codex
|
||||||
|
counts by default through ``codex.dispatch_mode: auto`` (including the
|
||||||
|
legacy ``sub-agent`` alias); explicit inline mode loads context through
|
||||||
|
skills, not JSONL.
|
||||||
|
"""
|
||||||
|
for config_dir in _SUBAGENT_CONFIG_DIRS:
|
||||||
|
if (repo_root / config_dir).is_dir():
|
||||||
|
return True
|
||||||
|
if (repo_root / _CODEX_CONFIG_DIR).is_dir():
|
||||||
|
return get_codex_dispatch_mode(repo_root) == "auto"
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _write_seed_jsonl(path: Path) -> None:
|
||||||
|
"""Write a one-line seed JSONL file with a self-describing ``_example``.
|
||||||
|
|
||||||
|
The seed row has no ``file`` field, so downstream consumers (hooks +
|
||||||
|
preludes) that iterate entries via ``item.get("file")`` naturally skip
|
||||||
|
it. The row exists purely as an in-file prompt for the AI curator.
|
||||||
|
"""
|
||||||
|
seed = {"_example": _SEED_EXAMPLE}
|
||||||
|
path.write_text(json.dumps(seed, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_meta_pairs(pairs: list[str] | None) -> dict[str, str] | None:
|
||||||
|
"""Parse repeatable ``--meta key=value`` pairs into a dict.
|
||||||
|
|
||||||
|
Returns ``None`` (after printing an error naming the bad value) on the
|
||||||
|
first malformed pair: missing ``=`` or an empty key. Values are stored
|
||||||
|
as-is (strings, no nesting, no type coercion).
|
||||||
|
"""
|
||||||
|
meta: dict[str, str] = {}
|
||||||
|
for pair in pairs or []:
|
||||||
|
key, sep, value = pair.partition("=")
|
||||||
|
if not sep or not key:
|
||||||
|
print(
|
||||||
|
colored(f"Error: malformed --meta value '{pair}' (expected key=value)", Colors.RED),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
meta[key] = value
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def _default_prd_content(title: str, description: str | None = None) -> str:
|
||||||
|
"""Return the default PRD skeleton created with every task."""
|
||||||
|
goal = (description or "").strip() or "TBD."
|
||||||
|
heading = title.strip() or "Untitled task"
|
||||||
|
return f"""# {heading}
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
{goal}
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] TBD
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Keep `prd.md` focused on requirements, constraints, and acceptance criteria.
|
||||||
|
- Lightweight tasks can remain PRD-only.
|
||||||
|
- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Command: create
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def cmd_create(args: argparse.Namespace) -> int:
|
||||||
|
"""Create a new task."""
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
if not args.title:
|
||||||
|
print(colored("Error: title is required", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Validate --meta (CLI source: fail-fast, before any directory is created)
|
||||||
|
meta = _parse_meta_pairs(getattr(args, "meta", None))
|
||||||
|
if meta is None:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Validate --package (CLI source: fail-fast)
|
||||||
|
package: str | None = getattr(args, "package", None)
|
||||||
|
if not is_monorepo(repo_root):
|
||||||
|
# Single-repo: ignore --package, no package prefix
|
||||||
|
if package:
|
||||||
|
print(colored(f"Warning: --package ignored in single-repo project", Colors.YELLOW), file=sys.stderr)
|
||||||
|
package = None
|
||||||
|
elif package:
|
||||||
|
if not validate_package(package, repo_root):
|
||||||
|
packages = get_packages(repo_root)
|
||||||
|
available = ", ".join(sorted(packages.keys())) if packages else "(none)"
|
||||||
|
print(colored(f"Error: unknown package '{package}'. Available: {available}", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
else:
|
||||||
|
# Inferred: default_package → None (no task.json yet for create)
|
||||||
|
package = resolve_package(repo_root=repo_root)
|
||||||
|
|
||||||
|
# Default assignee to current developer
|
||||||
|
assignee = args.assignee
|
||||||
|
if not assignee:
|
||||||
|
assignee = get_developer(repo_root)
|
||||||
|
if not assignee:
|
||||||
|
print(colored("Error: No developer set. Run init_developer.py first or use --assignee", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
ensure_tasks_dir(repo_root)
|
||||||
|
|
||||||
|
# Get current developer as creator
|
||||||
|
creator = get_developer(repo_root) or assignee
|
||||||
|
|
||||||
|
# Generate slug if not provided
|
||||||
|
slug = args.slug or _slugify(args.title)
|
||||||
|
if not slug:
|
||||||
|
print(colored("Error: could not generate slug from title", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Create task directory with MM-DD-slug format
|
||||||
|
tasks_dir = get_tasks_dir(repo_root)
|
||||||
|
date_prefix = generate_task_date_prefix()
|
||||||
|
|
||||||
|
# Guard against date-prefixed --slug (e.g. a full task dir name pasted in),
|
||||||
|
# which would otherwise produce MM-DD-MM-DD-slug (issue #377). Only an
|
||||||
|
# explicit --slug is guarded; title-derived slugs are left untouched.
|
||||||
|
if args.slug:
|
||||||
|
m = re.match(r"^(\d{2})-(\d{2})-(.+)$", slug)
|
||||||
|
if m and 1 <= int(m.group(1)) <= 12 and 1 <= int(m.group(2)) <= 31:
|
||||||
|
slug_prefix = f"{m.group(1)}-{m.group(2)}"
|
||||||
|
if slug_prefix == date_prefix:
|
||||||
|
slug = m.group(3)
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
f'warning: --slug should not include the MM-DD prefix; normalized to "{slug}"',
|
||||||
|
Colors.YELLOW,
|
||||||
|
),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
f"Error: --slug starts with a date prefix ({slug_prefix}-), but task.py create always uses today's date ({date_prefix}).",
|
||||||
|
Colors.RED,
|
||||||
|
),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(f"Pass only the slug body, e.g. --slug {m.group(3)}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
dir_name = f"{date_prefix}-{slug}"
|
||||||
|
task_dir = tasks_dir / dir_name
|
||||||
|
task_json_path = task_dir / FILE_TASK_JSON
|
||||||
|
|
||||||
|
archived_task_dir = _find_archived_task_by_dir_name(tasks_dir, dir_name)
|
||||||
|
if archived_task_dir:
|
||||||
|
print(colored(f"Error: Task already archived: {dir_name}", Colors.RED), file=sys.stderr)
|
||||||
|
print(f"Archived at: {_repo_relative_path(archived_task_dir, repo_root)}", file=sys.stderr)
|
||||||
|
print("Use a new slug if you intend to create a new task.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if task_dir.exists():
|
||||||
|
print(colored(f"Warning: Task directory already exists: {dir_name}", Colors.YELLOW), file=sys.stderr)
|
||||||
|
else:
|
||||||
|
task_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# Record the PR target branch. Prefer the repo's actual default branch
|
||||||
|
# (origin/HEAD) so creating a task from a feature branch doesn't
|
||||||
|
# mis-stamp that feature branch as the PR target (#399 item 1). Falls
|
||||||
|
# back to the checked-out branch when the default can't be resolved
|
||||||
|
# (no remote configured, offline, etc.) — the pre-existing behavior.
|
||||||
|
# --base-branch lets the caller override both when neither is correct.
|
||||||
|
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
|
||||||
|
current_branch = branch_out.strip() or "main"
|
||||||
|
explicit_base_branch: str | None = getattr(args, "base_branch", None)
|
||||||
|
if explicit_base_branch:
|
||||||
|
base_branch = explicit_base_branch
|
||||||
|
else:
|
||||||
|
resolved_base_branch = resolve_default_branch(repo_root)
|
||||||
|
if resolved_base_branch:
|
||||||
|
base_branch = resolved_base_branch
|
||||||
|
else:
|
||||||
|
base_branch = current_branch
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
f"warning: could not resolve the repository's default branch "
|
||||||
|
f"(no remote configured, offline, etc.); stamping base_branch as "
|
||||||
|
f"the checked-out branch '{base_branch}'. Pass --base-branch to override.",
|
||||||
|
Colors.YELLOW,
|
||||||
|
),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
description = (args.description or "").strip()
|
||||||
|
if not description.strip():
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
"warning: task description is empty; pass --description to improve search and later audits.",
|
||||||
|
Colors.YELLOW,
|
||||||
|
),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
task_data = {
|
||||||
|
"id": slug,
|
||||||
|
"name": slug,
|
||||||
|
"title": args.title,
|
||||||
|
"description": description,
|
||||||
|
"status": "planning",
|
||||||
|
"dev_type": None,
|
||||||
|
"scope": None,
|
||||||
|
"package": package,
|
||||||
|
"priority": args.priority,
|
||||||
|
"creator": creator,
|
||||||
|
"assignee": assignee,
|
||||||
|
"createdAt": today,
|
||||||
|
"completedAt": None,
|
||||||
|
"branch": None,
|
||||||
|
"base_branch": base_branch,
|
||||||
|
"worktree_path": None,
|
||||||
|
"commit": None,
|
||||||
|
"pr_url": None,
|
||||||
|
"subtasks": [],
|
||||||
|
"children": [],
|
||||||
|
"parent": None,
|
||||||
|
"relatedFiles": [],
|
||||||
|
"notes": "",
|
||||||
|
"meta": meta,
|
||||||
|
}
|
||||||
|
|
||||||
|
write_json(task_json_path, task_data)
|
||||||
|
|
||||||
|
prd_path = task_dir / "prd.md"
|
||||||
|
if not prd_path.exists():
|
||||||
|
prd_path.write_text(
|
||||||
|
_default_prd_content(args.title, description),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Seed implement.jsonl / check.jsonl for sub-agent-capable platforms.
|
||||||
|
# Agent curates real entries during planning when the task needs them.
|
||||||
|
# Agent-less platforms (Kilo / Antigravity / Devin) skip this — they
|
||||||
|
# load specs via the trellis-before-dev skill instead of JSONL.
|
||||||
|
seeded_jsonl = False
|
||||||
|
if _has_subagent_platform(repo_root):
|
||||||
|
for jsonl_name in ("implement.jsonl", "check.jsonl"):
|
||||||
|
jsonl_path = task_dir / jsonl_name
|
||||||
|
if not jsonl_path.exists():
|
||||||
|
_write_seed_jsonl(jsonl_path)
|
||||||
|
seeded_jsonl = True
|
||||||
|
|
||||||
|
# Handle --parent: establish bidirectional link
|
||||||
|
if args.parent:
|
||||||
|
parent_dir = resolve_task_dir(args.parent, repo_root)
|
||||||
|
if not parent_dir or not (parent_dir / FILE_TASK_JSON).is_file():
|
||||||
|
print(colored(f"Warning: Parent task.json not found: {args.parent}", Colors.YELLOW), file=sys.stderr)
|
||||||
|
else:
|
||||||
|
parent_json_path = parent_dir / FILE_TASK_JSON
|
||||||
|
parent_data = read_json(parent_json_path)
|
||||||
|
if parent_data:
|
||||||
|
# Add child to parent's children list
|
||||||
|
parent_children = parent_data.get("children", [])
|
||||||
|
if dir_name not in parent_children:
|
||||||
|
parent_children.append(dir_name)
|
||||||
|
parent_data["children"] = parent_children
|
||||||
|
write_json(parent_json_path, parent_data)
|
||||||
|
|
||||||
|
# Set parent in child's task.json
|
||||||
|
task_data["parent"] = parent_dir.name
|
||||||
|
write_json(task_json_path, task_data)
|
||||||
|
|
||||||
|
print(colored(f"Linked as child of: {parent_dir.name}", Colors.GREEN), file=sys.stderr)
|
||||||
|
|
||||||
|
# Auto-activate the new task so the per-turn breadcrumb fires planning
|
||||||
|
# state. Best-effort: gracefully degrade if no session identity (CLI run
|
||||||
|
# outside an AI session) — the task is still created, the user can run
|
||||||
|
# task.py start later. Pointer is session-scoped so this never affects
|
||||||
|
# other AI sessions.
|
||||||
|
if getattr(args, "no_start", False):
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
"Skipped session activation (--no-start); run task.py start when ready.",
|
||||||
|
Colors.YELLOW,
|
||||||
|
),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
from .active_task import resolve_context_key, set_active_task
|
||||||
|
except Exception as exc:
|
||||||
|
print(
|
||||||
|
colored(f"Warning: session activation unavailable (import failed: {exc})", Colors.YELLOW),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
context_key = resolve_context_key()
|
||||||
|
except Exception as exc:
|
||||||
|
print(
|
||||||
|
colored(f"Warning: session activation failed (context resolution: {exc})", Colors.YELLOW),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No session identity is the normal CLI-outside-an-AI-session
|
||||||
|
# case (see comment above) — stay silent, not a failure.
|
||||||
|
if context_key:
|
||||||
|
try:
|
||||||
|
rel_dir = task_dir.relative_to(repo_root).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
rel_dir = str(task_dir)
|
||||||
|
try:
|
||||||
|
active = set_active_task(rel_dir, repo_root)
|
||||||
|
except Exception as exc:
|
||||||
|
print(
|
||||||
|
colored(f"Warning: session activation failed (pointer persistence: {exc})", Colors.YELLOW),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if active:
|
||||||
|
print(
|
||||||
|
colored(f"Activated task for this session: {active.task_path}", Colors.GREEN),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(f"Source: {active.source}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
colored("Warning: session activation failed (no pointer returned)", Colors.YELLOW),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(colored(f"Created task: {dir_name}", Colors.GREEN), file=sys.stderr)
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
print(colored("Next steps:", Colors.BLUE), file=sys.stderr)
|
||||||
|
print(" - Fill prd.md with requirements and acceptance criteria", file=sys.stderr)
|
||||||
|
print(" - Lightweight task: PRD-only is valid", file=sys.stderr)
|
||||||
|
print(" - Complex task: add design.md and implement.md before task.py start", file=sys.stderr)
|
||||||
|
if seeded_jsonl:
|
||||||
|
print(
|
||||||
|
" - Curate implement.jsonl / check.jsonl as spec/research manifests when sub-agents need context",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(" - Use /trellis:continue or phase context to decide the next step", file=sys.stderr)
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
|
||||||
|
# Output relative path for script chaining
|
||||||
|
print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}")
|
||||||
|
|
||||||
|
run_task_hooks("after_create", task_json_path, repo_root)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Command: archive
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def cmd_archive(args: argparse.Namespace) -> int:
|
||||||
|
"""Archive completed task."""
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
task_name = args.name
|
||||||
|
|
||||||
|
if not task_name:
|
||||||
|
print(colored("Error: Task name is required", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
tasks_dir = get_tasks_dir(repo_root)
|
||||||
|
|
||||||
|
# Resolve task directory (supports task name, relative path, or absolute path)
|
||||||
|
task_dir = resolve_task_dir(task_name, repo_root)
|
||||||
|
|
||||||
|
if not task_dir or not task_dir.is_dir():
|
||||||
|
print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr)
|
||||||
|
print("Active tasks:", file=sys.stderr)
|
||||||
|
# Import lazily to avoid circular dependency
|
||||||
|
from .tasks import iter_active_tasks
|
||||||
|
for t in iter_active_tasks(tasks_dir):
|
||||||
|
print(f" - {t.dir_name}/", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Refuse to archive anything that isn't a real task directly under
|
||||||
|
# .trellis/tasks/. A mistyped name (e.g. "src") resolves to repo_root/src,
|
||||||
|
# which is a dir but not a task — without this guard archive would move the
|
||||||
|
# user's source directory out of the repo.
|
||||||
|
if not is_within_tasks_dir(task_dir, repo_root):
|
||||||
|
print(colored(
|
||||||
|
f"Error: refusing to archive '{task_name}': "
|
||||||
|
f"{task_dir} is not a task under {tasks_dir}",
|
||||||
|
Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
dir_name = task_dir.name
|
||||||
|
task_json_path = task_dir / FILE_TASK_JSON
|
||||||
|
|
||||||
|
# Update status before archiving
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
# Names of child task dirs whose task.json gets modified below; passed
|
||||||
|
# into safe_archive_paths_to_add so they're staged in this commit.
|
||||||
|
modified_children: list[str] = []
|
||||||
|
if task_json_path.is_file():
|
||||||
|
data = read_json(task_json_path)
|
||||||
|
if data:
|
||||||
|
# Warn (don't block) when the recorded branch is stale — it was
|
||||||
|
# likely already merged and deleted (#399 item 2).
|
||||||
|
stored_branch = data.get("branch")
|
||||||
|
if stored_branch and not branch_exists_locally(stored_branch, repo_root):
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
f"Warning: recorded branch '{stored_branch}' no longer exists locally "
|
||||||
|
"(likely merged and deleted).",
|
||||||
|
Colors.YELLOW,
|
||||||
|
),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
data["status"] = "completed"
|
||||||
|
data["completedAt"] = today
|
||||||
|
write_json(task_json_path, data)
|
||||||
|
|
||||||
|
# Handle subtask relationships on archive.
|
||||||
|
# Keep this task in its parent's children list so progress
|
||||||
|
# counters (children_progress) stay consistent — children
|
||||||
|
# missing from the active set are treated as completed.
|
||||||
|
task_children = data.get("children", [])
|
||||||
|
|
||||||
|
# If this is a parent, clear parent field in all children
|
||||||
|
if task_children:
|
||||||
|
for child_name in task_children:
|
||||||
|
child_dir_path = find_task_by_name(child_name, tasks_dir)
|
||||||
|
if child_dir_path:
|
||||||
|
child_json = child_dir_path / FILE_TASK_JSON
|
||||||
|
if child_json.is_file():
|
||||||
|
child_data = read_json(child_json)
|
||||||
|
if child_data:
|
||||||
|
child_data["parent"] = None
|
||||||
|
write_json(child_json, child_data)
|
||||||
|
modified_children.append(child_dir_path.name)
|
||||||
|
|
||||||
|
# Clear any session that still points at this task before the path moves.
|
||||||
|
from .active_task import clear_task_from_sessions
|
||||||
|
clear_task_from_sessions(str(task_dir), repo_root)
|
||||||
|
|
||||||
|
# Archive
|
||||||
|
result = archive_task_complete(task_dir, repo_root)
|
||||||
|
if "archived_to" in result:
|
||||||
|
archive_dest = Path(result["archived_to"])
|
||||||
|
year_month = archive_dest.parent.name
|
||||||
|
print(colored(f"Archived: {dir_name} -> archive/{year_month}/", Colors.GREEN), file=sys.stderr)
|
||||||
|
|
||||||
|
# Auto-commit unless --no-commit
|
||||||
|
if not getattr(args, "no_commit", False):
|
||||||
|
if not _auto_commit_archive(dir_name, repo_root, modified_children):
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
"Archive moved on disk, but git auto-commit did not complete. "
|
||||||
|
"Resolve `git status` before continuing.",
|
||||||
|
Colors.RED,
|
||||||
|
),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Return the archive path
|
||||||
|
print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}")
|
||||||
|
|
||||||
|
# Run hooks with the archived path
|
||||||
|
archived_json = archive_dest / FILE_TASK_JSON
|
||||||
|
run_task_hooks("after_archive", archived_json, repo_root)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_commit_archive(
|
||||||
|
task_name: str,
|
||||||
|
repo_root: Path,
|
||||||
|
modified_children: list[str] | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Stage Trellis-owned task paths and commit after archive.
|
||||||
|
|
||||||
|
Scoped narrowly to the archived task's source + destination paths
|
||||||
|
plus any child task dirs whose ``task.json`` was edited (parent →
|
||||||
|
children relationship update). Dirty changes in OTHER active task
|
||||||
|
dirs are NOT bundled into the archive commit.
|
||||||
|
|
||||||
|
If ``.gitignore`` blocks the paths, we warn + skip — we do NOT
|
||||||
|
retry with ``git add -f``. The warning explicitly forbids
|
||||||
|
``git add -f .trellis/`` (which would fan out to caches/backups)
|
||||||
|
and points users at ``session_auto_commit: false``.
|
||||||
|
|
||||||
|
Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when
|
||||||
|
set to ``false``, this function returns immediately without
|
||||||
|
touching git (the archive directory move on disk is unaffected).
|
||||||
|
"""
|
||||||
|
if not get_session_auto_commit(repo_root):
|
||||||
|
print(
|
||||||
|
"[OK] session_auto_commit: false — skipping git stage/commit.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
source_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}"
|
||||||
|
rc, tracked_out, _ = run_git(
|
||||||
|
["ls-files", "--", source_rel],
|
||||||
|
cwd=repo_root,
|
||||||
|
)
|
||||||
|
source_was_tracked = rc == 0 and bool(tracked_out.strip())
|
||||||
|
|
||||||
|
paths = safe_archive_paths_to_add(
|
||||||
|
repo_root, task_name=task_name, modified_children=modified_children
|
||||||
|
)
|
||||||
|
if not paths:
|
||||||
|
print("[OK] No task changes to commit.", file=sys.stderr)
|
||||||
|
return True
|
||||||
|
|
||||||
|
success, _, err = safe_git_add(paths, repo_root)
|
||||||
|
if not success:
|
||||||
|
if err and "ignored by" in err.lower():
|
||||||
|
print_gitignore_warning(paths)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"[WARN] git add failed: {err.strip() if err else 'unknown error'}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return not source_was_tracked
|
||||||
|
|
||||||
|
# Belt-and-suspenders for the phantom-delete bug: `safe_git_add` uses
|
||||||
|
# `git add` (no -A) which only stages additions/modifications. The
|
||||||
|
# source task directory was moved away by `shutil.move`, so its files
|
||||||
|
# need an explicit `git rm --cached` to stage the deletions in this
|
||||||
|
# same commit — otherwise they sit as uncommitted "phantom deletes"
|
||||||
|
# against HEAD until something later picks them up.
|
||||||
|
#
|
||||||
|
# `--ignore-unmatch` makes this a no-op when the task was never tracked
|
||||||
|
# (e.g. archiving a task that lived only in working tree).
|
||||||
|
run_git(
|
||||||
|
["rm", "-r", "--cached", "--ignore-unmatch", "--", source_rel],
|
||||||
|
cwd=repo_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
rc, _, _ = run_git(
|
||||||
|
["diff", "--cached", "--quiet", "--", *paths, source_rel],
|
||||||
|
cwd=repo_root,
|
||||||
|
)
|
||||||
|
if rc == 0:
|
||||||
|
print("[OK] No task changes to commit.", file=sys.stderr)
|
||||||
|
return True
|
||||||
|
|
||||||
|
commit_msg = f"chore(task): archive {task_name}"
|
||||||
|
rc, _, err = run_git(["commit", "-m", commit_msg], cwd=repo_root)
|
||||||
|
if rc == 0:
|
||||||
|
print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr)
|
||||||
|
return not source_was_tracked
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Command: add-subtask
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def cmd_add_subtask(args: argparse.Namespace) -> int:
|
||||||
|
"""Link a child task to a parent task."""
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
|
||||||
|
child_dir = resolve_task_dir(args.child_dir, repo_root)
|
||||||
|
|
||||||
|
if not parent_dir:
|
||||||
|
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not child_dir:
|
||||||
|
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
parent_json_path = parent_dir / FILE_TASK_JSON
|
||||||
|
child_json_path = child_dir / FILE_TASK_JSON
|
||||||
|
|
||||||
|
if not parent_json_path.is_file():
|
||||||
|
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not child_json_path.is_file():
|
||||||
|
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
parent_data = read_json(parent_json_path)
|
||||||
|
child_data = read_json(child_json_path)
|
||||||
|
|
||||||
|
if not parent_data or not child_data:
|
||||||
|
print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Check if child already has a parent
|
||||||
|
existing_parent = child_data.get("parent")
|
||||||
|
if existing_parent:
|
||||||
|
print(colored(f"Error: Child task already has a parent: {existing_parent}", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Add child to parent's children list
|
||||||
|
parent_children = parent_data.get("children", [])
|
||||||
|
child_dir_name = child_dir.name
|
||||||
|
if child_dir_name not in parent_children:
|
||||||
|
parent_children.append(child_dir_name)
|
||||||
|
parent_data["children"] = parent_children
|
||||||
|
|
||||||
|
# Set parent in child's task.json
|
||||||
|
child_data["parent"] = parent_dir.name
|
||||||
|
|
||||||
|
# Write both
|
||||||
|
write_json(parent_json_path, parent_data)
|
||||||
|
write_json(child_json_path, child_data)
|
||||||
|
|
||||||
|
print(colored(f"Linked: {child_dir.name} -> {parent_dir.name}", Colors.GREEN), file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Command: remove-subtask
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def cmd_remove_subtask(args: argparse.Namespace) -> int:
|
||||||
|
"""Unlink a child task from a parent task."""
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
|
||||||
|
child_dir = resolve_task_dir(args.child_dir, repo_root)
|
||||||
|
|
||||||
|
if not parent_dir:
|
||||||
|
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not child_dir:
|
||||||
|
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
parent_json_path = parent_dir / FILE_TASK_JSON
|
||||||
|
child_json_path = child_dir / FILE_TASK_JSON
|
||||||
|
|
||||||
|
if not parent_json_path.is_file():
|
||||||
|
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not child_json_path.is_file():
|
||||||
|
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
parent_data = read_json(parent_json_path)
|
||||||
|
child_data = read_json(child_json_path)
|
||||||
|
|
||||||
|
if not parent_data or not child_data:
|
||||||
|
print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Remove child from parent's children list
|
||||||
|
parent_children = parent_data.get("children", [])
|
||||||
|
child_dir_name = child_dir.name
|
||||||
|
if child_dir_name in parent_children:
|
||||||
|
parent_children.remove(child_dir_name)
|
||||||
|
parent_data["children"] = parent_children
|
||||||
|
|
||||||
|
# Clear parent in child's task.json
|
||||||
|
child_data["parent"] = None
|
||||||
|
|
||||||
|
# Write both
|
||||||
|
write_json(parent_json_path, parent_data)
|
||||||
|
write_json(child_json_path, child_data)
|
||||||
|
|
||||||
|
print(colored(f"Unlinked: {child_dir.name} from {parent_dir.name}", Colors.GREEN), file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Command: set-branch
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def cmd_set_branch(args: argparse.Namespace) -> int:
|
||||||
|
"""Set git branch for task."""
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||||
|
branch = args.branch
|
||||||
|
|
||||||
|
if not branch:
|
||||||
|
print(colored("Error: Missing arguments", Colors.RED))
|
||||||
|
print("Usage: python3 task.py set-branch <task-dir> <branch-name>")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not target_dir:
|
||||||
|
# target_dir is None here, so it must not appear in the message. This
|
||||||
|
# is also the branch a ref pointing outside the repo lands in.
|
||||||
|
print(colored(f"Error: Task not found: {args.dir}", Colors.RED))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
task_json = target_dir / FILE_TASK_JSON
|
||||||
|
if not task_json.is_file():
|
||||||
|
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
data = read_json(task_json)
|
||||||
|
if not data:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
data["branch"] = branch
|
||||||
|
write_json(task_json, data)
|
||||||
|
|
||||||
|
print(colored(f"✓ Branch set to: {branch}", Colors.GREEN))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Command: set-base-branch
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def cmd_set_base_branch(args: argparse.Namespace) -> int:
|
||||||
|
"""Set the base branch (PR target) for task."""
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||||
|
base_branch = args.base_branch
|
||||||
|
|
||||||
|
if not base_branch:
|
||||||
|
print(colored("Error: Missing arguments", Colors.RED))
|
||||||
|
print("Usage: python3 task.py set-base-branch <task-dir> <base-branch>")
|
||||||
|
print("Example: python3 task.py set-base-branch <dir> develop")
|
||||||
|
print()
|
||||||
|
print("This sets the target branch for PR (the branch your feature will merge into).")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not target_dir:
|
||||||
|
# target_dir is None here, so it must not appear in the message. This
|
||||||
|
# is also the branch a ref pointing outside the repo lands in.
|
||||||
|
print(colored(f"Error: Task not found: {args.dir}", Colors.RED))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
task_json = target_dir / FILE_TASK_JSON
|
||||||
|
if not task_json.is_file():
|
||||||
|
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
data = read_json(task_json)
|
||||||
|
if not data:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
data["base_branch"] = base_branch
|
||||||
|
write_json(task_json, data)
|
||||||
|
|
||||||
|
print(colored(f"✓ Base branch set to: {base_branch}", Colors.GREEN))
|
||||||
|
print(f" PR will target: {base_branch}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Command: set-scope
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def cmd_set_scope(args: argparse.Namespace) -> int:
|
||||||
|
"""Set scope for PR title."""
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||||
|
scope = args.scope
|
||||||
|
|
||||||
|
if not scope:
|
||||||
|
print(colored("Error: Missing arguments", Colors.RED))
|
||||||
|
print("Usage: python3 task.py set-scope <task-dir> <scope>")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not target_dir:
|
||||||
|
# target_dir is None here, so it must not appear in the message. This
|
||||||
|
# is also the branch a ref pointing outside the repo lands in.
|
||||||
|
print(colored(f"Error: Task not found: {args.dir}", Colors.RED))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
task_json = target_dir / FILE_TASK_JSON
|
||||||
|
if not task_json.is_file():
|
||||||
|
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
data = read_json(task_json)
|
||||||
|
if not data:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
data["scope"] = scope
|
||||||
|
write_json(task_json, data)
|
||||||
|
|
||||||
|
print(colored(f"✓ Scope set to: {scope}", Colors.GREEN))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Command: set-meta
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def cmd_set_meta(args: argparse.Namespace) -> int:
|
||||||
|
"""Set/overwrite one metadata key on an existing task."""
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||||
|
key = args.key
|
||||||
|
value = args.value
|
||||||
|
|
||||||
|
if not key:
|
||||||
|
print(colored("Error: Missing arguments", Colors.RED))
|
||||||
|
print("Usage: python3 task.py set-meta <task-dir> <key> <value>")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not target_dir:
|
||||||
|
# target_dir is None here, so it must not appear in the message. This
|
||||||
|
# is also the branch a ref pointing outside the repo lands in.
|
||||||
|
print(colored(f"Error: Task not found: {args.dir}", Colors.RED))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
task_json = target_dir / FILE_TASK_JSON
|
||||||
|
if not task_json.is_file():
|
||||||
|
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
data = read_json(task_json)
|
||||||
|
if not data:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
meta = data.get("meta")
|
||||||
|
if not isinstance(meta, dict):
|
||||||
|
meta = {}
|
||||||
|
meta[key] = value
|
||||||
|
data["meta"] = meta
|
||||||
|
write_json(task_json, data)
|
||||||
|
|
||||||
|
print(colored(f"✓ Meta set: {key} = {value}", Colors.GREEN))
|
||||||
|
return 0
|
||||||
309
.trellis/scripts/common/task_utils.py
Executable file
309
.trellis/scripts/common/task_utils.py
Executable file
@@ -0,0 +1,309 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Task utility functions.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
is_safe_task_path - Validate task path is safe to operate on
|
||||||
|
find_task_by_name - Find task directory by name
|
||||||
|
resolve_task_dir - Resolve task directory from name, relative, or absolute path
|
||||||
|
archive_task_dir - Archive task to monthly directory
|
||||||
|
run_task_hooks - Run lifecycle hooks for task events
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .paths import get_repo_root, get_tasks_dir
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 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).
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
try:
|
||||||
|
resolved = task_dir_abs.resolve()
|
||||||
|
tasks_resolved = get_tasks_dir(repo_root).resolve()
|
||||||
|
except (OSError, RuntimeError):
|
||||||
|
return False
|
||||||
|
if resolved.parent != tasks_resolved:
|
||||||
|
return False
|
||||||
|
return resolved.name != "archive"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Task Lookup
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None:
|
||||||
|
"""Find task directory by name (exact or suffix match).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_name: Task name to find.
|
||||||
|
tasks_dir: Tasks directory path.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Absolute path to task directory, or None if not found.
|
||||||
|
"""
|
||||||
|
if not task_name or not tasks_dir or not tasks_dir.is_dir():
|
||||||
|
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
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Archive Operations
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path | None:
|
||||||
|
"""Archive a task directory to archive/{YYYY-MM}/.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_dir_abs: Absolute path to task directory.
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to archived directory, or None on error.
|
||||||
|
"""
|
||||||
|
if not task_dir_abs.is_dir():
|
||||||
|
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
|
||||||
|
|
||||||
|
# Create archive directory
|
||||||
|
try:
|
||||||
|
month_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
except (OSError, IOError) as e:
|
||||||
|
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
|
||||||
|
|
||||||
|
try:
|
||||||
|
shutil.move(str(task_dir_abs), str(dest))
|
||||||
|
except (OSError, IOError, shutil.Error) as e:
|
||||||
|
print(f"Error: Failed to move task to archive: {e}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def archive_task_complete(
|
||||||
|
task_dir_abs: Path,
|
||||||
|
repo_root: Path | None = None
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Complete archive workflow: archive directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_dir_abs: Absolute path to task directory.
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with archive result info.
|
||||||
|
"""
|
||||||
|
if not task_dir_abs.is_dir():
|
||||||
|
print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
archive_dest = archive_task_dir(task_dir_abs, repo_root)
|
||||||
|
if archive_dest:
|
||||||
|
return {"archived_to": str(archive_dest)}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Task Directory Resolution
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def resolve_task_dir(target_dir: str, repo_root: Path) -> Path | None:
|
||||||
|
"""Resolve task directory to absolute path.
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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).
|
||||||
|
"""
|
||||||
|
if not target_dir:
|
||||||
|
return Path()
|
||||||
|
|
||||||
|
normalized = target_dir.replace("\\", "/")
|
||||||
|
while normalized.startswith("./"):
|
||||||
|
normalized = normalized[2:]
|
||||||
|
|
||||||
|
# Absolute path
|
||||||
|
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"):
|
||||||
|
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)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved = candidate.resolve()
|
||||||
|
root = repo_root.resolve()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved.relative_to(root)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Lifecycle Hooks
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def run_task_hooks(event: str, task_json_path: Path, repo_root: Path) -> None:
|
||||||
|
"""Run lifecycle hooks for a task event.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: Event name (e.g. "after_create").
|
||||||
|
task_json_path: Absolute path to the task's task.json.
|
||||||
|
repo_root: Repository root for cwd and config lookup.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from .config import get_hooks
|
||||||
|
from .log import Colors, colored
|
||||||
|
|
||||||
|
commands = get_hooks(event, repo_root)
|
||||||
|
if not commands:
|
||||||
|
return
|
||||||
|
|
||||||
|
env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)}
|
||||||
|
|
||||||
|
for cmd in commands:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
shell=True,
|
||||||
|
cwd=repo_root,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(
|
||||||
|
colored(f"[WARN] Hook failed ({event}): {cmd}", Colors.YELLOW),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
if result.stderr.strip():
|
||||||
|
print(f" {result.stderr.strip()}", file=sys.stderr)
|
||||||
|
except Exception as e:
|
||||||
|
print(
|
||||||
|
colored(f"[WARN] Hook error ({event}): {cmd} — {e}", Colors.YELLOW),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Main Entry (for testing)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
repo = get_repo_root()
|
||||||
|
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)}")
|
||||||
112
.trellis/scripts/common/tasks.py
Executable file
112
.trellis/scripts/common/tasks.py
Executable file
@@ -0,0 +1,112 @@
|
|||||||
|
"""
|
||||||
|
Task data access layer.
|
||||||
|
|
||||||
|
Single source of truth for loading and iterating task directories.
|
||||||
|
Replaces scattered task.json parsing across 9+ files.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
load_task — Load a single task by directory path
|
||||||
|
iter_active_tasks — Iterate all non-archived tasks (sorted)
|
||||||
|
get_all_statuses — Get {dir_name: status} map for children progress
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .io import read_json
|
||||||
|
from .paths import FILE_TASK_JSON
|
||||||
|
from .types import TaskInfo
|
||||||
|
|
||||||
|
|
||||||
|
def load_task(task_dir: Path) -> TaskInfo | None:
|
||||||
|
"""Load task from a directory containing task.json.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_dir: Absolute path to the task directory.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TaskInfo if task.json exists and is valid, None otherwise.
|
||||||
|
"""
|
||||||
|
task_json = task_dir / FILE_TASK_JSON
|
||||||
|
if not task_json.is_file():
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = read_json(task_json)
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return TaskInfo(
|
||||||
|
dir_name=task_dir.name,
|
||||||
|
directory=task_dir,
|
||||||
|
title=data.get("title") or data.get("name") or "unknown",
|
||||||
|
status=data.get("status", "unknown"),
|
||||||
|
assignee=data.get("assignee", ""),
|
||||||
|
priority=data.get("priority", "P2"),
|
||||||
|
children=tuple(data.get("children", [])),
|
||||||
|
parent=data.get("parent"),
|
||||||
|
package=data.get("package"),
|
||||||
|
raw=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def iter_active_tasks(tasks_dir: Path) -> Iterator[TaskInfo]:
|
||||||
|
"""Iterate all active (non-archived) tasks, sorted by directory name.
|
||||||
|
|
||||||
|
Skips the "archive" directory and directories without valid task.json.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tasks_dir: Path to the tasks directory.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
TaskInfo for each valid task.
|
||||||
|
"""
|
||||||
|
if not tasks_dir.is_dir():
|
||||||
|
return
|
||||||
|
|
||||||
|
for d in sorted(tasks_dir.iterdir()):
|
||||||
|
if not d.is_dir() or d.name == "archive":
|
||||||
|
continue
|
||||||
|
info = load_task(d)
|
||||||
|
if info is not None:
|
||||||
|
yield info
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_statuses(tasks_dir: Path) -> dict[str, str]:
|
||||||
|
"""Get a {dir_name: status} mapping for all active tasks.
|
||||||
|
|
||||||
|
Useful for computing children progress without loading full TaskInfo.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tasks_dir: Path to the tasks directory.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping directory names to status strings.
|
||||||
|
"""
|
||||||
|
return {t.dir_name: t.status for t in iter_active_tasks(tasks_dir)}
|
||||||
|
|
||||||
|
|
||||||
|
def children_progress(
|
||||||
|
children: tuple[str, ...] | list[str],
|
||||||
|
all_statuses: dict[str, str],
|
||||||
|
) -> str:
|
||||||
|
"""Format children progress string like " [2/3 done]".
|
||||||
|
|
||||||
|
Args:
|
||||||
|
children: List of child directory names.
|
||||||
|
all_statuses: Status map from get_all_statuses().
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string, or "" if no children.
|
||||||
|
"""
|
||||||
|
if not children:
|
||||||
|
return ""
|
||||||
|
# A child missing from active statuses has been archived (cmd_archive
|
||||||
|
# sets status=completed before moving the dir). Count it as done so
|
||||||
|
# parent progress doesn't regress when children are archived.
|
||||||
|
done = sum(
|
||||||
|
1 for c in children
|
||||||
|
if c not in all_statuses or all_statuses.get(c) in ("completed", "done")
|
||||||
|
)
|
||||||
|
return f" [{done}/{len(children)} done]"
|
||||||
132
.trellis/scripts/common/trellis_config.py
Executable file
132
.trellis/scripts/common/trellis_config.py
Executable file
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_REL_PATH = ".trellis/config.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def _unquote(value: str) -> str:
|
||||||
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
|
||||||
|
return value[1:-1]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
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 _next_content_line(lines: list[str], start: int) -> tuple[int, str]:
|
||||||
|
i = start
|
||||||
|
while i < len(lines):
|
||||||
|
stripped = lines[i].strip()
|
||||||
|
if stripped and not stripped.startswith("#"):
|
||||||
|
return i, lines[i]
|
||||||
|
i += 1
|
||||||
|
return i, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_yaml_block(
|
||||||
|
lines: list[str], start: int, min_indent: int, target: dict
|
||||||
|
) -> int:
|
||||||
|
i = start
|
||||||
|
current_list: list | None = None
|
||||||
|
|
||||||
|
while i < len(lines):
|
||||||
|
line = lines[i]
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
indent = len(line) - len(line.lstrip())
|
||||||
|
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:
|
||||||
|
target[key] = value
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
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("- "):
|
||||||
|
current_list = []
|
||||||
|
target[key] = current_list
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
next_indent = len(next_line) - len(next_line.lstrip())
|
||||||
|
if next_indent > indent:
|
||||||
|
nested: dict = {}
|
||||||
|
target[key] = nested
|
||||||
|
i = _parse_yaml_block(lines, i + 1, next_indent, nested)
|
||||||
|
else:
|
||||||
|
target[key] = {}
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return i
|
||||||
|
|
||||||
|
|
||||||
|
def parse_simple_yaml(content: str) -> dict:
|
||||||
|
"""Parse a small subset of YAML. See common.config for full doc."""
|
||||||
|
lines = content.splitlines()
|
||||||
|
result: dict = {}
|
||||||
|
_parse_yaml_block(lines, 0, 0, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def read_trellis_config(repo_root: Optional[Path] = None) -> dict:
|
||||||
|
"""Read .trellis/config.yaml. Returns {} on missing or malformed file."""
|
||||||
|
root = repo_root or Path.cwd()
|
||||||
|
config_file = root / CONFIG_REL_PATH
|
||||||
|
try:
|
||||||
|
content = config_file.read_text(encoding="utf-8")
|
||||||
|
except (FileNotFoundError, OSError):
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
parsed = parse_simple_yaml(content)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return parsed if isinstance(parsed, dict) else {}
|
||||||
110
.trellis/scripts/common/types.py
Executable file
110
.trellis/scripts/common/types.py
Executable file
@@ -0,0 +1,110 @@
|
|||||||
|
"""
|
||||||
|
Core type definitions for Trellis task data.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
TaskData — TypedDict for task.json shape (read-path type hints only)
|
||||||
|
TaskInfo — Frozen dataclass for loaded task (the public API type)
|
||||||
|
AgentRecord — TypedDict for registry.json agent entries
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# task.json shape (TypedDict — used only for read-path type hints)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TaskData(TypedDict, total=False):
|
||||||
|
"""Shape of task.json on disk.
|
||||||
|
|
||||||
|
Used only for type annotations when reading task.json.
|
||||||
|
Writes must use the original dict to avoid losing unknown fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
status: str
|
||||||
|
dev_type: str
|
||||||
|
scope: str | None
|
||||||
|
package: str | None
|
||||||
|
priority: str
|
||||||
|
creator: str
|
||||||
|
assignee: str
|
||||||
|
createdAt: str
|
||||||
|
completedAt: str | None
|
||||||
|
branch: str | None
|
||||||
|
base_branch: str | None
|
||||||
|
worktree_path: str | None
|
||||||
|
commit: str | None
|
||||||
|
pr_url: str | None
|
||||||
|
subtasks: list[str]
|
||||||
|
children: list[str]
|
||||||
|
parent: str | None
|
||||||
|
relatedFiles: list[str]
|
||||||
|
notes: str
|
||||||
|
meta: dict
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Loaded task object (frozen dataclass — the public API type)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TaskInfo:
|
||||||
|
"""Immutable view of a loaded task.
|
||||||
|
|
||||||
|
Created by load_task() / iter_active_tasks().
|
||||||
|
Contains the commonly accessed fields; the original dict
|
||||||
|
is preserved in `raw` for write-back and uncommon field access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
dir_name: str
|
||||||
|
directory: Path
|
||||||
|
title: str
|
||||||
|
status: str
|
||||||
|
assignee: str
|
||||||
|
priority: str
|
||||||
|
children: tuple[str, ...]
|
||||||
|
parent: str | None
|
||||||
|
package: str | None
|
||||||
|
raw: dict # original dict — use for writes and uncommon fields
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Task name (id or name field)."""
|
||||||
|
return self.raw.get("name") or self.raw.get("id") or self.dir_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
return self.raw.get("description", "")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def branch(self) -> str | None:
|
||||||
|
return self.raw.get("branch")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def meta(self) -> dict:
|
||||||
|
return self.raw.get("meta", {})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# registry.json agent entry
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class AgentRecord(TypedDict, total=False):
|
||||||
|
"""Shape of an agent entry in registry.json."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
pid: int
|
||||||
|
task_dir: str
|
||||||
|
worktree_path: str
|
||||||
|
branch: str
|
||||||
|
platform: str
|
||||||
|
started_at: str
|
||||||
|
status: str
|
||||||
219
.trellis/scripts/common/workflow_phase.py
Executable file
219
.trellis/scripts/common/workflow_phase.py
Executable file
@@ -0,0 +1,219 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Workflow Phase Extraction.
|
||||||
|
|
||||||
|
Extracts step-level content from .trellis/workflow.md and optionally filters
|
||||||
|
platform-specific blocks.
|
||||||
|
|
||||||
|
Platform marker syntax in workflow.md:
|
||||||
|
|
||||||
|
[Claude Code, Cursor, ...]
|
||||||
|
agent-capable content
|
||||||
|
[/Claude Code, Cursor, ...]
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
get_phase_index - Extract the Phase Index section (no --step)
|
||||||
|
get_step - Extract a single step (#### X.X) section
|
||||||
|
filter_platform - Strip platform blocks that don't include the given name
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from .paths import DIR_WORKFLOW, get_repo_root
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow_md_path():
|
||||||
|
return get_repo_root() / DIR_WORKFLOW / "workflow.md"
|
||||||
|
|
||||||
|
# Match a line that *is* a platform marker: "[A, B, C]" or "[/A, B, C]"
|
||||||
|
_MARKER_RE = re.compile(r"^\[(/?)([A-Za-z][^\[\]]*)\]\s*$")
|
||||||
|
|
||||||
|
# Step heading: "#### 1.0 Title" or "#### 1.0 ..."
|
||||||
|
_STEP_HEADING_RE = re.compile(r"^####\s+(\d+\.\d+)\b.*$")
|
||||||
|
|
||||||
|
# Phase Index starts here; Phase 1/2/3 step bodies follow; ends at Breadcrumbs.
|
||||||
|
_PHASE_INDEX_HEADING = "## Phase Index"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_workflow() -> str:
|
||||||
|
path = _workflow_md_path()
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(f"workflow.md not found: {path}")
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_marker(line: str) -> tuple[bool, list[str]] | None:
|
||||||
|
"""Parse a platform marker line.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(is_closing, [platform_names]) if line is a marker, else None.
|
||||||
|
"""
|
||||||
|
m = _MARKER_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
is_closing = m.group(1) == "/"
|
||||||
|
names = [p.strip() for p in m.group(2).split(",") if p.strip()]
|
||||||
|
return is_closing, names
|
||||||
|
|
||||||
|
|
||||||
|
def get_phase_index() -> str:
|
||||||
|
"""Return the compact Phase Index summary from workflow.md.
|
||||||
|
|
||||||
|
SessionStart and no-step phase context use this small summary as their
|
||||||
|
orientation payload. Detailed Phase 1/2/3 instructions are loaded with
|
||||||
|
``get_step`` on demand. ``[workflow-state:STATUS]`` tag blocks are
|
||||||
|
consumed by the per-turn hook, so they're stripped from this output.
|
||||||
|
"""
|
||||||
|
text = _read_workflow()
|
||||||
|
lines = text.splitlines()
|
||||||
|
|
||||||
|
start: int | None = None
|
||||||
|
end: int | None = None
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
stripped = line.strip()
|
||||||
|
if start is None and stripped == _PHASE_INDEX_HEADING:
|
||||||
|
start = i
|
||||||
|
continue
|
||||||
|
if start is not None and stripped == "## Phase 1: Plan":
|
||||||
|
end = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if start is None:
|
||||||
|
return ""
|
||||||
|
if end is None:
|
||||||
|
end = len(lines)
|
||||||
|
|
||||||
|
section = "\n".join(lines[start:end]).rstrip()
|
||||||
|
# Strip [workflow-state:STATUS]...[/workflow-state:STATUS] blocks since
|
||||||
|
# they're injected separately by inject-workflow-state.py per-turn.
|
||||||
|
import re as _re
|
||||||
|
tag_re = _re.compile(
|
||||||
|
r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n.*?\n\s*\[/workflow-state:\1\]\n?",
|
||||||
|
_re.DOTALL,
|
||||||
|
)
|
||||||
|
return tag_re.sub("", section).rstrip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def get_step(step_id: str) -> str:
|
||||||
|
"""Return the `#### X.X` section matching step_id (header + body).
|
||||||
|
|
||||||
|
Body ends at the next `####` or `---` or `##` heading (whichever comes first).
|
||||||
|
"""
|
||||||
|
text = _read_workflow()
|
||||||
|
lines = text.splitlines()
|
||||||
|
|
||||||
|
start: int | None = None
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
m = _STEP_HEADING_RE.match(line)
|
||||||
|
if m and m.group(1) == step_id:
|
||||||
|
start = i
|
||||||
|
break
|
||||||
|
if start is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
end: int = len(lines)
|
||||||
|
for j in range(start + 1, len(lines)):
|
||||||
|
line = lines[j]
|
||||||
|
if line.startswith("#### "):
|
||||||
|
end = j
|
||||||
|
break
|
||||||
|
if line.startswith("## "):
|
||||||
|
end = j
|
||||||
|
break
|
||||||
|
# Horizontal rule at column 0
|
||||||
|
if line.strip() == "---":
|
||||||
|
end = j
|
||||||
|
break
|
||||||
|
|
||||||
|
return "\n".join(lines[start:end]).rstrip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _platform_matches(platform: str, block_names: list[str]) -> bool:
|
||||||
|
"""Case-insensitive fuzzy match: accept 'cursor', 'Cursor', 'claude-code', 'Claude Code'."""
|
||||||
|
needle = platform.lower().replace("-", "").replace("_", "").replace(" ", "")
|
||||||
|
for name in block_names:
|
||||||
|
hay = name.lower().replace("-", "").replace("_", "").replace(" ", "")
|
||||||
|
if needle == hay:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_effective_platform(platform: str, config: dict) -> str:
|
||||||
|
"""Map ``codex`` to a dispatch-mode-namespaced virtual platform name.
|
||||||
|
|
||||||
|
When ``--platform codex`` is passed, return ``"codex-sub-agent"`` by
|
||||||
|
default or ``"codex-inline"`` when explicitly configured in
|
||||||
|
``.trellis/config.yaml``. ``sub-agent`` remains an alias for ``auto``.
|
||||||
|
``filter_platform`` then surfaces blocks whose marker lists include the
|
||||||
|
namespaced name (e.g. ``[codex-sub-agent, ...]`` or ``[codex-inline, Kilo,
|
||||||
|
Antigravity, Devin]``).
|
||||||
|
|
||||||
|
Native Codex context injection supports the ``auto`` default. Invalid
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
if platform == "codex":
|
||||||
|
mode = "auto"
|
||||||
|
codex_cfg = config.get("codex") if isinstance(config, dict) else None
|
||||||
|
if codex_cfg is not None:
|
||||||
|
if not isinstance(codex_cfg, dict):
|
||||||
|
mode = "inline"
|
||||||
|
else:
|
||||||
|
cfg_mode = str(codex_cfg.get("dispatch_mode", mode)).strip().lower()
|
||||||
|
if cfg_mode == "inline":
|
||||||
|
mode = "inline"
|
||||||
|
elif cfg_mode in ("auto", "sub-agent"):
|
||||||
|
mode = "auto"
|
||||||
|
else:
|
||||||
|
mode = "inline"
|
||||||
|
return "codex-sub-agent" if mode == "auto" else "codex-inline"
|
||||||
|
return platform
|
||||||
|
|
||||||
|
|
||||||
|
def filter_platform(content: str, platform: str) -> str:
|
||||||
|
"""Keep lines outside any `[...]` block + lines inside blocks that include platform.
|
||||||
|
|
||||||
|
Marker lines themselves are dropped from the output.
|
||||||
|
"""
|
||||||
|
lines = content.splitlines()
|
||||||
|
out: list[str] = []
|
||||||
|
|
||||||
|
in_block = False
|
||||||
|
keep_block = False
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
marker = _parse_marker(line)
|
||||||
|
if marker is not None:
|
||||||
|
is_closing, names = marker
|
||||||
|
if not is_closing:
|
||||||
|
in_block = True
|
||||||
|
keep_block = _platform_matches(platform, names)
|
||||||
|
else:
|
||||||
|
in_block = False
|
||||||
|
keep_block = False
|
||||||
|
continue # drop the marker line itself
|
||||||
|
|
||||||
|
if in_block:
|
||||||
|
if keep_block:
|
||||||
|
out.append(line)
|
||||||
|
continue
|
||||||
|
out.append(line)
|
||||||
|
|
||||||
|
# Collapse runs of 3+ blank lines that may arise from dropped markers
|
||||||
|
collapsed: list[str] = []
|
||||||
|
blank_run = 0
|
||||||
|
for line in out:
|
||||||
|
if line.strip() == "":
|
||||||
|
blank_run += 1
|
||||||
|
if blank_run <= 2:
|
||||||
|
collapsed.append(line)
|
||||||
|
else:
|
||||||
|
blank_run = 0
|
||||||
|
collapsed.append(line)
|
||||||
|
|
||||||
|
return "\n".join(collapsed).rstrip() + "\n"
|
||||||
16
.trellis/scripts/get_context.py
Executable file
16
.trellis/scripts/get_context.py
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Get Session Context for AI Agent.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 get_context.py Output context in text format
|
||||||
|
python3 get_context.py --json Output context in JSON format
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from common.git_context import main
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
26
.trellis/scripts/get_developer.py
Executable file
26
.trellis/scripts/get_developer.py
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Get current developer name.
|
||||||
|
|
||||||
|
This is a wrapper that uses common/paths.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from common.paths import get_developer
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""CLI entry point."""
|
||||||
|
developer = get_developer()
|
||||||
|
if developer:
|
||||||
|
print(developer)
|
||||||
|
else:
|
||||||
|
print("Developer not initialized", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
243
.trellis/scripts/hooks/linear_sync.py
Executable file
243
.trellis/scripts/hooks/linear_sync.py
Executable file
@@ -0,0 +1,243 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Linear sync hook for Trellis task lifecycle.
|
||||||
|
|
||||||
|
Syncs task events to Linear via the `linearis` CLI.
|
||||||
|
|
||||||
|
Usage (called automatically by task.py hooks):
|
||||||
|
python3 .trellis/scripts/hooks/linear_sync.py create
|
||||||
|
python3 .trellis/scripts/hooks/linear_sync.py start
|
||||||
|
python3 .trellis/scripts/hooks/linear_sync.py archive
|
||||||
|
|
||||||
|
Manual usage:
|
||||||
|
TASK_JSON_PATH=.trellis/tasks/<name>/task.json python3 .trellis/scripts/hooks/linear_sync.py sync
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
TASK_JSON_PATH - Absolute path to task.json (set by task.py)
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
.trellis/hooks.local.json - Local config (gitignored), example:
|
||||||
|
{
|
||||||
|
"linear": {
|
||||||
|
"team": "TEAM_KEY",
|
||||||
|
"project": "Project Name",
|
||||||
|
"assignees": {
|
||||||
|
"dev-name": "linear-user-id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ─── Configuration ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Trellis priority → Linear priority (1=Urgent, 2=High, 3=Medium, 4=Low)
|
||||||
|
PRIORITY_MAP = {"P0": 1, "P1": 2, "P2": 3, "P3": 4}
|
||||||
|
|
||||||
|
# Linear status names (must match your team's workflow)
|
||||||
|
STATUS_IN_PROGRESS = "In Progress"
|
||||||
|
STATUS_DONE = "Done"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config() -> dict:
|
||||||
|
"""Load local hook config from .trellis/hooks.local.json."""
|
||||||
|
task_json_path = os.environ.get("TASK_JSON_PATH", "")
|
||||||
|
if task_json_path:
|
||||||
|
# Walk up from task.json to find .trellis/
|
||||||
|
trellis_dir = Path(task_json_path).parent.parent.parent
|
||||||
|
else:
|
||||||
|
trellis_dir = Path(".trellis")
|
||||||
|
|
||||||
|
config_path = trellis_dir / "hooks.local.json"
|
||||||
|
try:
|
||||||
|
with open(config_path, encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG = _load_config()
|
||||||
|
LINEAR_CFG = CONFIG.get("linear", {})
|
||||||
|
|
||||||
|
TEAM = LINEAR_CFG.get("team", "")
|
||||||
|
PROJECT = LINEAR_CFG.get("project", "")
|
||||||
|
ASSIGNEE_MAP = LINEAR_CFG.get("assignees", {})
|
||||||
|
|
||||||
|
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _read_task() -> tuple[dict, str]:
|
||||||
|
path = os.environ.get("TASK_JSON_PATH", "")
|
||||||
|
if not path:
|
||||||
|
print("TASK_JSON_PATH not set", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
return json.load(f), path
|
||||||
|
|
||||||
|
|
||||||
|
def _write_task(data: dict, path: str) -> None:
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _linearis(*args: str) -> dict | None:
|
||||||
|
result = subprocess.run(
|
||||||
|
["linearis", *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"linearis error: {result.stderr.strip()}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
stdout = result.stdout.strip()
|
||||||
|
if stdout:
|
||||||
|
return json.loads(stdout)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_linear_issue(task: dict) -> str | None:
|
||||||
|
meta = task.get("meta")
|
||||||
|
if isinstance(meta, dict):
|
||||||
|
return meta.get("linear_issue")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Actions ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_create() -> None:
|
||||||
|
if not TEAM:
|
||||||
|
print("No linear.team configured in hooks.local.json", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
task, path = _read_task()
|
||||||
|
|
||||||
|
# Skip if already linked
|
||||||
|
if _get_linear_issue(task):
|
||||||
|
print(f"Already linked: {_get_linear_issue(task)}")
|
||||||
|
return
|
||||||
|
|
||||||
|
title = task.get("title") or task.get("name") or "Untitled"
|
||||||
|
args = ["issues", "create", title, "--team", TEAM]
|
||||||
|
|
||||||
|
# Map priority
|
||||||
|
priority = PRIORITY_MAP.get(task.get("priority", ""), 0)
|
||||||
|
if priority:
|
||||||
|
args.extend(["-p", str(priority)])
|
||||||
|
|
||||||
|
# Set project
|
||||||
|
if PROJECT:
|
||||||
|
args.extend(["--project", PROJECT])
|
||||||
|
|
||||||
|
# Assign to Linear user
|
||||||
|
assignee = task.get("assignee", "")
|
||||||
|
linear_user_id = ASSIGNEE_MAP.get(assignee)
|
||||||
|
if linear_user_id:
|
||||||
|
args.extend(["--assignee", linear_user_id])
|
||||||
|
|
||||||
|
# Link to parent's Linear issue if available
|
||||||
|
parent_issue = _resolve_parent_linear_issue(task)
|
||||||
|
if parent_issue:
|
||||||
|
args.extend(["--parent-ticket", parent_issue])
|
||||||
|
|
||||||
|
result = _linearis(*args)
|
||||||
|
if result and "identifier" in result:
|
||||||
|
if not isinstance(task.get("meta"), dict):
|
||||||
|
task["meta"] = {}
|
||||||
|
task["meta"]["linear_issue"] = result["identifier"]
|
||||||
|
_write_task(task, path)
|
||||||
|
print(f"Created Linear issue: {result['identifier']}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_start() -> None:
|
||||||
|
task, _ = _read_task()
|
||||||
|
issue = _get_linear_issue(task)
|
||||||
|
if not issue:
|
||||||
|
return
|
||||||
|
_linearis("issues", "update", issue, "-s", STATUS_IN_PROGRESS)
|
||||||
|
print(f"Updated {issue} -> {STATUS_IN_PROGRESS}")
|
||||||
|
cmd_sync()
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_archive() -> None:
|
||||||
|
task, _ = _read_task()
|
||||||
|
issue = _get_linear_issue(task)
|
||||||
|
if not issue:
|
||||||
|
return
|
||||||
|
_linearis("issues", "update", issue, "-s", STATUS_DONE)
|
||||||
|
print(f"Updated {issue} -> {STATUS_DONE}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_sync() -> None:
|
||||||
|
"""Sync prd.md content to Linear issue description."""
|
||||||
|
task, _ = _read_task()
|
||||||
|
issue = _get_linear_issue(task)
|
||||||
|
if not issue:
|
||||||
|
print("No linear_issue in meta, run create first", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Find prd.md next to task.json
|
||||||
|
task_json_path = os.environ.get("TASK_JSON_PATH", "")
|
||||||
|
prd_path = Path(task_json_path).parent / "prd.md"
|
||||||
|
if not prd_path.is_file():
|
||||||
|
print(f"No prd.md found at {prd_path}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
description = prd_path.read_text(encoding="utf-8").strip()
|
||||||
|
_linearis("issues", "update", issue, "-d", description)
|
||||||
|
print(f"Synced prd.md to {issue} description")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Parent Issue Resolution ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_parent_linear_issue(task: dict) -> str | None:
|
||||||
|
"""Find parent task's Linear issue identifier."""
|
||||||
|
parent_name = task.get("parent")
|
||||||
|
if not parent_name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
task_json_path = os.environ.get("TASK_JSON_PATH", "")
|
||||||
|
if not task_json_path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
current_task_dir = Path(task_json_path).parent
|
||||||
|
tasks_dir = current_task_dir.parent
|
||||||
|
parent_json = tasks_dir / parent_name / "task.json"
|
||||||
|
|
||||||
|
if parent_json.exists():
|
||||||
|
try:
|
||||||
|
with open(parent_json, encoding="utf-8") as f:
|
||||||
|
parent_task = json.load(f)
|
||||||
|
return _get_linear_issue(parent_task)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
action = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||||
|
actions = {
|
||||||
|
"create": cmd_create,
|
||||||
|
"start": cmd_start,
|
||||||
|
"archive": cmd_archive,
|
||||||
|
"sync": cmd_sync,
|
||||||
|
}
|
||||||
|
fn = actions.get(action)
|
||||||
|
if fn:
|
||||||
|
fn()
|
||||||
|
else:
|
||||||
|
print(f"Unknown action: {action}", file=sys.stderr)
|
||||||
|
print(f"Valid actions: {', '.join(actions)}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
51
.trellis/scripts/init_developer.py
Executable file
51
.trellis/scripts/init_developer.py
Executable file
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Initialize developer for workflow.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 init_developer.py <developer-name>
|
||||||
|
|
||||||
|
This creates:
|
||||||
|
- .trellis/.developer file with developer info
|
||||||
|
- .trellis/workspace/<name>/ directory structure
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from common.paths import (
|
||||||
|
DIR_WORKFLOW,
|
||||||
|
FILE_DEVELOPER,
|
||||||
|
get_developer,
|
||||||
|
)
|
||||||
|
from common.developer import init_developer
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""CLI entry point."""
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(f"Usage: {sys.argv[0]} <developer-name>")
|
||||||
|
print()
|
||||||
|
print("Example:")
|
||||||
|
print(f" {sys.argv[0]} john")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
name = sys.argv[1]
|
||||||
|
|
||||||
|
# Check if already initialized
|
||||||
|
existing = get_developer()
|
||||||
|
if existing:
|
||||||
|
print(f"Developer already initialized: {existing}")
|
||||||
|
print()
|
||||||
|
print(f"To reinitialize, remove {DIR_WORKFLOW}/{FILE_DEVELOPER} first")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if init_developer(name):
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
612
.trellis/scripts/task.py
Executable file
612
.trellis/scripts/task.py
Executable file
@@ -0,0 +1,612 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
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 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 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 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
|
||||||
|
python3 task.py remove-subtask <parent-dir> <child-dir> # Unlink child from parent
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from common.log import Colors, colored
|
||||||
|
from common.paths import (
|
||||||
|
DIR_WORKFLOW,
|
||||||
|
DIR_TASKS,
|
||||||
|
FILE_TASK_JSON,
|
||||||
|
get_repo_root,
|
||||||
|
get_developer,
|
||||||
|
get_tasks_dir,
|
||||||
|
get_current_task,
|
||||||
|
)
|
||||||
|
from common.active_task import (
|
||||||
|
clear_active_task,
|
||||||
|
resolve_active_task,
|
||||||
|
resolve_context_key,
|
||||||
|
set_active_task,
|
||||||
|
)
|
||||||
|
from common.io import read_json, 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_archive,
|
||||||
|
cmd_set_branch,
|
||||||
|
cmd_set_base_branch,
|
||||||
|
cmd_set_scope,
|
||||||
|
cmd_set_meta,
|
||||||
|
cmd_add_subtask,
|
||||||
|
cmd_remove_subtask,
|
||||||
|
)
|
||||||
|
from common.task_context import (
|
||||||
|
cmd_add_context,
|
||||||
|
cmd_validate,
|
||||||
|
cmd_list_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Command: start / finish
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def cmd_start(args: argparse.Namespace) -> int:
|
||||||
|
"""Set active task."""
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
task_input = args.dir
|
||||||
|
|
||||||
|
if not task_input:
|
||||||
|
print(colored("Error: task directory or name required", Colors.RED))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# 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():
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# symlink (e.g. /tmp on macOS) and reject a perfectly normal task.
|
||||||
|
try:
|
||||||
|
task_dir = full_path.relative_to(repo_root.resolve()).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
# resolve_task_dir already refused everything outside the repo, so
|
||||||
|
# this is unreachable in practice. Refuse rather than fall back to
|
||||||
|
# str(full_path) — that fallback (a lexical relative_to() paired with
|
||||||
|
# an absolute-path fallback) is exactly the pattern that let a `..`
|
||||||
|
# ref escape into storage before this fix.
|
||||||
|
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
|
||||||
|
|
||||||
|
task_json_path = full_path / FILE_TASK_JSON
|
||||||
|
|
||||||
|
if not resolve_context_key():
|
||||||
|
# Degraded mode: no session identity available.
|
||||||
|
# Hook didn't inject TRELLIS_CONTEXT_ID (common on Windows + Claude Code,
|
||||||
|
# --continue resume path, fork distribution, hooks disabled, etc.). Skip
|
||||||
|
# per-session pointer write; AI continues based on conversation context.
|
||||||
|
print(colored(
|
||||||
|
"ℹ Session identity not available; active-task pointer not persisted "
|
||||||
|
"this session (degraded mode). AI continues based on conversation context.",
|
||||||
|
Colors.YELLOW,
|
||||||
|
))
|
||||||
|
print(colored(
|
||||||
|
"Hint: run inside an AI IDE/session that exposes session identity, "
|
||||||
|
"or set TRELLIS_CONTEXT_ID before running task.py start.",
|
||||||
|
Colors.YELLOW,
|
||||||
|
))
|
||||||
|
|
||||||
|
# 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))
|
||||||
|
run_task_hooks("after_start", task_json_path, repo_root)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
active = set_active_task(task_dir, repo_root)
|
||||||
|
if active:
|
||||||
|
print(colored(f"✓ Current task set to: {task_dir}", Colors.GREEN))
|
||||||
|
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))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(colored("The hook will now inject context from this task's jsonl files.", Colors.BLUE))
|
||||||
|
|
||||||
|
run_task_hooks("after_start", task_json_path, repo_root)
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
print(colored("Error: Failed to set current task", Colors.RED))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_finish(args: argparse.Namespace) -> int:
|
||||||
|
"""Clear active task."""
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
active = clear_active_task(repo_root)
|
||||||
|
current = active.task_path
|
||||||
|
|
||||||
|
if not current:
|
||||||
|
print(colored("No current task set", Colors.YELLOW))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Resolve task.json path before clearing
|
||||||
|
task_json_path = repo_root / current / FILE_TASK_JSON
|
||||||
|
|
||||||
|
print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN))
|
||||||
|
print(f"Source: {active.source}")
|
||||||
|
|
||||||
|
if task_json_path.is_file():
|
||||||
|
run_task_hooks("after_finish", task_json_path, repo_root)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_current(args: argparse.Namespace) -> int:
|
||||||
|
"""Show active task."""
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
active = resolve_active_task(repo_root)
|
||||||
|
|
||||||
|
if getattr(args, "json", False):
|
||||||
|
task_obj = None
|
||||||
|
if active.task_path:
|
||||||
|
data = read_json(repo_root / active.task_path / FILE_TASK_JSON) or {}
|
||||||
|
task_obj = {
|
||||||
|
"dir": active.task_path,
|
||||||
|
"id": data.get("id") or data.get("name"),
|
||||||
|
"title": data.get("title"),
|
||||||
|
"status": data.get("status"),
|
||||||
|
"parent": data.get("parent"),
|
||||||
|
"children": data.get("children", []),
|
||||||
|
"branch": data.get("branch"),
|
||||||
|
"base_branch": data.get("base_branch"),
|
||||||
|
}
|
||||||
|
print(json.dumps({
|
||||||
|
"current_task": task_obj,
|
||||||
|
"source": active.source,
|
||||||
|
"stale": active.stale,
|
||||||
|
}, ensure_ascii=False))
|
||||||
|
return 0 if active.task_path else 1
|
||||||
|
|
||||||
|
if args.source:
|
||||||
|
print(f"Current task: {active.task_path or '(none)'}")
|
||||||
|
print(f"Source: {active.source}")
|
||||||
|
if active.stale:
|
||||||
|
print("State: stale")
|
||||||
|
return 0 if active.task_path else 1
|
||||||
|
|
||||||
|
if active.task_path:
|
||||||
|
print(active.task_path)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Command: list
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _display_status(t, all_statuses: dict) -> str:
|
||||||
|
"""Return the status label to show for a task in `list` output.
|
||||||
|
|
||||||
|
A parent task's stored status stays "planning" until someone runs
|
||||||
|
`task.py start` on the parent directly, even while its children are
|
||||||
|
actively being worked — a misleading label for anyone scanning the
|
||||||
|
list (#399 item 3). Show "active" instead when at least one child is
|
||||||
|
past planning; the stored status.json value is left untouched.
|
||||||
|
"""
|
||||||
|
if t.status == "planning" and t.children:
|
||||||
|
child_in_flight = any(
|
||||||
|
all_statuses.get(c) not in (None, "planning") for c in t.children
|
||||||
|
)
|
||||||
|
if child_in_flight:
|
||||||
|
return "active"
|
||||||
|
return t.status
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list(args: argparse.Namespace) -> int:
|
||||||
|
"""List active tasks."""
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
tasks_dir = get_tasks_dir(repo_root)
|
||||||
|
current_task = get_current_task(repo_root)
|
||||||
|
developer = get_developer(repo_root)
|
||||||
|
filter_mine = args.mine
|
||||||
|
filter_status = args.status
|
||||||
|
as_json = getattr(args, "json", False)
|
||||||
|
|
||||||
|
# Single pass: collect all tasks via shared iterator
|
||||||
|
all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)}
|
||||||
|
all_statuses = {name: t.status for name, t in all_tasks.items()}
|
||||||
|
|
||||||
|
if as_json:
|
||||||
|
if filter_mine and not developer:
|
||||||
|
print(json.dumps({"error": "No developer set"}), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for dir_name in sorted(all_tasks.keys()):
|
||||||
|
t = all_tasks[dir_name]
|
||||||
|
if filter_mine and (t.assignee or "-") != developer:
|
||||||
|
continue
|
||||||
|
if filter_status and t.status != filter_status:
|
||||||
|
continue
|
||||||
|
items.append({
|
||||||
|
"dir": f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}",
|
||||||
|
"id": t.raw.get("id") or dir_name,
|
||||||
|
"title": t.title,
|
||||||
|
"status": t.status,
|
||||||
|
"display_status": _display_status(t, all_statuses),
|
||||||
|
"priority": t.priority,
|
||||||
|
"assignee": t.assignee or None,
|
||||||
|
"parent": t.parent,
|
||||||
|
"children": list(t.children),
|
||||||
|
"package": t.package,
|
||||||
|
})
|
||||||
|
print(json.dumps({"tasks": items}, ensure_ascii=False))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if filter_mine:
|
||||||
|
if not developer:
|
||||||
|
print(colored("Error: No developer set. Run init_developer.py first", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(colored(f"My tasks (assignee: {developer}):", Colors.BLUE))
|
||||||
|
else:
|
||||||
|
print(colored("All active tasks:", Colors.BLUE))
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Display tasks hierarchically
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
def _print_task(dir_name: str, indent: int = 0) -> None:
|
||||||
|
nonlocal count
|
||||||
|
t = all_tasks[dir_name]
|
||||||
|
|
||||||
|
# Apply --mine filter
|
||||||
|
if filter_mine and (t.assignee or "-") != developer:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Apply --status filter
|
||||||
|
if filter_status and t.status != filter_status:
|
||||||
|
return
|
||||||
|
|
||||||
|
relative_path = f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}"
|
||||||
|
marker = ""
|
||||||
|
if relative_path == current_task:
|
||||||
|
marker = f" {colored('<- current', Colors.GREEN)}"
|
||||||
|
|
||||||
|
# Children progress
|
||||||
|
progress = children_progress(t.children, all_statuses)
|
||||||
|
status_label = _display_status(t, all_statuses)
|
||||||
|
|
||||||
|
# Package tag
|
||||||
|
pkg_tag = f" @{t.package}" if t.package else ""
|
||||||
|
|
||||||
|
prefix = " " * indent + " - "
|
||||||
|
|
||||||
|
if filter_mine:
|
||||||
|
print(f"{prefix}{dir_name}/ ({status_label}){pkg_tag}{progress}{marker}")
|
||||||
|
else:
|
||||||
|
print(f"{prefix}{dir_name}/ ({status_label}){pkg_tag}{progress} [{colored(t.assignee or '-', Colors.CYAN)}]{marker}")
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
# Print children indented
|
||||||
|
for child_name in t.children:
|
||||||
|
if child_name in all_tasks:
|
||||||
|
_print_task(child_name, indent + 1)
|
||||||
|
|
||||||
|
# Display only top-level tasks: those without a parent, plus orphans
|
||||||
|
# whose recorded parent is not (or no longer) in the active set — a
|
||||||
|
# dangling parent ref must still render flat instead of disappearing.
|
||||||
|
for dir_name in sorted(all_tasks.keys()):
|
||||||
|
parent = all_tasks[dir_name].parent
|
||||||
|
if not parent or parent not in all_tasks:
|
||||||
|
_print_task(dir_name)
|
||||||
|
|
||||||
|
if count == 0:
|
||||||
|
if filter_mine:
|
||||||
|
print(" (no tasks assigned to you)")
|
||||||
|
else:
|
||||||
|
print(" (no active tasks)")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"Total: {count} task(s)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Command: list-archive
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def cmd_list_archive(args: argparse.Namespace) -> int:
|
||||||
|
"""List archived tasks."""
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
tasks_dir = get_tasks_dir(repo_root)
|
||||||
|
archive_dir = tasks_dir / "archive"
|
||||||
|
month = args.month
|
||||||
|
|
||||||
|
print(colored("Archived tasks:", Colors.BLUE))
|
||||||
|
print()
|
||||||
|
|
||||||
|
if month:
|
||||||
|
month_dir = archive_dir / month
|
||||||
|
if month_dir.is_dir():
|
||||||
|
print(f"[{month}]")
|
||||||
|
for d in sorted(month_dir.iterdir()):
|
||||||
|
if d.is_dir():
|
||||||
|
print(f" - {d.name}/")
|
||||||
|
else:
|
||||||
|
print(f" No archives for {month}")
|
||||||
|
else:
|
||||||
|
if archive_dir.is_dir():
|
||||||
|
for month_dir in sorted(archive_dir.iterdir()):
|
||||||
|
if month_dir.is_dir():
|
||||||
|
month_name = month_dir.name
|
||||||
|
count = sum(1 for d in month_dir.iterdir() if d.is_dir())
|
||||||
|
print(f"[{month_name}] - {count} task(s)")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Help
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def show_usage() -> None:
|
||||||
|
"""Show usage help."""
|
||||||
|
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 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 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 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
|
||||||
|
python3 task.py list [--mine] [--status <status>] [--json] List tasks
|
||||||
|
python3 task.py list-archive [YYYY-MM] List archived tasks
|
||||||
|
|
||||||
|
Monorepo options:
|
||||||
|
--package <pkg> Package name (validated against config.yaml packages)
|
||||||
|
|
||||||
|
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 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 archive add-login
|
||||||
|
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
|
||||||
|
python3 task.py list --mine # List my tasks only
|
||||||
|
python3 task.py list --mine --status in_progress # List my in-progress tasks
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Main Entry
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""CLI entry point."""
|
||||||
|
# Deprecation guard: `init-context` was removed in v0.5.0-beta.12.
|
||||||
|
# Detect early so argparse doesn't mask the real reason with a generic
|
||||||
|
# "invalid choice" error.
|
||||||
|
if len(sys.argv) >= 2 and sys.argv[1] == "init-context":
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
"Error: `task.py init-context` was removed in v0.5.0-beta.12.",
|
||||||
|
Colors.RED,
|
||||||
|
),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"implement.jsonl / check.jsonl are now seeded on `task.py create` for",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"sub-agent-capable platforms and curated by the AI during planning when needed.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print("See .trellis/workflow.md planning artifact guidance or run:", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
" python3 ./.trellis/scripts/get_context.py --mode phase --step 1",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"Use `task.py add-context <dir> implement|check <path> <reason>` to append entries.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Task Management Script",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
|
subparsers = parser.add_subparsers(dest="command", help="Commands")
|
||||||
|
|
||||||
|
# create
|
||||||
|
p_create = subparsers.add_parser("create", help="Create new task")
|
||||||
|
p_create.add_argument("title", help="Task title")
|
||||||
|
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("--parent", help="Parent task directory (establishes subtask link)")
|
||||||
|
p_create.add_argument("--package", help="Package name for monorepo projects")
|
||||||
|
p_create.add_argument(
|
||||||
|
"--base-branch",
|
||||||
|
help="PR target branch (overrides origin/HEAD detection and the checked-out-branch fallback)",
|
||||||
|
)
|
||||||
|
p_create.add_argument(
|
||||||
|
"--meta",
|
||||||
|
action="append",
|
||||||
|
help="Task metadata key=value (repeatable)",
|
||||||
|
)
|
||||||
|
p_create.add_argument(
|
||||||
|
"--no-start",
|
||||||
|
action="store_true",
|
||||||
|
help="Create the task without making it active in this session",
|
||||||
|
)
|
||||||
|
|
||||||
|
# add-context
|
||||||
|
p_add = subparsers.add_parser("add-context", help="Add context entry")
|
||||||
|
p_add.add_argument("dir", help="Task directory")
|
||||||
|
p_add.add_argument("file", help="JSONL file (implement|check)")
|
||||||
|
p_add.add_argument("path", help="File path to add")
|
||||||
|
p_add.add_argument("reason", nargs="?", help="Reason for adding")
|
||||||
|
|
||||||
|
# validate
|
||||||
|
p_validate = subparsers.add_parser("validate", help="Validate context files")
|
||||||
|
p_validate.add_argument("dir", help="Task directory")
|
||||||
|
|
||||||
|
# list-context
|
||||||
|
p_listctx = subparsers.add_parser("list-context", help="List context entries")
|
||||||
|
p_listctx.add_argument("dir", help="Task directory")
|
||||||
|
|
||||||
|
# start
|
||||||
|
p_start = subparsers.add_parser("start", help="Set active task")
|
||||||
|
p_start.add_argument("dir", help="Task directory")
|
||||||
|
|
||||||
|
# current
|
||||||
|
p_current = subparsers.add_parser("current", help="Show active task")
|
||||||
|
p_current.add_argument("--source", action="store_true",
|
||||||
|
help="Show active task source")
|
||||||
|
p_current.add_argument("--json", action="store_true",
|
||||||
|
help="Output machine-readable JSON")
|
||||||
|
|
||||||
|
# finish
|
||||||
|
subparsers.add_parser("finish", help="Clear active task")
|
||||||
|
|
||||||
|
# set-branch
|
||||||
|
p_branch = subparsers.add_parser("set-branch", help="Set git branch")
|
||||||
|
p_branch.add_argument("dir", help="Task directory")
|
||||||
|
p_branch.add_argument("branch", help="Branch name")
|
||||||
|
|
||||||
|
# set-base-branch
|
||||||
|
p_base = subparsers.add_parser("set-base-branch", help="Set PR target branch")
|
||||||
|
p_base.add_argument("dir", help="Task directory")
|
||||||
|
p_base.add_argument("base_branch", help="Base branch name (PR target)")
|
||||||
|
|
||||||
|
# set-scope
|
||||||
|
p_scope = subparsers.add_parser("set-scope", help="Set scope")
|
||||||
|
p_scope.add_argument("dir", help="Task directory")
|
||||||
|
p_scope.add_argument("scope", help="Scope name")
|
||||||
|
|
||||||
|
# set-meta
|
||||||
|
p_setmeta = subparsers.add_parser("set-meta", help="Set/overwrite a task metadata key")
|
||||||
|
p_setmeta.add_argument("dir", help="Task directory")
|
||||||
|
p_setmeta.add_argument("key", help="Metadata key")
|
||||||
|
p_setmeta.add_argument("value", help="Metadata value")
|
||||||
|
|
||||||
|
# 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")
|
||||||
|
|
||||||
|
# list
|
||||||
|
p_list = subparsers.add_parser("list", help="List tasks")
|
||||||
|
p_list.add_argument("--mine", "-m", action="store_true", help="My tasks only")
|
||||||
|
p_list.add_argument("--status", "-s", help="Filter by status")
|
||||||
|
p_list.add_argument("--json", action="store_true", help="Output machine-readable JSON")
|
||||||
|
|
||||||
|
# add-subtask
|
||||||
|
p_addsub = subparsers.add_parser("add-subtask", help="Link child task to parent")
|
||||||
|
p_addsub.add_argument("parent_dir", help="Parent task directory")
|
||||||
|
p_addsub.add_argument("child_dir", help="Child task directory")
|
||||||
|
|
||||||
|
# remove-subtask
|
||||||
|
p_rmsub = subparsers.add_parser("remove-subtask", help="Unlink child task from parent")
|
||||||
|
p_rmsub.add_argument("parent_dir", help="Parent task directory")
|
||||||
|
p_rmsub.add_argument("child_dir", help="Child task directory")
|
||||||
|
|
||||||
|
# list-archive
|
||||||
|
p_listarch = subparsers.add_parser("list-archive", help="List archived tasks")
|
||||||
|
p_listarch.add_argument("month", nargs="?", help="Month (YYYY-MM)")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.command:
|
||||||
|
show_usage()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
commands = {
|
||||||
|
"create": cmd_create,
|
||||||
|
"add-context": cmd_add_context,
|
||||||
|
"validate": cmd_validate,
|
||||||
|
"list-context": cmd_list_context,
|
||||||
|
"start": cmd_start,
|
||||||
|
"current": cmd_current,
|
||||||
|
"finish": cmd_finish,
|
||||||
|
"set-branch": cmd_set_branch,
|
||||||
|
"set-base-branch": cmd_set_base_branch,
|
||||||
|
"set-scope": cmd_set_scope,
|
||||||
|
"set-meta": cmd_set_meta,
|
||||||
|
"archive": cmd_archive,
|
||||||
|
"add-subtask": cmd_add_subtask,
|
||||||
|
"remove-subtask": cmd_remove_subtask,
|
||||||
|
"list": cmd_list,
|
||||||
|
"list-archive": cmd_list_archive,
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.command in commands:
|
||||||
|
return commands[args.command](args)
|
||||||
|
else:
|
||||||
|
show_usage()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -37,6 +37,8 @@ type RoadConstraintKind =
|
|||||||
| 'junction-approach-width'
|
| 'junction-approach-width'
|
||||||
| 'junction-cutback'
|
| 'junction-cutback'
|
||||||
| 'junction-corner-radius'
|
| 'junction-corner-radius'
|
||||||
|
| 'junction-crosswalk-inset'
|
||||||
|
| 'junction-stop-line-offset'
|
||||||
```
|
```
|
||||||
|
|
||||||
| kind | 锚点 | value | 单位与含义 | 所有者 |
|
| kind | 锚点 | value | 单位与含义 | 所有者 |
|
||||||
@@ -47,8 +49,18 @@ type RoadConstraintKind =
|
|||||||
| `junction-approach-width` | `junction-approach` | `{ widthMeters }` | 米 | JunctionTools |
|
| `junction-approach-width` | `junction-approach` | `{ widthMeters }` | 米 | JunctionTools |
|
||||||
| `junction-cutback` | `junction-approach` | `{ cutbackMeters }` | 米 | JunctionTools |
|
| `junction-cutback` | `junction-approach` | `{ cutbackMeters }` | 米 | JunctionTools |
|
||||||
| `junction-corner-radius` | `junction-corner` | `{ radiusMeters }` | 米 | JunctionTools |
|
| `junction-corner-radius` | `junction-corner` | `{ radiusMeters }` | 米 | JunctionTools |
|
||||||
|
| `junction-crosswalk-inset` | `junction-approach` | `{ insetMeters }` | 米,斑马线距路口边界的退让 | 主地图 / JunctionTools |
|
||||||
|
| `junction-stop-line-offset` | `junction-approach` | `{ offsetMeters }` | 米,停止线沿进口后退的距离 | 主地图 / JunctionTools |
|
||||||
|
|
||||||
这 6 个 kind 与 PRD 首期范围一一对应(外缘、步行带、车道分隔 + 进口、cutback、角部),没有多余项也没有缺口。
|
前 6 个 kind 对应 PRD 首期范围(外缘、步行带、车道分隔 + 进口、cutback、角部)。
|
||||||
|
|
||||||
|
后 2 个于 2026-08-28 追加,是「更多元素可直接操纵」方向的第一类扩展:控制标线的**位置**
|
||||||
|
成为可编辑参数。它们不改变路口形状,只移动标线,因此不触发路口边界重算。
|
||||||
|
放置是否成立的判据(`STOP_LINE_MAX_APPROACH_DISTANCE_METERS` 等)不在此范围内。
|
||||||
|
|
||||||
|
新增 kind 的前提:**几何阶段必须已经消费它**。本项目曾为 `profile.interval` 交付过
|
||||||
|
一个拖动无效果的手柄(见 `08-26-direct-edit-map-editor/research/interval-not-applied.md`),
|
||||||
|
所以扩展 kind 时先证明 `compileGeometry()` 读取该参数,再画任何手柄。
|
||||||
|
|
||||||
`transition: 'smoothstep' | 'linear'`,默认 `smoothstep`,作用于 interval 两端回归基线的过渡段。
|
`transition: 'smoothstep' | 'linear'`,默认 `smoothstep`,作用于 interval 两端回归基线的过渡段。
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,9 @@
|
|||||||
"08-26-direct-edit-solver-api",
|
"08-26-direct-edit-solver-api",
|
||||||
"08-26-direct-edit-map-editor",
|
"08-26-direct-edit-map-editor",
|
||||||
"08-26-direct-edit-junction-tools",
|
"08-26-direct-edit-junction-tools",
|
||||||
"08-27-junction-dominated-roads"
|
"08-27-junction-dominated-roads",
|
||||||
|
"08-28-edit-interaction-polish",
|
||||||
|
"08-28-junction-control-offsets"
|
||||||
],
|
],
|
||||||
"parent": null,
|
"parent": null,
|
||||||
"relatedFiles": [
|
"relatedFiles": [
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||||
91
.trellis/tasks/08-28-junction-control-offsets/prd.md
Normal file
91
.trellis/tasks/08-28-junction-control-offsets/prd.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# 路口控制标线偏移:斑马线与停止线可拖
|
||||||
|
|
||||||
|
父任务:`.trellis/tasks/08-26-direct-manipulation-road-editor`。
|
||||||
|
这是「更多元素可直接操纵」方向的第一个元素类型。
|
||||||
|
|
||||||
|
## 为什么先做这一类
|
||||||
|
|
||||||
|
`08-26-direct-edit-map-editor` 交付了完整的直接操纵链路——
|
||||||
|
manifest → 命中 → 拖拽 → 投影 → 钳位 → ghost → 防抖 → 乱序仲裁 → 预览 → 保存 → 撤销。
|
||||||
|
这条链路**不认识具体元素**,只认 `kind + anchor + axis + value`。所以加一类元素
|
||||||
|
本应是「定义一个 kind + 一个写入器」,而不是造新子系统。
|
||||||
|
|
||||||
|
选斑马线/停止线打头,因为可行性已实测确认(2026-08-28):
|
||||||
|
|
||||||
|
```js
|
||||||
|
611 model = modelWithDirectEditProfiles(model, options.directEdit);
|
||||||
|
613 const junctionPlans = compileJunctionPlans(model, options, diagnostics);
|
||||||
|
614 applyDirectJunctionPlans(junctionPlans, options.directEdit); // 直接编辑已合并
|
||||||
|
768 const controls = compileControlMarkings(model, lanes, diagnostics, junctionPlans);
|
||||||
|
1299 const plan = junctionPlans.get(junctionNodeId); // 已按路口取到 plan
|
||||||
|
```
|
||||||
|
|
||||||
|
junction 级约束**已经能流到控制标线的生成**,管线是通的。挡路的只是模块级常量:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const STOP_LINE_OFFSET_METERS = 2.7;
|
||||||
|
const CROSSWALK_JUNCTION_INSET_METERS = 1.5;
|
||||||
|
```
|
||||||
|
|
||||||
|
它们全局一个值、不按进口区分。把常量换成「先查进口配置,没有再用默认」即可。
|
||||||
|
|
||||||
|
这与区间那件事性质不同:区间要求横断面**沿路变化**,是几何管线重构;这里只是
|
||||||
|
在已经拿到手的 `plan` 上多读一个字段。
|
||||||
|
|
||||||
|
## 强制顺序:编译器优先,UI 最后
|
||||||
|
|
||||||
|
`08-26-direct-edit-map-editor` 交付了一个拖了没有任何效果的区间手柄,因为
|
||||||
|
`compileGeometry()` 从不读 `profile.interval`——UI 先于消费者建成。
|
||||||
|
见 `research/interval-not-applied.md`。
|
||||||
|
|
||||||
|
本任务反过来:**第 1 步不产出任何 UI**,只证明编译器真的消费这个参数。
|
||||||
|
第 1 步的门禁不过就停,不进入第 2、3 步。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
### 1. 服务端打通并证明(无 UI)
|
||||||
|
|
||||||
|
- 新增两个约束 kind:`junction-crosswalk-inset`、`junction-stop-line-offset`,
|
||||||
|
锚点均为 `junction-approach`(求解器已建模该锚点)。
|
||||||
|
- `native-road-edits.js`:schema 校验与取值范围。
|
||||||
|
- `direct-edit-solver.js`:`applyJunctionConstraint()` 把值写进该进口的 plan 条目。
|
||||||
|
- `native-road.js`:`compileControlMarkings()` 读进口配置,缺省时退回既有常量。
|
||||||
|
|
||||||
|
### 2. 手柄目标泛化
|
||||||
|
|
||||||
|
`EditHandle` 声明「一次手势写到哪里」,客户端在提交处分岔。
|
||||||
|
验收标准是:**加第 N+1 种元素时不需要修改任何已有<E5B7B2><E69C89>柄代码**。
|
||||||
|
|
||||||
|
### 3. 手柄与拖拽
|
||||||
|
|
||||||
|
选中路口后斑马线、停止线上出现手柄,沿进口方向拖动调整其距路口的距离。
|
||||||
|
复用既有链路,不新增预览或保存机制。
|
||||||
|
|
||||||
|
## 不做
|
||||||
|
|
||||||
|
- 不做红绿灯(属「有位姿的实体资产」,需要不同的写入器与相对锚点)
|
||||||
|
- 不做箭头(需先定交互语义:拖箭头应改行驶动作,而非移动图标)
|
||||||
|
- 不做区间生效,方块手柄继续隐藏
|
||||||
|
- 不改 `STOP_LINE_MAX_APPROACH_DISTANCE_METERS` 等与放置**是否成立**有关的判据,
|
||||||
|
只改放置**位置**
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
### 第 1 步(门禁,不过则停)
|
||||||
|
|
||||||
|
- [ ] 带 `junction-crosswalk-inset` 约束编译出的斑马线坐标,与不带时**不同**
|
||||||
|
- [ ] 带 `junction-stop-line-offset` 约束编译出的停止线坐标,与不带时**不同**
|
||||||
|
- [ ] 无约束时输出与当前 main **逐字节相同**(fixture 基线无 diff)
|
||||||
|
- [ ] 越界取值产生阻塞诊断,不静默钳制
|
||||||
|
- [ ] 约束经保存与重新加载后状态为 `exact`
|
||||||
|
|
||||||
|
### 第 2、3 步
|
||||||
|
|
||||||
|
- [ ] 选中路口后斑马线与停止线出现手柄,拖动可改变其距路口距离
|
||||||
|
- [ ] 手柄位置由钳位值反算,拖到极限停在边界
|
||||||
|
- [ ] 新增元素类型未修改既有手柄代码(以 diff 佐证)
|
||||||
|
- [ ] `npm run format:check`、`npm run test`、`npm run test:client`、`npm run test:client:unit`、`npm run build` 全绿
|
||||||
|
|
||||||
|
## 顺序依赖
|
||||||
|
|
||||||
|
无前置。后继:红绿灯位姿与箭头语义,二者都依赖第 2 步的目标泛化。
|
||||||
26
.trellis/tasks/08-28-junction-control-offsets/task.json
Normal file
26
.trellis/tasks/08-28-junction-control-offsets/task.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"id": "junction-control-offsets",
|
||||||
|
"name": "junction-control-offsets",
|
||||||
|
"title": "路口控制标线偏移:斑马线与停止线可拖",
|
||||||
|
"description": "",
|
||||||
|
"status": "in_progress",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": null,
|
||||||
|
"package": null,
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "dingkang",
|
||||||
|
"assignee": "dingkang",
|
||||||
|
"createdAt": "2026-08-28",
|
||||||
|
"completedAt": null,
|
||||||
|
"branch": null,
|
||||||
|
"base_branch": "main",
|
||||||
|
"worktree_path": null,
|
||||||
|
"commit": null,
|
||||||
|
"pr_url": null,
|
||||||
|
"subtasks": [],
|
||||||
|
"children": [],
|
||||||
|
"parent": "08-26-direct-manipulation-road-editor",
|
||||||
|
"relatedFiles": [],
|
||||||
|
"notes": "",
|
||||||
|
"meta": {}
|
||||||
|
}
|
||||||
709
.trellis/workflow.md
Normal file
709
.trellis/workflow.md
Normal file
@@ -0,0 +1,709 @@
|
|||||||
|
# Development Workflow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
1. **Plan before code** — figure out what to do before you start
|
||||||
|
2. **Specs injected, not remembered** — guidelines are injected via hook/skill, not recalled from memory
|
||||||
|
3. **Persist everything** — research, decisions, and lessons all go to files; conversations get compacted, files don't
|
||||||
|
4. **Incremental development** — one task at a time
|
||||||
|
5. **Capture learnings** — after each task, review and write new knowledge back to spec
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Trellis System
|
||||||
|
|
||||||
|
### Developer Identity
|
||||||
|
|
||||||
|
On first use, initialize your identity:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./.trellis/scripts/init_developer.py <your-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates `.trellis/.developer` (gitignored) + `.trellis/workspace/<your-name>/`.
|
||||||
|
|
||||||
|
### Spec System
|
||||||
|
|
||||||
|
`.trellis/spec/` holds coding guidelines organized by package and layer.
|
||||||
|
|
||||||
|
- `.trellis/spec/<package>/<layer>/index.md` — entry point with **Pre-Development Checklist** + **Quality Check**. Actual guidelines live in the `.md` files it points to.
|
||||||
|
- `.trellis/spec/guides/index.md` — cross-package thinking guides.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./.trellis/scripts/get_context.py --mode packages # list packages / layers
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to update spec**: new pattern/convention found · bug-fix prevention to codify · new technical decision.
|
||||||
|
|
||||||
|
### Task System
|
||||||
|
|
||||||
|
Every task has its own directory under `.trellis/tasks/{MM-DD-name}/` holding `task.json`, `prd.md`, optional `design.md`, optional `implement.md`, optional `research/`, and context manifests (`implement.jsonl`, `check.jsonl`) for sub-agent-capable platforms.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Task lifecycle
|
||||||
|
python3 ./.trellis/scripts/task.py create "<title>" [--slug <name>] [--parent <dir>]
|
||||||
|
python3 ./.trellis/scripts/task.py start <name> # set active task (session-scoped when available)
|
||||||
|
python3 ./.trellis/scripts/task.py current --source # show active task and source
|
||||||
|
python3 ./.trellis/scripts/task.py finish # clear active task (triggers after_finish hooks)
|
||||||
|
python3 ./.trellis/scripts/task.py archive <name> # move to archive/{year-month}/
|
||||||
|
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.
|
||||||
|
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>
|
||||||
|
|
||||||
|
# Task metadata
|
||||||
|
python3 ./.trellis/scripts/task.py set-branch <name> <branch>
|
||||||
|
python3 ./.trellis/scripts/task.py set-base-branch <name> <branch> # PR target
|
||||||
|
python3 ./.trellis/scripts/task.py set-scope <name> <scope>
|
||||||
|
|
||||||
|
# Hierarchy (parent/child)
|
||||||
|
python3 ./.trellis/scripts/task.py add-subtask <parent> <child>
|
||||||
|
python3 ./.trellis/scripts/task.py remove-subtask <parent> <child>
|
||||||
|
|
||||||
|
# PR creation
|
||||||
|
python3 ./.trellis/scripts/task.py create-pr [name] [--dry-run]
|
||||||
|
```
|
||||||
|
|
||||||
|
> Run `python3 ./.trellis/scripts/task.py --help` to see the authoritative, up-to-date list.
|
||||||
|
|
||||||
|
**Current-task mechanism**: `task.py create` creates the task directory and (when session identity is available) auto-sets the per-session active-task pointer so the planning breadcrumb fires immediately. `task.py start` writes the same pointer (idempotent if already set) and flips `task.json.status` from `planning` to `in_progress`. State is stored under `.trellis/.runtime/sessions/`. If no context key is available from hook input, `TRELLIS_CONTEXT_ID`, or a platform-native session environment variable, there is no active task and `task.py start` fails with a session identity hint. `task.py finish` deletes the current session file (status unchanged). `task.py archive <task>` writes `status=completed`, moves the directory to `archive/`, and deletes any runtime session files that still point at the archived task.
|
||||||
|
|
||||||
|
### Workspace System
|
||||||
|
|
||||||
|
Records every AI session for cross-session tracking under `.trellis/workspace/<developer>/`.
|
||||||
|
|
||||||
|
- `journal-N.md` — session log. **Max 2000 lines per file**; a new `journal-(N+1).md` is auto-created when exceeded.
|
||||||
|
- `index.md` — personal index (total sessions, last active).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./.trellis/scripts/add_session.py --title "Title" --commit "hash" --summary "Summary"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Context Script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./.trellis/scripts/get_context.py # full session runtime
|
||||||
|
python3 ./.trellis/scripts/get_context.py --mode packages # available packages + spec layers
|
||||||
|
python3 ./.trellis/scripts/get_context.py --mode phase --step <X.Y> # detailed guide for a workflow step
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!--
|
||||||
|
WORKFLOW-STATE BREADCRUMB CONTRACT (read this before editing the tag blocks below)
|
||||||
|
|
||||||
|
The [workflow-state:STATUS] blocks embedded in the ## Phase Index section
|
||||||
|
below are the SINGLE source of truth for the per-turn `<workflow-state>`
|
||||||
|
breadcrumb that every supported AI platform's UserPromptSubmit hook
|
||||||
|
reads. inject-workflow-state.py (Python platforms) and
|
||||||
|
inject-workflow-state.js (OpenCode plugin) only parse them — there is no
|
||||||
|
fallback dict baked into the scripts after v0.5.0-rc.0.
|
||||||
|
|
||||||
|
STATUS charset: [A-Za-z0-9_-]+. When the hook can't find a tag, it
|
||||||
|
degrades to a generic "Refer to workflow.md for current step." line —
|
||||||
|
intentionally visible so users notice and fix a broken workflow.md.
|
||||||
|
|
||||||
|
INVARIANT (test/regression.test.ts):
|
||||||
|
Every workflow-walkthrough step marked `[required · once]` must have a
|
||||||
|
matching enforcement line in its phase's [workflow-state:*] block. The
|
||||||
|
breadcrumb is the only per-turn channel; if a mandatory step isn't
|
||||||
|
mentioned there, the AI silently skips it (Phase 1 planning gate
|
||||||
|
skip and Phase 3.4 commit skip both manifested via this gap).
|
||||||
|
|
||||||
|
TAG ↔ PHASE scoping:
|
||||||
|
[workflow-state:no_task] → no active task; before Phase 1
|
||||||
|
[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
|
||||||
|
(status stays 'in_progress' from
|
||||||
|
task.py start until task.py archive)
|
||||||
|
[workflow-state:in_progress-inline] → Codex inline variant of Phase 2/3
|
||||||
|
[workflow-state:completed] → currently DEAD: cmd_archive flips
|
||||||
|
status and moves the dir in the same
|
||||||
|
call, so the resolver loses the
|
||||||
|
pointer (block kept for a future
|
||||||
|
explicit in_progress→completed
|
||||||
|
transition)
|
||||||
|
|
||||||
|
Editing checklist:
|
||||||
|
- When you change a [workflow-state:STATUS] block, also check the
|
||||||
|
matching phase's `[required · once]` walkthrough steps for sync
|
||||||
|
- Run `trellis update` after editing to push the new bodies to
|
||||||
|
downstream user projects (block-level managed replacement)
|
||||||
|
- Full runtime contract:
|
||||||
|
.trellis/spec/cli/backend/workflow-state-contract.md
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Phase Index
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1: Plan → classify, get task-creation consent, then write planning artifacts
|
||||||
|
Phase 2: Execute → implement only after task status is in_progress
|
||||||
|
Phase 3: Finish → verify, update spec, commit, and wrap up
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request Triage
|
||||||
|
|
||||||
|
- Simple conversation or small task: ask only whether this turn should create a Trellis task. If the user says no, skip Trellis for this session.
|
||||||
|
- Complex task: ask whether you may create a Trellis task and enter planning. If the user says no, do not do broad inline implementation; explain, clarify scope, or suggest a smaller split.
|
||||||
|
- User approval to create a task is not approval to start implementation. Planning still happens first.
|
||||||
|
|
||||||
|
### Planning Artifacts
|
||||||
|
|
||||||
|
- `prd.md` — requirements, constraints, and acceptance criteria. Do not put technical design or execution checklists here.
|
||||||
|
- `design.md` — technical design for complex tasks: boundaries, contracts, data flow, tradeoffs, compatibility, rollout / rollback shape.
|
||||||
|
- `implement.md` — execution plan for complex tasks: ordered checklist, validation commands, review gates, and rollback points.
|
||||||
|
- `implement.jsonl` / `check.jsonl` — spec and research manifests for sub-agent context. They do not replace `implement.md`.
|
||||||
|
- Lightweight tasks may be PRD-only. Complex tasks must have `prd.md`, `design.md`, and `implement.md` before `task.py start`.
|
||||||
|
|
||||||
|
### Parent / Child Task Trees
|
||||||
|
|
||||||
|
Use a parent task when one user request contains several independently verifiable deliverables. The parent task owns the source requirement set, the task map, cross-child acceptance criteria, and final integration review; it normally should not be the implementation target unless it also has direct work.
|
||||||
|
|
||||||
|
Use child tasks for deliverables that can be planned, implemented, checked, and archived independently. Parent/child structure is not a dependency system: if one child must wait for another, write that ordering in the child `prd.md` / `implement.md` and keep each child's acceptance criteria testable.
|
||||||
|
|
||||||
|
Create new children with `task.py create "<title>" --slug <name> --parent <parent-dir>`. Link existing tasks with `task.py add-subtask <parent> <child>`, and unlink mistakes with `task.py remove-subtask <parent> <child>`.
|
||||||
|
|
||||||
|
<!-- Per-turn breadcrumb: shown when there is no active task (before Phase 1) -->
|
||||||
|
|
||||||
|
[workflow-state:no_task]
|
||||||
|
No active task. First classify the current turn and ask for task-creation consent before creating any Trellis task.
|
||||||
|
Simple conversation / small task: ask only whether this turn should create a Trellis task. If the user says no, skip Trellis for this session.
|
||||||
|
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]
|
||||||
|
|
||||||
|
### 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`)
|
||||||
|
- 1.2 Research `[optional · repeatable]`
|
||||||
|
- 1.3 Configure context `[required · once]` — Claude Code, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Grok, Kimi Code (sub-agent-dispatch platforms only; inline platforms skip)
|
||||||
|
- 1.4 Activate task `[required · once]` (review gate, then `task.py start`; status → in_progress)
|
||||||
|
- 1.5 Completion criteria
|
||||||
|
|
||||||
|
<!-- Per-turn breadcrumb: shown throughout Phase 1 (status='planning') -->
|
||||||
|
|
||||||
|
[workflow-state:planning]
|
||||||
|
Load `trellis-brainstorm`; stay in planning.
|
||||||
|
Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`.
|
||||||
|
Multi-deliverable scope: consider a parent task plus independently verifiable child tasks; dependencies must be written in child artifacts, not implied by tree position.
|
||||||
|
Sub-agent mode: curate `implement.jsonl` and `check.jsonl` as spec/research manifests before start.
|
||||||
|
[/workflow-state:planning]
|
||||||
|
|
||||||
|
<!-- Per-turn breadcrumb: shown throughout Phase 1 when codex.dispatch_mode=inline.
|
||||||
|
Codex-only opt-in alternate to [workflow-state:planning]. The main agent
|
||||||
|
edits code directly in Phase 2, so jsonl curation is skipped —
|
||||||
|
the inline workflow loads `trellis-before-dev` instead of injecting JSONL
|
||||||
|
into a sub-agent. -->
|
||||||
|
|
||||||
|
[workflow-state:planning-inline]
|
||||||
|
Load `trellis-brainstorm`; stay in planning.
|
||||||
|
Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`.
|
||||||
|
Multi-deliverable scope: consider a parent task plus independently verifiable child tasks; dependencies must be written in child artifacts, not implied by tree position.
|
||||||
|
Inline mode: skip jsonl curation; Phase 2 reads artifacts/specs via `trellis-before-dev`.
|
||||||
|
[/workflow-state:planning-inline]
|
||||||
|
|
||||||
|
### Phase 2: Execute
|
||||||
|
- 2.1 Implement `[required · repeatable]`
|
||||||
|
- 2.2 Quality check `[required · repeatable]`
|
||||||
|
- 2.3 Rollback `[on demand]`
|
||||||
|
|
||||||
|
<!-- Per-turn breadcrumb: shown while status='in_progress'.
|
||||||
|
Scope: all of Phase 2 + Phase 3.2-3.4 (status stays 'in_progress' from
|
||||||
|
task.py start until task.py archive; only archive flips it). The body
|
||||||
|
therefore must cover every required step from implementation through
|
||||||
|
commit, including Phase 3.3 spec update and Phase 3.4 commit. -->
|
||||||
|
|
||||||
|
Sub-agent dispatch protocol applies to all platforms and all sub-agents, including native Codex `SubagentStart` context injection with child-side pull fallback, class-2 Gemini/Qoder/Copilot/Reasonix/Trae/Grok/Kimi Code, hook-backed ZCode/Snow, and `trellis-research`: every dispatch prompt starts with `Active task: <task path from task.py current>` before role-specific instructions. On Grok Build, use `spawn_subagent` with `subagent_type` set to the Trellis agent name (e.g. `trellis-implement`). On Kimi Code, dispatch the built-in `coder` / `explore` sub-agent with the matching `.kimi-code/skills/trellis-<role>/SKILL.md` instructions.
|
||||||
|
|
||||||
|
[workflow-state:in_progress]
|
||||||
|
Tools: `trellis-implement` / `trellis-research` are sub-agent types only (Task/Agent tool, NOT Skill; there is no skill by these names). `trellis-update-spec` is a skill. `trellis-check` exists as both; prefer the Agent form when verifying after code changes.
|
||||||
|
Flow: `trellis-implement` -> `trellis-check` -> `trellis-update-spec` -> commit (Phase 3.4) -> `/trellis:finish-work`.
|
||||||
|
Main-session default: dispatch implement/check sub-agents. Sub-agent self-exemption: if already running as `trellis-implement`, do NOT spawn another `trellis-implement` or `trellis-check`; if already running as `trellis-check`, do NOT spawn another `trellis-check` or `trellis-implement`. Dispatch is main session only.
|
||||||
|
Dispatch prompt starts with `Active task: <task path from task.py current>`. Read context: jsonl entries -> `prd.md` -> `design.md if present` -> `implement.md if present`.
|
||||||
|
[/workflow-state:in_progress]
|
||||||
|
|
||||||
|
<!-- Per-turn breadcrumb: shown while status='in_progress' when
|
||||||
|
codex.dispatch_mode=inline. Codex-only opt-in alternate to
|
||||||
|
[workflow-state:in_progress]. The main session edits code directly
|
||||||
|
instead of dispatching sub-agents. -->
|
||||||
|
|
||||||
|
[workflow-state:in_progress-inline]
|
||||||
|
Flow: `trellis-before-dev` -> edit -> `trellis-check` -> validation -> `trellis-update-spec` -> commit (Phase 3.4) -> `/trellis:finish-work`.
|
||||||
|
Do not dispatch implement/check sub-agents in inline mode.
|
||||||
|
Read context: `prd.md` -> `design.md if present` -> `implement.md if present`, plus relevant spec/research loaded by skills.
|
||||||
|
[/workflow-state:in_progress-inline]
|
||||||
|
|
||||||
|
### Phase 3: Finish
|
||||||
|
- 3.2 Debug retrospective `[on demand]`
|
||||||
|
- 3.3 Spec update `[required · once]`
|
||||||
|
- 3.4 Commit changes `[required · once]`
|
||||||
|
- 3.5 Wrap-up reminder
|
||||||
|
|
||||||
|
> Note: step 3.1 was folded into 2.2 (last-iteration full-scope check) and 3.4 (commit preamble). Numbering kept stable to avoid breaking external references.
|
||||||
|
|
||||||
|
<!-- Per-turn breadcrumb: shown while status='completed'.
|
||||||
|
Currently DEAD in normal flow: cmd_archive writes status='completed' in
|
||||||
|
the same call that moves the task dir to archive/, so the active-task
|
||||||
|
resolver loses the pointer and the hook never fires on archived tasks.
|
||||||
|
Block preserved for a future status-transition redesign (e.g. an
|
||||||
|
explicit in_progress→completed command). Edit through the same spec
|
||||||
|
channel as the live blocks. -->
|
||||||
|
|
||||||
|
[workflow-state:completed]
|
||||||
|
Code committed. Run `/trellis:finish-work`; if dirty, return to Phase 3.4 first.
|
||||||
|
[/workflow-state:completed]
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
1. Identify which Phase you're in, then continue from the next step there
|
||||||
|
2. Run steps in order inside each Phase; `[required]` steps can't be skipped
|
||||||
|
3. Phases can roll back (e.g., Execute reveals a prd defect → return to Plan to fix, then re-enter Execute)
|
||||||
|
4. Steps tagged `[once]` are skipped if the output already exists; don't re-run
|
||||||
|
5. Artifact presence informs the next step; missing `design.md` / `implement.md` is valid for lightweight tasks and incomplete planning for complex tasks.
|
||||||
|
|
||||||
|
### Active Task Routing
|
||||||
|
|
||||||
|
When a user request matches one of these intents inside an active task, route first, then load the detailed phase step if needed.
|
||||||
|
|
||||||
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||||
|
|
||||||
|
- Planning or unclear requirements -> `trellis-brainstorm`.
|
||||||
|
- `in_progress` implementation/check -> dispatch `trellis-implement` / `trellis-check`.
|
||||||
|
- Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`.
|
||||||
|
|
||||||
|
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||||
|
|
||||||
|
[codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness]
|
||||||
|
|
||||||
|
- Planning or unclear requirements -> `trellis-brainstorm`.
|
||||||
|
- Before editing -> `trellis-before-dev`; after editing -> `trellis-check`.
|
||||||
|
- Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`.
|
||||||
|
|
||||||
|
[/codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness]
|
||||||
|
|
||||||
|
### Guardrails
|
||||||
|
|
||||||
|
- Task creation approval is not implementation approval; implementation waits for `task.py start` after artifact review.
|
||||||
|
- PRD-only is valid for lightweight tasks; complex tasks need `design.md` + `implement.md`.
|
||||||
|
- Planning must be persisted to task artifacts; checks must run before reporting completion.
|
||||||
|
|
||||||
|
### Loading Step Detail
|
||||||
|
|
||||||
|
At each step, run this to fetch detailed guidance:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./.trellis/scripts/get_context.py --mode phase --step <step>
|
||||||
|
# e.g. python3 ./.trellis/scripts/get_context.py --mode phase --step 1.1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Plan
|
||||||
|
|
||||||
|
Goal: classify the request, get task-creation consent when a task is needed, and produce the planning artifacts required before implementation.
|
||||||
|
|
||||||
|
#### 1.0 Create task `[required · once]`
|
||||||
|
|
||||||
|
Create the task directory only after task-creation consent. The command sets status to `planning`, writes `task.json`, creates a default `prd.md`, and auto-targets the new task when session identity is available:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./.trellis/scripts/task.py create "<task title>" --slug <name>
|
||||||
|
```
|
||||||
|
|
||||||
|
`--slug` is the human-readable name only. Do **not** include the `MM-DD-` date prefix; `task.py create` adds that prefix automatically.
|
||||||
|
|
||||||
|
For task trees, create the parent task first and then create each child with `--parent <parent-dir>`. Do not start the parent just because children exist; start the child that owns the next independently verifiable deliverable.
|
||||||
|
|
||||||
|
After this command succeeds, the per-turn breadcrumb auto-switches to `[workflow-state:planning]`, telling the AI to stay in planning.
|
||||||
|
|
||||||
|
Run only `create` here — do not also run `start`. `start` flips status to `in_progress`, which switches the breadcrumb to the implementation phase before planning artifacts are reviewed. Save `start` for step 1.4.
|
||||||
|
|
||||||
|
Skip when `python3 ./.trellis/scripts/task.py current --source` already points to a task.
|
||||||
|
|
||||||
|
#### 1.1 Requirement exploration `[required · repeatable]`
|
||||||
|
|
||||||
|
Load the `trellis-brainstorm` skill and explore requirements interactively with the user per the skill's guidance.
|
||||||
|
|
||||||
|
The brainstorm skill will guide you to:
|
||||||
|
- Ask one question at a time
|
||||||
|
- Prefer researching over asking the user
|
||||||
|
- Prefer offering options over open-ended questions
|
||||||
|
- Update `prd.md` immediately after each user answer
|
||||||
|
- Split large scopes into a parent task plus child tasks when the deliverables can be verified independently
|
||||||
|
- Keep `prd.md` focused on requirements and acceptance criteria
|
||||||
|
- For complex tasks, produce `design.md` and `implement.md` before implementation starts
|
||||||
|
|
||||||
|
When considering a parent/child split:
|
||||||
|
- Use a parent task when one request contains several independently verifiable deliverables.
|
||||||
|
- Parent tasks own source requirements, child-task mapping, cross-child acceptance criteria, and final integration review.
|
||||||
|
- Child tasks own actual deliverables that can be planned, implemented, checked, and archived independently.
|
||||||
|
- Parent/child structure is not a dependency system. If child B depends on child A, write that ordering in child B's `prd.md` / `implement.md`.
|
||||||
|
- Start the child task that owns the next deliverable. Do not start the parent unless the parent itself has direct implementation work.
|
||||||
|
|
||||||
|
Return to this step whenever requirements change and revise the relevant artifact.
|
||||||
|
|
||||||
|
#### 1.2 Research `[optional · repeatable]`
|
||||||
|
|
||||||
|
Research can happen at any time during requirement exploration. It isn't limited to local code — you can use any available tool (MCP servers, skills, web search, etc.) to look up external information, including third-party library docs, industry practices, API references, etc.
|
||||||
|
|
||||||
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||||
|
|
||||||
|
Spawn the research sub-agent:
|
||||||
|
|
||||||
|
- **Agent type**: `trellis-research`
|
||||||
|
- **Task description**: Research <specific question>
|
||||||
|
- **Key requirement**: Research output MUST be persisted to `{TASK_DIR}/research/`
|
||||||
|
|
||||||
|
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||||
|
|
||||||
|
[codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness]
|
||||||
|
|
||||||
|
Do the research in the main session directly and write findings into `{TASK_DIR}/research/`. `codex-inline` is the explicit mode that keeps work in the main session.
|
||||||
|
|
||||||
|
[/codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness]
|
||||||
|
|
||||||
|
**Research artifact conventions**:
|
||||||
|
- One file per research topic (e.g. `research/auth-library-comparison.md`)
|
||||||
|
- Record third-party library usage examples, API references, version constraints in files
|
||||||
|
- Note relevant spec file paths you discovered for later reference
|
||||||
|
|
||||||
|
Brainstorm and research can interleave freely — pause to research a technical question, then return to talk with the user.
|
||||||
|
|
||||||
|
**Key principle**: Research output must be written to files, not left only in the chat. Conversations get compacted; files don't.
|
||||||
|
|
||||||
|
#### 1.3 Configure context `[required · once]`
|
||||||
|
|
||||||
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||||
|
|
||||||
|
Curate `implement.jsonl` and `check.jsonl` so the Phase 2 sub-agents get the right spec/research context. These files were seeded on `task create` with a single self-describing `_example` line; your job here is to fill in real entries.
|
||||||
|
|
||||||
|
**Location**: `{TASK_DIR}/implement.jsonl` and `{TASK_DIR}/check.jsonl` (already exist).
|
||||||
|
|
||||||
|
**Format**: one JSON object per line — `{"file": "<path>", "reason": "<why>"}`. Paths are repo-root relative.
|
||||||
|
|
||||||
|
**What to put in**:
|
||||||
|
- **Spec files** — `.trellis/spec/<package>/<layer>/index.md` and any specific guideline files (`error-handling.md`, `conventions.md`, etc.) relevant to this task
|
||||||
|
- **Research files** — `{TASK_DIR}/research/*.md` that the sub-agent will need to consult
|
||||||
|
|
||||||
|
**What NOT to put in**:
|
||||||
|
- Code files (`src/**`, `packages/**/*.ts`, etc.) — those are read by the sub-agent during implementation, not pre-registered here
|
||||||
|
- Files you're about to modify — same reason
|
||||||
|
|
||||||
|
**Split between the two files**:
|
||||||
|
- `implement.jsonl` → specs + research the implement sub-agent needs to write code correctly
|
||||||
|
- `check.jsonl` → specs for the check sub-agent (quality guidelines, check conventions, same research if needed)
|
||||||
|
|
||||||
|
These manifests do not replace `implement.md`. `implement.md` is the human-readable execution plan for a complex task; jsonl files only list context files to inject or load.
|
||||||
|
|
||||||
|
**How to discover relevant specs**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./.trellis/scripts/get_context.py --mode packages
|
||||||
|
```
|
||||||
|
|
||||||
|
Lists every package + its spec layers with paths. Pick the entries that match this task's domain.
|
||||||
|
|
||||||
|
**How to append entries**:
|
||||||
|
|
||||||
|
Either edit the jsonl file directly in your editor, or use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" implement "<path>" "<reason>"
|
||||||
|
python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" check "<path>" "<reason>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Delete the seed `_example` line once real entries exist (optional — it's skipped automatically by consumers).
|
||||||
|
|
||||||
|
Ready gate: both `implement.jsonl` and `check.jsonl` must contain at least one real `{"file": "...", "reason": "..."}` entry before `task.py start`. The seed `_example` row alone is not ready.
|
||||||
|
|
||||||
|
Skip this step only when both files already have real curated entries.
|
||||||
|
|
||||||
|
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||||
|
|
||||||
|
[codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness]
|
||||||
|
|
||||||
|
Skip this step. Context is loaded directly by the `trellis-before-dev` skill in Phase 2.
|
||||||
|
|
||||||
|
[/codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness]
|
||||||
|
|
||||||
|
#### 1.4 Activate task `[required · once]`
|
||||||
|
|
||||||
|
After artifact review, flip the task status to `in_progress`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./.trellis/scripts/task.py start <task-dir>
|
||||||
|
```
|
||||||
|
|
||||||
|
For lightweight tasks, `prd.md` can be enough. For complex tasks, `prd.md`, `design.md`, and `implement.md` must exist and be reviewed before start. On sub-agent-dispatch platforms, `implement.jsonl` and `check.jsonl` must both have real curated entries before start. Runtime consumers tolerate missing or seed-only manifests for compatibility, but that tolerance is not a planning-ready state.
|
||||||
|
|
||||||
|
After this command succeeds, the breadcrumb auto-switches to `[workflow-state:in_progress]`, and the rest of Phase 2 / 3 follows.
|
||||||
|
|
||||||
|
If `task.py start` errors with a session-identity message (no context key from hook input, `TRELLIS_CONTEXT_ID`, or platform-native session env), follow the hint in the error to set up session identity, then retry.
|
||||||
|
|
||||||
|
#### 1.5 Completion criteria
|
||||||
|
|
||||||
|
| Condition | Required |
|
||||||
|
|------|:---:|
|
||||||
|
| `prd.md` exists | ✅ |
|
||||||
|
| User confirms task should enter implementation | ✅ |
|
||||||
|
| `task.py start` has been run (status = in_progress) | ✅ |
|
||||||
|
| `research/` has artifacts (complex tasks) | recommended |
|
||||||
|
| `design.md` exists (complex tasks) | ✅ |
|
||||||
|
| `implement.md` exists (complex tasks) | ✅ |
|
||||||
|
|
||||||
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||||
|
|
||||||
|
| `implement.jsonl` and `check.jsonl` each contain at least one real curated entry (seed row does not count) | ✅ |
|
||||||
|
|
||||||
|
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Execute
|
||||||
|
|
||||||
|
Goal: turn reviewed planning artifacts into code that passes quality checks.
|
||||||
|
|
||||||
|
#### 2.1 Implement `[required · repeatable]`
|
||||||
|
|
||||||
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, CodeBuddy, Droid, Pi, ZCode, Snow, Oh My Pi]
|
||||||
|
|
||||||
|
Spawn the implement sub-agent:
|
||||||
|
|
||||||
|
- **Agent type**: `trellis-implement`
|
||||||
|
- **Task description**: Implement the reviewed task artifacts, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check
|
||||||
|
- **Dispatch prompt guard**: The prompt MUST start with `Active task: <task path>`, then tell the spawned agent it is already the `trellis-implement` sub-agent and must implement directly, not spawn another `trellis-implement` / `trellis-check`.
|
||||||
|
|
||||||
|
The platform hook/plugin auto-handles:
|
||||||
|
- Reads `implement.jsonl` and injects referenced spec/research files into the agent prompt
|
||||||
|
- Injects `prd.md`, `design.md` if present, and `implement.md` if present
|
||||||
|
- For Codex, `SubagentStart` supplies native context injection; the agent profile keeps child-side loading as the fallback
|
||||||
|
|
||||||
|
[/Claude Code, Cursor, OpenCode, codex-sub-agent, CodeBuddy, Droid, Pi, ZCode, Snow, Oh My Pi]
|
||||||
|
|
||||||
|
[Gemini, Qoder, Copilot, Reasonix, Trae, Grok, Kimi Code]
|
||||||
|
|
||||||
|
Spawn the implement sub-agent:
|
||||||
|
|
||||||
|
- **Agent type**: `trellis-implement`
|
||||||
|
- **Task description**: Implement the reviewed task artifacts, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check
|
||||||
|
- **Dispatch prompt guard**: The prompt MUST start with `Active task: <task path>`, then explicitly say the spawned agent is already `trellis-implement` and must implement directly without spawning another `trellis-implement` / `trellis-check`.
|
||||||
|
|
||||||
|
The pull-based sub-agent definition auto-handles the context load requirement:
|
||||||
|
- Resolves the active task with `task.py current --source`, then reads `prd.md`, `design.md` if present, and `implement.md` if present
|
||||||
|
- Reads `implement.jsonl` and requires the agent to load each referenced spec/research file before coding
|
||||||
|
|
||||||
|
[/Gemini, Qoder, Copilot, Reasonix, Trae, Grok, Kimi Code]
|
||||||
|
|
||||||
|
[Kiro]
|
||||||
|
|
||||||
|
Spawn the implement sub-agent:
|
||||||
|
|
||||||
|
- **Agent type**: `trellis-implement`
|
||||||
|
- **Task description**: Implement the reviewed task artifacts, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check
|
||||||
|
- **Dispatch prompt guard**: Tell the spawned agent it is already the `trellis-implement` sub-agent and must implement directly, not spawn another `trellis-implement` / `trellis-check`.
|
||||||
|
|
||||||
|
The platform prelude auto-handles the context load requirement:
|
||||||
|
- Reads `implement.jsonl` and injects referenced spec/research files into the agent prompt
|
||||||
|
- Injects `prd.md`, `design.md` if present, and `implement.md` if present
|
||||||
|
|
||||||
|
[/Kiro]
|
||||||
|
|
||||||
|
[codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness]
|
||||||
|
|
||||||
|
1. Load the `trellis-before-dev` skill to read project guidelines
|
||||||
|
2. Read `{TASK_DIR}/prd.md`, then `design.md` if present, then `implement.md` if present
|
||||||
|
3. Consult materials under `{TASK_DIR}/research/`
|
||||||
|
4. Implement the code per reviewed artifacts
|
||||||
|
5. Run project lint and type-check
|
||||||
|
|
||||||
|
[/codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness]
|
||||||
|
|
||||||
|
#### 2.2 Quality check `[required · repeatable]`
|
||||||
|
|
||||||
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||||
|
|
||||||
|
Spawn the check sub-agent:
|
||||||
|
|
||||||
|
- **Agent type**: `trellis-check`
|
||||||
|
- **Task description**: Review all code changes against specs and task artifacts; fix any findings directly; ensure lint and type-check pass
|
||||||
|
- **Dispatch prompt guard**: The prompt MUST start with `Active task: <task path>`, then tell the spawned agent it is already the `trellis-check` sub-agent and must review/fix directly, not spawn another `trellis-check` / `trellis-implement`.
|
||||||
|
|
||||||
|
The check agent's job:
|
||||||
|
- Review code changes against specs
|
||||||
|
- Review code changes against `prd.md`, `design.md` if present, and `implement.md` if present
|
||||||
|
- Auto-fix issues it finds
|
||||||
|
- Run lint and typecheck to verify
|
||||||
|
|
||||||
|
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||||
|
|
||||||
|
[codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness]
|
||||||
|
|
||||||
|
Load the `trellis-check` skill and verify the code per its guidance:
|
||||||
|
- Spec compliance
|
||||||
|
- lint / type-check / tests
|
||||||
|
- Cross-layer consistency (when changes span layers)
|
||||||
|
|
||||||
|
If issues are found → fix → re-check, until green.
|
||||||
|
|
||||||
|
[/codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness]
|
||||||
|
|
||||||
|
**Final pass (before Phase 3.4 commit)**: the last 2.2 of a task must run full-scope, not just on the latest implement chunk. List all affected packages with `python3 ./.trellis/scripts/get_context.py --mode packages`, then load each package's spec index Quality Check section. This catches cross-layer / multi-package issues a mid-iteration local 2.2 cannot.
|
||||||
|
|
||||||
|
#### 2.3 Rollback `[on demand]`
|
||||||
|
|
||||||
|
- `check` reveals a prd defect → return to Phase 1, fix `prd.md`, then redo 2.1
|
||||||
|
- Implementation went wrong → revert code, redo 2.1
|
||||||
|
- Need more research → research (same as Phase 1.2), write findings into `research/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Finish
|
||||||
|
|
||||||
|
Goal: ensure code quality, capture lessons, record the work.
|
||||||
|
|
||||||
|
#### 3.2 Debug retrospective `[on demand]`
|
||||||
|
|
||||||
|
If this task involved repeated debugging (the same issue was fixed multiple times), load the `trellis-break-loop` skill to:
|
||||||
|
- Classify the root cause
|
||||||
|
- Explain why earlier fixes failed
|
||||||
|
- Propose prevention
|
||||||
|
|
||||||
|
The goal is to capture debugging lessons so the same class of issue doesn't recur.
|
||||||
|
|
||||||
|
#### 3.3 Spec update `[required · once]`
|
||||||
|
|
||||||
|
Load the `trellis-update-spec` skill and review whether this task produced new knowledge worth recording:
|
||||||
|
- Newly discovered patterns or conventions
|
||||||
|
- Pitfalls you hit
|
||||||
|
- New technical decisions
|
||||||
|
|
||||||
|
Update the docs under `.trellis/spec/` accordingly. Even if the conclusion is "nothing to update", walk through the judgment.
|
||||||
|
|
||||||
|
#### 3.4 Commit changes `[required · once]`
|
||||||
|
|
||||||
|
**Spec-sync preamble**: before drafting commits, ask: did this task fix a bug or surface non-obvious knowledge that should land in `.trellis/spec/` so future-you (or future-AI) doesn't repeat the mistake? If yes, return to Phase 3.3 first — spec writes belong in the same task's commit batch, not as a forgotten follow-up.
|
||||||
|
|
||||||
|
The AI drives a batched commit of this task's code changes so `/finish-work` can run cleanly afterwards. Goal: produce work commits FIRST, then bookkeeping (archive + journal) commits land after — never interleaved.
|
||||||
|
|
||||||
|
**Step-by-step**:
|
||||||
|
|
||||||
|
1. **Inspect dirty state**:
|
||||||
|
```bash
|
||||||
|
git status --porcelain
|
||||||
|
```
|
||||||
|
Snapshot every dirty path. If the working tree is clean, skip to 3.5.
|
||||||
|
|
||||||
|
2. **Learn commit style** from recent history (so drafted messages blend in):
|
||||||
|
```bash
|
||||||
|
git log --oneline -5
|
||||||
|
```
|
||||||
|
Note the prefix convention (`feat:` / `fix:` / `chore:` / `docs:` ...), language (中文/English), and length style.
|
||||||
|
|
||||||
|
3. **Classify dirty files into two groups**:
|
||||||
|
- **AI-edited this session** — files you wrote/edited via Edit/Write/Bash tool calls in this session. You know what changed and why.
|
||||||
|
- **Unrecognized** — dirty files you did NOT touch this session (could be the user's manual edits, leftover WIP from a previous session, or unrelated work). Do NOT silently include these.
|
||||||
|
|
||||||
|
4. **Draft a commit plan**. Group AI-edited files into logical commits (1 commit per coherent change unit, not 1 commit per file). Each entry: `<commit message>` + file list. List unrecognized files separately at the bottom.
|
||||||
|
|
||||||
|
5. **Present the plan once, ask for one-shot confirmation**. Format:
|
||||||
|
```
|
||||||
|
Proposed commits (in order):
|
||||||
|
1. <message>
|
||||||
|
- <file>
|
||||||
|
- <file>
|
||||||
|
2. <message>
|
||||||
|
- <file>
|
||||||
|
|
||||||
|
Unrecognized dirty files (NOT in any commit — confirm include/exclude):
|
||||||
|
- <file>
|
||||||
|
- <file>
|
||||||
|
|
||||||
|
Reply 'ok' / '行' to execute. Reply with edits, or '我自己来' / 'manual' to abort.
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **On confirmation**: run `git add <files>` + `git commit -m "<msg>"` for each batch in order. Do not amend. Do not push.
|
||||||
|
|
||||||
|
7. **On rejection** (user replies "不行" / "我自己来" / "manual" / any pushback on the plan): stop. Do not attempt a second plan. The user will commit by hand; you skip ahead to 3.5 once they confirm.
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
- No `git commit --amend` anywhere — three-stage three-commit flow (work commits → archive commit → journal commit).
|
||||||
|
- Never push to remote in this step.
|
||||||
|
- If the user wants different message wording but accepts the file grouping, edit the message and re-confirm once — but if they reject the grouping, exit to manual mode.
|
||||||
|
- The batched plan is one prompt; do not prompt per commit.
|
||||||
|
|
||||||
|
#### 3.5 Wrap-up reminder
|
||||||
|
|
||||||
|
After the above, remind the user they can run `/finish-work` to wrap up (archive the task, record the session).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Customizing Trellis (for forks)
|
||||||
|
|
||||||
|
This section is for developers who want to modify the Trellis workflow itself. All customization is done by editing this file; the scripts are parsers only.
|
||||||
|
|
||||||
|
### Changing what a step means
|
||||||
|
|
||||||
|
Edit the corresponding step's walkthrough body in the Phase 1 / 2 / 3 sections above. Critical invariants:
|
||||||
|
- No active task must triage first and ask for task-creation consent before creating a Trellis task.
|
||||||
|
- Planning must distinguish lightweight PRD-only tasks from complex tasks that require `prd.md`, `design.md`, and `implement.md` before start.
|
||||||
|
- Every required execution path must keep the Phase 3.4 commit reminder reachable before `/trellis:finish-work`.
|
||||||
|
|
||||||
|
All tag blocks live in the `## Phase Index` section above, immediately after each phase summary:
|
||||||
|
|
||||||
|
| Scope | Corresponding tag |
|
||||||
|
|---|---|
|
||||||
|
| No active task (before Phase 1) | `[workflow-state:no_task]` (after the Phase Index ASCII art) |
|
||||||
|
| 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) |
|
||||||
|
| Codex inline Phase 2 + Phase 3.2–3.4 | `[workflow-state:in_progress-inline]` |
|
||||||
|
| After Phase 3.5 (archived) | `[workflow-state:completed]` (after Phase 3 summary; **currently DEAD**) |
|
||||||
|
|
||||||
|
### Changing the per-turn prompt text
|
||||||
|
|
||||||
|
Directly edit the body of the corresponding `[workflow-state:STATUS]` block. After editing, run `trellis update` (if you're a template maintainer) or restart your AI session (if you're customizing your own project) — no script changes required.
|
||||||
|
|
||||||
|
### Adding a custom status
|
||||||
|
|
||||||
|
Add a new block:
|
||||||
|
|
||||||
|
```
|
||||||
|
[workflow-state:my-status]
|
||||||
|
your per-turn prompt text
|
||||||
|
[/workflow-state:my-status]
|
||||||
|
```
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
- STATUS charset: `[A-Za-z0-9_-]+` (underscores and hyphens allowed, e.g. `in-review`, `blocked-by-team`)
|
||||||
|
- A lifecycle hook must write `task.json.status` to your custom value, otherwise the tag is never read
|
||||||
|
- Lifecycle hooks live in `task.json.hooks.after_*` and bind to one of `after_create / after_start / after_finish / after_archive`
|
||||||
|
|
||||||
|
### Adding a lifecycle hook
|
||||||
|
|
||||||
|
Add a `hooks` field to your `task.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"after_finish": [
|
||||||
|
"your-script-or-command-here"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported events: `after_create / after_start / after_finish / after_archive`. Note that `after_finish` ≠ a status change (it only clears the active-task pointer); use `after_archive` for "task is done" notifications.
|
||||||
|
|
||||||
|
### Full contract
|
||||||
|
|
||||||
|
For the workflow state machine's runtime contract, the locations of all status writers, pseudo-statuses (`no_task` / `stale_<source_type>`), the hook reachability matrix, and other deep details, see:
|
||||||
|
|
||||||
|
- `.trellis/spec/cli/backend/workflow-state-contract.md` — runtime contract + writer table + test invariants
|
||||||
|
- `.trellis/scripts/inject-workflow-state.py` — actual parser (reads workflow.md only, no embedded text)
|
||||||
125
.trellis/workspace/index.md
Normal file
125
.trellis/workspace/index.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# Workspace Index
|
||||||
|
|
||||||
|
> Records of all AI Agent work records across all developers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This directory tracks records for all developers working with AI Agents on this project.
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
workspace/
|
||||||
|
|-- index.md # This file - main index
|
||||||
|
+-- {developer}/ # Per-developer directory
|
||||||
|
|-- index.md # Personal index with session history
|
||||||
|
|-- tasks/ # Task files
|
||||||
|
| |-- *.json # Active tasks
|
||||||
|
| +-- archive/ # Archived tasks by month
|
||||||
|
+-- journal-N.md # Journal files (sequential: 1, 2, 3...)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Active Developers
|
||||||
|
|
||||||
|
| Developer | Last Active | Sessions | Active File |
|
||||||
|
|-----------|-------------|----------|-------------|
|
||||||
|
| (none yet) | - | - | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### For New Developers
|
||||||
|
|
||||||
|
Run the initialization script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./.trellis/scripts/init_developer.py <your-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Create your identity file (gitignored)
|
||||||
|
2. Create your progress directory
|
||||||
|
3. Create your personal index
|
||||||
|
4. Create initial journal file
|
||||||
|
|
||||||
|
### For Returning Developers
|
||||||
|
|
||||||
|
1. Get your developer name:
|
||||||
|
```bash
|
||||||
|
python3 ./.trellis/scripts/get_developer.py
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Read your personal index:
|
||||||
|
```bash
|
||||||
|
cat .trellis/workspace/$(python3 ./.trellis/scripts/get_developer.py)/index.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
### Journal File Rules
|
||||||
|
|
||||||
|
- **Max 2000 lines** per journal file
|
||||||
|
- When limit is reached, create `journal-{N+1}.md`
|
||||||
|
- Update your personal `index.md` when creating new files
|
||||||
|
|
||||||
|
### Session Record Format
|
||||||
|
|
||||||
|
Each session should include:
|
||||||
|
- Summary: One-line description
|
||||||
|
- Branch: Which branch the work was done on
|
||||||
|
- Main Changes: What was modified
|
||||||
|
- Git Commits: Commit hashes and messages
|
||||||
|
- Next Steps: What to do next
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session Template
|
||||||
|
|
||||||
|
Use this template when recording sessions:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Session {N}: {Title}
|
||||||
|
|
||||||
|
**Date**: YYYY-MM-DD
|
||||||
|
**Task**: {task-name}
|
||||||
|
**Branch**: `{branch-name}`
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
{One-line summary}
|
||||||
|
|
||||||
|
### Main Changes
|
||||||
|
|
||||||
|
- {Change 1}
|
||||||
|
- {Change 2}
|
||||||
|
|
||||||
|
### Git Commits
|
||||||
|
|
||||||
|
| Hash | Message |
|
||||||
|
|------|---------|
|
||||||
|
| `abc1234` | {commit message} |
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- [OK] {Test result}
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
[OK] **Completed** / # **In Progress** / [P] **Blocked**
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
|
||||||
|
- {Next step 1}
|
||||||
|
- {Next step 2}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Language**: All documentation must be written in **English**.
|
||||||
@@ -23,6 +23,8 @@ const SOLVED_KINDS = new Set([
|
|||||||
'junction-approach-width',
|
'junction-approach-width',
|
||||||
'junction-cutback',
|
'junction-cutback',
|
||||||
'junction-corner-radius',
|
'junction-corner-radius',
|
||||||
|
'junction-crosswalk-inset',
|
||||||
|
'junction-stop-line-offset',
|
||||||
]);
|
]);
|
||||||
const KINDS = [
|
const KINDS = [
|
||||||
'road-edge-offset',
|
'road-edge-offset',
|
||||||
@@ -31,8 +33,15 @@ const KINDS = [
|
|||||||
'junction-approach-width',
|
'junction-approach-width',
|
||||||
'junction-cutback',
|
'junction-cutback',
|
||||||
'junction-corner-radius',
|
'junction-corner-radius',
|
||||||
|
'junction-crosswalk-inset',
|
||||||
|
'junction-stop-line-offset',
|
||||||
];
|
];
|
||||||
const MIN_LANE_WIDTH_METERS = 2.4;
|
const MIN_LANE_WIDTH_METERS = 2.4;
|
||||||
|
// Bounds for the control-marking offsets. The crosswalk may sit anywhere from the
|
||||||
|
// junction boundary out to a few metres; the stop line must stay clear of the
|
||||||
|
// crossing it protects.
|
||||||
|
const MAX_CROSSWALK_INSET_METERS = 8;
|
||||||
|
const MIN_STOP_LINE_OFFSET_METERS = 0.5;
|
||||||
const GEOMETRY_VERSION = 'native-road-package/v1.1';
|
const GEOMETRY_VERSION = 'native-road-package/v1.1';
|
||||||
|
|
||||||
function emptyHandleManifest(context) {
|
function emptyHandleManifest(context) {
|
||||||
@@ -366,27 +375,65 @@ function applyJunctionConstraint(model, constraint, junctionPlans, junctionData,
|
|||||||
widthMeters: approach.items.reduce((sum, item) => sum + item.road.widthMeters, 0),
|
widthMeters: approach.items.reduce((sum, item) => sum + item.road.widthMeters, 0),
|
||||||
cutbackMeters: approach.cutback,
|
cutbackMeters: approach.cutback,
|
||||||
});
|
});
|
||||||
if (constraint.kind === 'junction-approach-width') {
|
// An explicit branch per kind: the previous `else` meant every kind that was not
|
||||||
const width = Number(constraint.value?.widthMeters);
|
// approach-width fell through to the cutback validator, so a new kind would have
|
||||||
if (!Number.isFinite(width) || width < 2.4) {
|
// been silently validated as, and written as, a cutback.
|
||||||
blocking(diagnostics, constraint, 'direct-edit-min-approach-width', '路口进口宽度不能小于 2.4 米。');
|
switch (constraint.kind) {
|
||||||
return false;
|
case 'junction-approach-width': {
|
||||||
|
const width = Number(constraint.value?.widthMeters);
|
||||||
|
if (!Number.isFinite(width) || width < MIN_LANE_WIDTH_METERS) {
|
||||||
|
blocking(diagnostics, constraint, 'direct-edit-min-approach-width', '路口进口宽度不能小于 2.4 米。');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
entry.widthMeters = width;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
entry.widthMeters = width;
|
case 'junction-cutback': {
|
||||||
} else {
|
const cutback = Number(constraint.value?.cutbackMeters);
|
||||||
const cutback = Number(constraint.value?.cutbackMeters);
|
if (!Number.isFinite(cutback) || cutback < 1 || cutback > approach.length * 0.45) {
|
||||||
if (!Number.isFinite(cutback) || cutback < 1 || cutback > approach.length * 0.45) {
|
blocking(
|
||||||
blocking(
|
diagnostics,
|
||||||
diagnostics,
|
constraint,
|
||||||
constraint,
|
'direct-edit-cutback-invalid',
|
||||||
'direct-edit-cutback-invalid',
|
'路口 cutback 必须落在进口可用长度的 45% 以内。',
|
||||||
'路口 cutback 必须落在进口可用长度的 45% 以内。',
|
);
|
||||||
);
|
return false;
|
||||||
return false;
|
}
|
||||||
|
entry.cutbackMeters = cutback;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
entry.cutbackMeters = cutback;
|
case 'junction-crosswalk-inset': {
|
||||||
|
const inset = Number(constraint.value?.insetMeters);
|
||||||
|
if (!Number.isFinite(inset) || inset < 0 || inset > MAX_CROSSWALK_INSET_METERS) {
|
||||||
|
blocking(
|
||||||
|
diagnostics,
|
||||||
|
constraint,
|
||||||
|
'direct-edit-crosswalk-inset-invalid',
|
||||||
|
`斑马线内缩必须在 0 到 ${MAX_CROSSWALK_INSET_METERS} 米之间。`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
entry.crosswalkInsetMeters = inset;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case 'junction-stop-line-offset': {
|
||||||
|
const offset = Number(constraint.value?.offsetMeters);
|
||||||
|
if (!Number.isFinite(offset) || offset < MIN_STOP_LINE_OFFSET_METERS || offset > approach.length * 0.45) {
|
||||||
|
blocking(
|
||||||
|
diagnostics,
|
||||||
|
constraint,
|
||||||
|
'direct-edit-stop-line-offset-invalid',
|
||||||
|
`停止线退距必须在 ${MIN_STOP_LINE_OFFSET_METERS} 米到进口可用长度的 45% 之间。`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
entry.stopLineOffsetMeters = offset;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
blocking(diagnostics, constraint, 'direct-edit-unknown-junction-kind', '未知的路口约束类型。');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function solveConstraints(model, constraints, junctionData, diagnostics) {
|
function solveConstraints(model, constraints, junctionData, diagnostics) {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const CONSTRAINT_ANCHORS = {
|
|||||||
'junction-approach-width': 'junction-approach',
|
'junction-approach-width': 'junction-approach',
|
||||||
'junction-cutback': 'junction-approach',
|
'junction-cutback': 'junction-approach',
|
||||||
'junction-corner-radius': 'junction-corner',
|
'junction-corner-radius': 'junction-corner',
|
||||||
|
'junction-crosswalk-inset': 'junction-approach',
|
||||||
|
'junction-stop-line-offset': 'junction-approach',
|
||||||
};
|
};
|
||||||
const CONSTRAINT_KINDS = Object.freeze(Object.keys(CONSTRAINT_ANCHORS));
|
const CONSTRAINT_KINDS = Object.freeze(Object.keys(CONSTRAINT_ANCHORS));
|
||||||
const ANCHOR_TYPES = new Set(['road-station', 'road-interval', 'junction-approach', 'junction-corner']);
|
const ANCHOR_TYPES = new Set(['road-station', 'road-interval', 'junction-approach', 'junction-corner']);
|
||||||
@@ -113,6 +115,16 @@ function validateValue(scope, kind, value) {
|
|||||||
} else if (kind === 'junction-corner-radius') {
|
} else if (kind === 'junction-corner-radius') {
|
||||||
if (!isFiniteNumber(value.radiusMeters) || value.radiusMeters < 0)
|
if (!isFiniteNumber(value.radiusMeters) || value.radiusMeters < 0)
|
||||||
fail(scope, 'value.radiusMeters', 'must be a non-negative number of meters');
|
fail(scope, 'value.radiusMeters', 'must be a non-negative number of meters');
|
||||||
|
} else if (kind === 'junction-crosswalk-inset') {
|
||||||
|
// How far the zebra sits back from the junction boundary. Negative would put
|
||||||
|
// it inside the intersection surface.
|
||||||
|
if (!isFiniteNumber(value.insetMeters) || value.insetMeters < 0)
|
||||||
|
fail(scope, 'value.insetMeters', 'must be a non-negative number of meters');
|
||||||
|
} else if (kind === 'junction-stop-line-offset') {
|
||||||
|
// Distance from the crossing back along the approach. Zero would put the stop
|
||||||
|
// line on the crosswalk itself.
|
||||||
|
if (!isFiniteNumber(value.offsetMeters) || value.offsetMeters <= 0)
|
||||||
|
fail(scope, 'value.offsetMeters', 'must be a positive number of meters');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -856,6 +856,11 @@ function applyDirectJunctionPlans(plans, directEdit) {
|
|||||||
approach.cutbackMeters = override.cutbackMeters;
|
approach.cutbackMeters = override.cutbackMeters;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
// Control-marking offsets move where the zebra and stop line sit; they do not
|
||||||
|
// reshape the junction, so they deliberately do not set `changed` and trigger
|
||||||
|
// a boundary recompute.
|
||||||
|
if (Number.isFinite(override.crosswalkInsetMeters)) approach.crosswalkInsetMeters = override.crosswalkInsetMeters;
|
||||||
|
if (Number.isFinite(override.stopLineOffsetMeters)) approach.stopLineOffsetMeters = override.stopLineOffsetMeters;
|
||||||
}
|
}
|
||||||
const cutbacks = Object.values(directPlan.approaches || {})
|
const cutbacks = Object.values(directPlan.approaches || {})
|
||||||
.map((entry) => entry.cutbackMeters)
|
.map((entry) => entry.cutbackMeters)
|
||||||
@@ -1279,7 +1284,11 @@ function compileControlMarkings(model, lanes, diagnostics, junctionPlans = new M
|
|||||||
const laneOffset = rawRoadPlacement ? project(approach.placement.point, rawRoadPlacement.point) : [0, 0];
|
const laneOffset = rawRoadPlacement ? project(approach.placement.point, rawRoadPlacement.point) : [0, 0];
|
||||||
const lateralOffset = laneOffset[0] * across[0] + laneOffset[1] * across[1];
|
const lateralOffset = laneOffset[0] * across[0] + laneOffset[1] * across[1];
|
||||||
const laneCenterAtCrossing = offsetByMeters(controlCenter, across, lateralOffset);
|
const laneCenterAtCrossing = offsetByMeters(controlCenter, across, lateralOffset);
|
||||||
const stopCenter = offsetByMeters(laneCenterAtCrossing, approach.placement.axis, -STOP_LINE_OFFSET_METERS);
|
const stopCenter = offsetByMeters(
|
||||||
|
laneCenterAtCrossing,
|
||||||
|
approach.placement.axis,
|
||||||
|
-approachControls(junctionPlans, approach.road).stopLineOffsetMeters,
|
||||||
|
);
|
||||||
stopLines.push(
|
stopLines.push(
|
||||||
controlFeature(
|
controlFeature(
|
||||||
'stop-line',
|
'stop-line',
|
||||||
@@ -1294,11 +1303,30 @@ function compileControlMarkings(model, lanes, diagnostics, junctionPlans = new M
|
|||||||
return { crosswalks, stopLines };
|
return { crosswalks, stopLines };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-approach overrides for the control markings, falling back to the module
|
||||||
|
* defaults. The junction a road approaches is the one at its far node, which is
|
||||||
|
* how `crossingJunctionInset()` has always resolved it.
|
||||||
|
*/
|
||||||
|
function approachControls(junctionPlans, road) {
|
||||||
|
const plan = junctionPlans.get(road?.sourceNodeIds?.at(-1));
|
||||||
|
const approach = plan?.approaches?.find?.((item) => item.segmentId === road.segmentId);
|
||||||
|
return {
|
||||||
|
crosswalkInsetMeters: Number.isFinite(approach?.crosswalkInsetMeters)
|
||||||
|
? approach.crosswalkInsetMeters
|
||||||
|
: CROSSWALK_JUNCTION_INSET_METERS,
|
||||||
|
stopLineOffsetMeters: Number.isFinite(approach?.stopLineOffsetMeters)
|
||||||
|
? approach.stopLineOffsetMeters
|
||||||
|
: STOP_LINE_OFFSET_METERS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function crossingJunctionInset(candidate, junctionPlans) {
|
function crossingJunctionInset(candidate, junctionPlans) {
|
||||||
const junctionNodeId = candidate.road.sourceNodeIds.at(-1);
|
const junctionNodeId = candidate.road.sourceNodeIds.at(-1);
|
||||||
const plan = junctionPlans.get(junctionNodeId);
|
const plan = junctionPlans.get(junctionNodeId);
|
||||||
if (!plan) return 0;
|
if (!plan) return 0;
|
||||||
const targetDistance = Math.max(0, plan.cutbackMeters - CROSSWALK_JUNCTION_INSET_METERS);
|
const inset = approachControls(junctionPlans, candidate.road).crosswalkInsetMeters;
|
||||||
|
const targetDistance = Math.max(0, plan.cutbackMeters - inset);
|
||||||
return Math.min(CROSSWALK_MAX_JUNCTION_INSET_METERS, Math.max(0, candidate.junctionDistanceMeters - targetDistance));
|
return Math.min(CROSSWALK_MAX_JUNCTION_INSET_METERS, Math.max(0, candidate.junctionDistanceMeters - targetDistance));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -392,6 +392,86 @@ const onBlocked = resolveDirectEditConstraints(
|
|||||||
assert.equal(onBlocked.constraintStates[0].applied, true);
|
assert.equal(onBlocked.constraintStates[0].applied, true);
|
||||||
assert.equal(onBlocked.roadProfiles.get('road:short').edgeOffsets.left, 1.5);
|
assert.equal(onBlocked.roadProfiles.get('road:short').edgeOffsets.left, 1.5);
|
||||||
|
|
||||||
|
// Control-marking offsets must reach the geometry before any handle is drawn for
|
||||||
|
// them. This project already shipped a range handle for `profile.interval`, which
|
||||||
|
// compileGeometry ignores, so the control dragged and changed nothing — see
|
||||||
|
// 08-26-direct-edit-map-editor/research/interval-not-applied.md. The consumer is
|
||||||
|
// asserted first.
|
||||||
|
const controlRoads = ['a', 'b', 'c'].map((id, index) => ({
|
||||||
|
id: `road:${id}`,
|
||||||
|
segmentId: `segment:${id}`,
|
||||||
|
direction: 'forward',
|
||||||
|
tags: {},
|
||||||
|
highway: 'residential',
|
||||||
|
// Roads *arrive* at the junction: `crossingJunctionInset()` resolves the junction
|
||||||
|
// from the road's last node, so a fixture whose roads leave the junction finds no
|
||||||
|
// plan and silently insets by zero.
|
||||||
|
centerline: [
|
||||||
|
[113 + index * 0.001 + (index === 1 ? 0.001 : 0), 30 + (index === 1 ? 0.001 : 0.001)],
|
||||||
|
[113 + index * 0.001, 30],
|
||||||
|
],
|
||||||
|
sourceNodeIds: [`end-${id}`, 'junction'],
|
||||||
|
osmWayIds: [id],
|
||||||
|
widthMeters: 6,
|
||||||
|
laneCount: 2,
|
||||||
|
sidewalkLeft: true,
|
||||||
|
sidewalkRight: true,
|
||||||
|
}));
|
||||||
|
const controlModel = {
|
||||||
|
roads: controlRoads,
|
||||||
|
endpoints: controlRoads.map((road) => ({
|
||||||
|
id: `endpoint:${road.id}:end`,
|
||||||
|
roadId: road.id,
|
||||||
|
side: 'end',
|
||||||
|
nodeId: 'junction',
|
||||||
|
coordinate: road.centerline.at(-1),
|
||||||
|
})),
|
||||||
|
connections: [],
|
||||||
|
diagnostics: [],
|
||||||
|
crossings: [{ id: 'x1', coordinate: [113, 30.0004], tags: { highway: 'crossing' }, osmWayIds: ['a'] }],
|
||||||
|
};
|
||||||
|
const controlConstraint = (kind, value) =>
|
||||||
|
constraint({
|
||||||
|
id: `c-${kind}`,
|
||||||
|
kind,
|
||||||
|
anchor: { type: 'junction-approach', nodeId: 'junction', segmentId: 'segment:a' },
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
// NOTE: the geometry-level proof (a crosswalk inset actually moving the zebra) was
|
||||||
|
// measured on a real 41-road workspace with 8 crossings, not asserted here: this
|
||||||
|
// synthetic junction resolves `junction_inset_m` to 0 because the crossing never
|
||||||
|
// binds to a junction plan, and the committed OSM fixture has no crossings at all.
|
||||||
|
// Closing that gap needs an OSM fixture with a crossing on a road that arrives at a
|
||||||
|
// junction. What is asserted below is the wiring the handles will depend on.
|
||||||
|
// Both new kinds land their value on the approach entry the compiler reads.
|
||||||
|
const approachEntry = (kind, value) =>
|
||||||
|
resolveDirectEditConstraints(controlModel, document([controlConstraint(kind, value)]), {}).junctionPlans.get(
|
||||||
|
'junction',
|
||||||
|
).approaches['segment:a'];
|
||||||
|
assert.equal(approachEntry('junction-crosswalk-inset', { insetMeters: 3 }).crosswalkInsetMeters, 3);
|
||||||
|
assert.equal(approachEntry('junction-stop-line-offset', { offsetMeters: 4 }).stopLineOffsetMeters, 4);
|
||||||
|
|
||||||
|
// Out of range blocks rather than clamping, and writes nothing to the plan.
|
||||||
|
const absurd = resolveDirectEditConstraints(
|
||||||
|
controlModel,
|
||||||
|
document([controlConstraint('junction-crosswalk-inset', { insetMeters: 9999 })]),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
assert.equal(absurd.constraintStates[0].applied, false);
|
||||||
|
assert.ok(
|
||||||
|
absurd.diagnostics.some((item) => item.rule === 'direct-edit-crosswalk-inset-invalid'),
|
||||||
|
'an out-of-range inset must produce a blocking diagnostic, not a silent clamp',
|
||||||
|
);
|
||||||
|
|
||||||
|
// The kinds are distinct branches: a stop-line offset must not be validated or
|
||||||
|
// written as a cutback, which the previous `else` fallthrough would have done.
|
||||||
|
const stopOnly = resolveDirectEditConstraints(
|
||||||
|
controlModel,
|
||||||
|
document([controlConstraint('junction-stop-line-offset', { offsetMeters: 4 })]),
|
||||||
|
{},
|
||||||
|
).junctionPlans.get('junction').approaches['segment:a'];
|
||||||
|
assert.equal(stopOnly.cutbackMeters !== 4, true, 'a stop-line offset must not be written as a cutback');
|
||||||
|
|
||||||
// The solver must be free of file and network access so preview can share it.
|
// The solver must be free of file and network access so preview can share it.
|
||||||
const source = require('fs').readFileSync(require.resolve('../src/compile/direct-edit-solver'), 'utf8');
|
const source = require('fs').readFileSync(require.resolve('../src/compile/direct-edit-solver'), 'utf8');
|
||||||
for (const forbidden of ["require('fs')", "require('path')", "require('http')", "require('https')"])
|
for (const forbidden of ["require('fs')", "require('path')", "require('http')", "require('https')"])
|
||||||
|
|||||||
@@ -28,13 +28,30 @@ function anchorFor(kind) {
|
|||||||
return { type, nodeId: 'node/9', incomingRoadId: 'road:way/1:forward', outgoingRoadId: 'road:way/2:forward' };
|
return { type, nodeId: 'node/9', incomingRoadId: 'road:way/1:forward', outgoingRoadId: 'road:way/2:forward' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One branch per kind. The trailing `return { radiusMeters: 6 }` this replaces
|
||||||
|
// silently answered for every kind it did not name, so a new kind was built with a
|
||||||
|
// corner-radius value and only failed later, in the validator.
|
||||||
function valueFor(kind) {
|
function valueFor(kind) {
|
||||||
if (kind === 'road-edge-offset') return { offsetMeters: 0.8, transition: 'smoothstep' };
|
switch (kind) {
|
||||||
if (kind === 'road-sidewalk-width') return { widthMeters: 2.5, transition: 'linear' };
|
case 'road-edge-offset':
|
||||||
if (kind === 'road-lane-divider') return { boundaryIndex: 2, offsetMeters: -1.6, transition: 'smoothstep' };
|
return { offsetMeters: 0.8, transition: 'smoothstep' };
|
||||||
if (kind === 'junction-approach-width') return { widthMeters: 12.5 };
|
case 'road-sidewalk-width':
|
||||||
if (kind === 'junction-cutback') return { cutbackMeters: 4 };
|
return { widthMeters: 2.5, transition: 'linear' };
|
||||||
return { radiusMeters: 6 };
|
case 'road-lane-divider':
|
||||||
|
return { boundaryIndex: 2, offsetMeters: -1.6, transition: 'smoothstep' };
|
||||||
|
case 'junction-approach-width':
|
||||||
|
return { widthMeters: 12.5 };
|
||||||
|
case 'junction-cutback':
|
||||||
|
return { cutbackMeters: 4 };
|
||||||
|
case 'junction-corner-radius':
|
||||||
|
return { radiusMeters: 6 };
|
||||||
|
case 'junction-crosswalk-inset':
|
||||||
|
return { insetMeters: 1.5 };
|
||||||
|
case 'junction-stop-line-offset':
|
||||||
|
return { offsetMeters: 2.7 };
|
||||||
|
default:
|
||||||
|
throw new Error(`valueFor has no case for ${kind}; the taxonomy grew without this fixture`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function constraintFor(kind, id = `c-${kind}`) {
|
function constraintFor(kind, id = `c-${kind}`) {
|
||||||
@@ -82,6 +99,8 @@ assert.deepEqual(CONSTRAINT_KINDS, [
|
|||||||
'junction-approach-width',
|
'junction-approach-width',
|
||||||
'junction-cutback',
|
'junction-cutback',
|
||||||
'junction-corner-radius',
|
'junction-corner-radius',
|
||||||
|
'junction-crosswalk-inset',
|
||||||
|
'junction-stop-line-offset',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// --- round trip is lossless --------------------------------------------------
|
// --- round trip is lossless --------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user