Initialize Trellis project guidelines
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/hooks/inject-subagent-context.py": "abffa237eb53f87ae6ffa434063b46b03d58a36a84cdb8fe88bfc5f243aab609",
|
||||
".claude/hooks/inject-workflow-state.py": "9ce43910ac39cbb0e4d1783fbde931761eb04536c7f82661a96d95ea72c14bde",
|
||||
".claude/hooks/session-start.py": "68c68e08ff3382c95766eced91332e2ae77b655d82972f206e4585b1e5751249",
|
||||
".claude/hooks/statusline.py": "edbd8d1443ac8e7bb4f8cce61cb21812a8b0e17c59e1bc5c0e3ad03ea1f69adb",
|
||||
".claude/commands/trellis/continue.md": "6c34c41824f8eff4b2df792e032e0c36787b59f8a8879b0338347073f76ad52c",
|
||||
".claude/commands/trellis/finish-work.md": "d6aa570ab684f57e4845de2d84a1ff6d9f0908e04c5a56e14fd70ae739c369fc",
|
||||
".claude/skills/trellis-before-dev/SKILL.md": "859894f2e8258cbfa142d710363433761c54984c69f7ed7bd44512fb4eb165cd",
|
||||
".claude/skills/trellis-brainstorm/SKILL.md": "a0f226ddcb8a3e846acd2a35d121996e9ca55165ce76202095d0b65e2b48a5e8",
|
||||
".claude/skills/trellis-break-loop/SKILL.md": "f5a93699832f29dee443b53c135a7459519b371f689af191d15c29e8ee5c7bde",
|
||||
".claude/skills/trellis-check/SKILL.md": "b21ff04b7680ebacb8c5ecbc48a22d627eb13e2b47fceb78c8ced0b43b60b282",
|
||||
".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": "7a8d1a5dcc8d1140c4c6bd19d02949364cdd01801bd0825a975a308cf85b8f37",
|
||||
".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": "9f41349b78f7ae64698a38490a317882561f3805b26a79bda587a13d03fda245",
|
||||
".claude/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "154af08f7ee8afe7a704968ec0b9fc21e905b7cfb03871c9dbafcdd6cc654319",
|
||||
".claude/skills/trellis-meta/references/platform-files/overview.md": "1aec9087ccedd56a213af877b5db474131aa4b4789b6ac0e8da73b058aa43bd4",
|
||||
".claude/skills/trellis-meta/references/platform-files/platform-map.md": "9e476e500f10b2a1a05278dd700837deb731a77e3f3993e90f8102528a9bdcca",
|
||||
".claude/skills/trellis-meta/references/platform-files/skills-and-commands.md": "e39831d860bd27a04e7757f7bd941ab83b3c5b11ad4460cec03975b655f26cc3",
|
||||
".claude/skills/trellis-meta/SKILL.md": "208e92ace3b8d979d8158b3bd0169185f508f2e1424fcd78fcd03cbcc39516a1",
|
||||
".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",
|
||||
".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": "859894f2e8258cbfa142d710363433761c54984c69f7ed7bd44512fb4eb165cd",
|
||||
".agents/skills/trellis-brainstorm/SKILL.md": "a0f226ddcb8a3e846acd2a35d121996e9ca55165ce76202095d0b65e2b48a5e8",
|
||||
".agents/skills/trellis-break-loop/SKILL.md": "f5a93699832f29dee443b53c135a7459519b371f689af191d15c29e8ee5c7bde",
|
||||
".agents/skills/trellis-check/SKILL.md": "b21ff04b7680ebacb8c5ecbc48a22d627eb13e2b47fceb78c8ced0b43b60b282",
|
||||
".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": "7a8d1a5dcc8d1140c4c6bd19d02949364cdd01801bd0825a975a308cf85b8f37",
|
||||
".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": "9f41349b78f7ae64698a38490a317882561f3805b26a79bda587a13d03fda245",
|
||||
".agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "154af08f7ee8afe7a704968ec0b9fc21e905b7cfb03871c9dbafcdd6cc654319",
|
||||
".agents/skills/trellis-meta/references/platform-files/overview.md": "1aec9087ccedd56a213af877b5db474131aa4b4789b6ac0e8da73b058aa43bd4",
|
||||
".agents/skills/trellis-meta/references/platform-files/platform-map.md": "9e476e500f10b2a1a05278dd700837deb731a77e3f3993e90f8102528a9bdcca",
|
||||
".agents/skills/trellis-meta/references/platform-files/skills-and-commands.md": "e39831d860bd27a04e7757f7bd941ab83b3c5b11ad4460cec03975b655f26cc3",
|
||||
".agents/skills/trellis-meta/SKILL.md": "208e92ace3b8d979d8158b3bd0169185f508f2e1424fcd78fcd03cbcc39516a1",
|
||||
".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": "79070c63fa404fc53061cca5194bf66db839132b67a57b9c8d6a295037ba7308",
|
||||
".codex/agents/trellis-implement.toml": "388fb8f39797e0ee6cf4db447c859c79b4ac15f531f7e1e3c68c1d1d71a1c188",
|
||||
".codex/agents/trellis-research.toml": "4435ce73197ba1d29d40359a3279b6423f7e4f559a449f934c016808090066c4",
|
||||
".codex/hooks/session-start.py": "14de3be1cf6eb9c9feba348d8998b407f3837d6c0756b74210c9200543440677",
|
||||
".codex/hooks/inject-subagent-context.py": "abffa237eb53f87ae6ffa434063b46b03d58a36a84cdb8fe88bfc5f243aab609",
|
||||
".codex/hooks/inject-workflow-state.py": "9ce43910ac39cbb0e4d1783fbde931761eb04536c7f82661a96d95ea72c14bde",
|
||||
".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": "31271e3b69b5a5eca958d8ce25f61fe852eb6e48d32c150471da8e7272fd6119",
|
||||
".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": "05898ef136cc7c4d861b05fbf2b16d53ddd3e6f311a231d4fcfcb81bde7c45ee",
|
||||
".trellis/scripts/common/safe_commit.py": "baa5c82324eb62154374ec63394ecdc8609bb37d93892e3bcb88f452bb7d6446",
|
||||
".trellis/scripts/common/session_context.py": "4ed3e13b2878ba367e9f2e2cd709b396f806152902df2cd1cd1478317d069017",
|
||||
".trellis/scripts/common/task_context.py": "4ea260a022f4122361eb0d9dd9200a9324aeafbdb20cadd2848bea1649938f1d",
|
||||
".trellis/scripts/common/task_queue.py": "0be61f713462b1fe4574927c82fc4704e678afe72dcb9813543aedf2f9e9e0c5",
|
||||
".trellis/scripts/common/task_store.py": "e3c2fbf8b79b591e39fc3c9f4e2f3ee0c840c8201c94a16709ec743fa45037f6",
|
||||
".trellis/scripts/common/task_utils.py": "90c0a6d50bad502c3f01cb24c1ccfeb0eece5e2c69efaff8d65eb827fba43871",
|
||||
".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": "e0ffed9f14994069f0c992141e3ec168524be5af32e3681e6ea30ba0a5da4bc4",
|
||||
".trellis/workflow.md": "e2c5ab7004ff83a5a804b50df81746aa1d558dd4480463287622605f86a82a76"
|
||||
}
|
||||
}
|
||||
1
.trellis/.version
Normal file
1
.trellis/.version
Normal file
@@ -0,0 +1 @@
|
||||
0.6.12
|
||||
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,
|
||||
)
|
||||
662
.trellis/scripts/common/active_task.py
Executable file
662
.trellis/scripts/common/active_task.py
Executable file
@@ -0,0 +1,662 @@
|
||||
#!/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_CURSOR_SHELL = "cursor-shell"
|
||||
CURSOR_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",
|
||||
}
|
||||
|
||||
_ENV_SESSION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("claude", ("CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID")),
|
||||
("codex", ("CODEX_SESSION_ID", "CODEX_THREAD_ID")),
|
||||
("cursor", ("CURSOR_SESSION_ID",)),
|
||||
("opencode", ("OPENCODE_SESSION_ID", "OPENCODE_SESSIONID", "OPENCODE_RUN_ID")),
|
||||
("gemini", ("GEMINI_SESSION_ID",)),
|
||||
("droid", ("FACTORY_SESSION_ID", "DROID_SESSION_ID")),
|
||||
("qoder", ("QODER_SESSION_ID",)),
|
||||
("codebuddy", ("CODEBUDDY_SESSION_ID",)),
|
||||
("kiro", ("KIRO_SESSION_ID",)),
|
||||
("copilot", ("COPILOT_SESSION_ID", "COPILOT_SESSIONID")),
|
||||
("pi", ("PI_SESSION_ID", "PI_SESSIONID")),
|
||||
("trae", ("TRAE_SESSION_ID",)),
|
||||
# ZCode reuses CLAUDE_SESSION_ID (it does not document a ZCODE_SESSION_ID).
|
||||
# Platform-scoped lookup (_iter_env_keys filters by platform name), so this
|
||||
# only fires when the resolver already detected "zcode" — no collision with
|
||||
# the claude entry above.
|
||||
("zcode", ("CLAUDE_SESSION_ID",)),
|
||||
# Snow CLI exports SNOW_SESSION_ID into hook/terminal/sub-agent children.
|
||||
# TRELLIS_CONTEXT_ID remains the preferred override when present.
|
||||
("snow", ("SNOW_SESSION_ID",)),
|
||||
)
|
||||
_ENV_CONVERSATION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("cursor", ("CURSOR_CONVERSATION_ID", "CURSOR_CONVERSATIONID")),
|
||||
)
|
||||
_ENV_TRANSCRIPT_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("claude", ("CLAUDE_TRANSCRIPT_PATH",)),
|
||||
("codex", ("CODEX_TRANSCRIPT_PATH",)),
|
||||
("cursor", ("CURSOR_TRANSCRIPT_PATH",)),
|
||||
("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_SESSION_ID. 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",
|
||||
}
|
||||
|
||||
|
||||
@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."""
|
||||
normalized = normalize_task_ref(task_ref)
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
path_obj = Path(normalized)
|
||||
if path_obj.is_absolute():
|
||||
return path_obj
|
||||
|
||||
if normalized.startswith(f"{DIR_WORKFLOW}/"):
|
||||
return repo_root / path_obj
|
||||
|
||||
return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj
|
||||
|
||||
|
||||
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, ...]], ...]:
|
||||
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 _cursor_shell_ticket_dir(repo_root: Path) -> Path:
|
||||
return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_CURSOR_SHELL
|
||||
|
||||
|
||||
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 <= CURSOR_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_cursor_ticket_context_key(
|
||||
ticket_path: Path,
|
||||
repo_root: Path,
|
||||
now: float,
|
||||
) -> str | None:
|
||||
ticket = _read_json(ticket_path)
|
||||
if ticket is None or ticket.get("platform") != "cursor":
|
||||
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_cursor_shell_ticket_context_key() -> str | None:
|
||||
"""Resolve Cursor conversation identity from a short-lived shell ticket.
|
||||
|
||||
Cursor exposes `conversation_id` to `beforeShellExecution`, but does not
|
||||
export it into the shell command environment. The Cursor hook writes a
|
||||
short-lived ticket just before `task.py` runs. We accept a ticket only when
|
||||
the current `task.py` subcommand matches and exactly one fresh context key
|
||||
matches, which avoids cross-window pointer contamination.
|
||||
"""
|
||||
repo_root = _find_repo_root_from_cwd()
|
||||
if repo_root is None:
|
||||
return None
|
||||
|
||||
ticket_dir = _cursor_shell_ticket_dir(repo_root)
|
||||
if not ticket_dir.is_dir():
|
||||
return None
|
||||
|
||||
now = time.time()
|
||||
candidates: set[str] = set()
|
||||
for ticket_path in ticket_dir.glob("*.json"):
|
||||
context_key = _matching_cursor_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
|
||||
|
||||
if allow_environment_context and platform_name in (None, "session", "cursor"):
|
||||
return _lookup_cursor_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).as_posix()
|
||||
except ValueError:
|
||||
return str(full_path)
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
447
.trellis/scripts/common/paths.py
Executable file
447
.trellis/scripts/common/paths.py
Executable file
@@ -0,0 +1,447 @@
|
||||
#!/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."""
|
||||
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():
|
||||
return path_obj
|
||||
|
||||
if normalized.startswith(f"{DIR_WORKFLOW}/"):
|
||||
return repo_root / path_obj
|
||||
|
||||
return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
874
.trellis/scripts/common/session_context.py
Executable file
874
.trellis/scripts/common/session_context.py
Executable file
@@ -0,0 +1,874 @@
|
||||
#!/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
|
||||
"""
|
||||
|
||||
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) -> Path:
|
||||
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) -> bool:
|
||||
marker_path = _update_marker_path(repo_root)
|
||||
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) -> str | None:
|
||||
marker_path = _update_marker_path(repo_root)
|
||||
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)
|
||||
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))
|
||||
319
.trellis/scripts/common/task_context.py
Executable file
319
.trellis/scripts/common/task_context.py
Executable file
@@ -0,0 +1,319 @@
|
||||
#!/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 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.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.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 _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 = repo_root / file_path
|
||||
if entry_type == "directory":
|
||||
if not full_path.is_dir():
|
||||
print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
if 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.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']}")
|
||||
945
.trellis/scripts/common/task_store.py
Executable file
945
.trellis/scripts/common/task_store.py
Executable file
@@ -0,0 +1,945 @@
|
||||
#!/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)
|
||||
parent_json_path = parent_dir / FILE_TASK_JSON
|
||||
if not parent_json_path.is_file():
|
||||
print(colored(f"Warning: Parent task.json not found: {args.parent}", Colors.YELLOW), file=sys.stderr)
|
||||
else:
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
298
.trellis/scripts/common/task_utils.py
Executable file
298
.trellis/scripts/common/task_utils.py
Executable file
@@ -0,0 +1,298 @@
|
||||
#!/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:
|
||||
"""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.
|
||||
"""
|
||||
if not target_dir:
|
||||
return Path()
|
||||
|
||||
normalized = target_dir.replace("\\", "/")
|
||||
while normalized.startswith("./"):
|
||||
normalized = normalized[2:]
|
||||
|
||||
# Absolute path
|
||||
if Path(target_dir).is_absolute():
|
||||
return Path(target_dir)
|
||||
|
||||
# Relative path (contains path separator or starts with .trellis)
|
||||
if "/" in normalized or normalized.startswith(".trellis"):
|
||||
return repo_root / Path(normalized)
|
||||
|
||||
# Task name - try to find in tasks directory
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
found = find_task_by_name(target_dir, tasks_dir)
|
||||
if found:
|
||||
return found
|
||||
|
||||
# Fallback to treating as relative path
|
||||
return repo_root / Path(normalized)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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()
|
||||
602
.trellis/scripts/task.py
Executable file
602
.trellis/scripts/task.py
Executable file
@@ -0,0 +1,602 @@
|
||||
#!/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.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
|
||||
try:
|
||||
task_dir = full_path.relative_to(repo_root).as_posix()
|
||||
except ValueError:
|
||||
task_dir = str(full_path)
|
||||
|
||||
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())
|
||||
218
.trellis/spec/blender/asset-generation.md
Normal file
218
.trellis/spec/blender/asset-generation.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# 资产生成
|
||||
|
||||
> 适用:改动 Blender 侧的几何构建、材质、实例化,或往场景里加新资产。
|
||||
> 贯穿全篇的约束是两条:**压低对象数**(GLB 要能在浏览器里跑)和
|
||||
> **构建必须确定性**(parity 校验的前提)。
|
||||
|
||||
---
|
||||
|
||||
## MeshBatch:几何构建的主力
|
||||
|
||||
`mesh.py:1-9` 说明了它为什么存在:场景的绝大部分是平面多边形和拉伸棱柱,
|
||||
**把它们批进一个 mesh datablock 能同时压低 Blender 对象数和导出 glTF 的节点数**。
|
||||
|
||||
用法固定为「累积 → 一次 `finish()`」:
|
||||
|
||||
```python
|
||||
batch = MeshBatch("Lake Surface", water_c, water_mat)
|
||||
batch.add_polygon(ring, 0.10)
|
||||
batch.finish() # water.py:12-14
|
||||
```
|
||||
|
||||
### 三条内建行为
|
||||
|
||||
1. **自动去掉重复的闭合点**(`mesh.py:41-42, 55-56`)。传闭合环或开放环都行,
|
||||
与 `geom.py` 的宽容度一致
|
||||
2. **退化输入静默返回**:`len(ring) < 3` 直接 return,不抛
|
||||
3. **空批次 `finish()` 返回 `None`**(`mesh.py:66-67`),不产生空对象
|
||||
|
||||
### 名字前缀是承重的
|
||||
|
||||
```python
|
||||
# Foliage reads as blobby volume, so it wants smooth normals; the built
|
||||
# environment wants its facets. The name prefix is the discriminator.
|
||||
if self.name.startswith("Tree_") or self.name.startswith("Scrub_"):
|
||||
for polygon in mesh.polygons:
|
||||
polygon.use_smooth = True # mesh.py:72-77
|
||||
```
|
||||
|
||||
**改植被对象的命名前缀会静默改变着色**。加新的植被类资产时要么沿用
|
||||
`Tree_` / `Scrub_` 前缀,要么显式扩展这个判断。
|
||||
|
||||
### 单材质约束
|
||||
|
||||
一个 `MeshBatch` 只挂一个材质(`mesh.py:64`)。需要多材质就开多个 batch——
|
||||
这也正是[材质顺序决定 GLB 索引](../pipeline/layer-registry.md#顺序是承重的)的地方。
|
||||
|
||||
### 便捷包装
|
||||
|
||||
`make_prism` / `add_roof`(`mesh.py:83, 89`)是「单个形体」的一次性包装,内部就是
|
||||
`MeshBatch` + `finish()`。只放一个形体时用它们,批量累积时直接用 `MeshBatch`。
|
||||
|
||||
---
|
||||
|
||||
## 材质:声明与构建分离
|
||||
|
||||
```
|
||||
catalog.MATERIALS 声明「是什么」 纯 Python,无 bpy
|
||||
│
|
||||
▼ materials.from_spec(spec) materials.py:198
|
||||
真实的 bpy.types.Material 只在 Blender 内
|
||||
```
|
||||
|
||||
这个拆分让 catalog 能被任何不启动 Blender 的工具读取(`materials.py:3-7`)。
|
||||
|
||||
### `kind` 选构建器
|
||||
|
||||
| `kind` | 走哪条路 | 必填字段 |
|
||||
|---|---|---|
|
||||
| `solid` | `make_material()` | `name`、`color` |
|
||||
| `textured` | `make_textured_material()` | `name`、`diffuse`、`normal`、`scale` |
|
||||
|
||||
`solid` 可以再叠 `procedural`(噪声驱动的基色和凹凸,`from_spec` 里判断)。
|
||||
可选字段一律 `spec.get(key, 默认值)`——**加新的可选字段不要改已有条目**。
|
||||
|
||||
### 加一种材质
|
||||
|
||||
1. `catalog.MATERIALS` **末尾追加**一个条目(顺序决定 GLB 材质索引)
|
||||
2. 若 `kind` 是 `textured`,贴图放 `assets/textures/`(`materials.py:14` 的
|
||||
`TEXTURE_ROOT`)
|
||||
3. `generate_scene.py` 里用 `material_from_spec(catalog.MATERIALS["<key>"])` 取
|
||||
4. **若这个材质在 Cesium 里需要调色,去 `export_cesium.py` 的四张表加**——
|
||||
见下文,那边按材质名字符串匹配
|
||||
|
||||
### 颜色是线性 RGB
|
||||
|
||||
`catalog` 里的 `color` 是 Blender 的线性值,**不是** sRGB hex,也**不**从
|
||||
`scripts/lib/scene-layers.js` 换算。两套配色独立调过,理由见
|
||||
[图层表](../pipeline/layer-registry.md#为什么颜色刻意不同步)。
|
||||
|
||||
---
|
||||
|
||||
## 实例化:树
|
||||
|
||||
`tree.py:1-8` 的模式——**import 一次 → bake 朝向 → 每棵树只 link 一个轻对象复用
|
||||
同一个 datablock**:
|
||||
|
||||
> Nothing is duplicated per tree, so the .blend and the exported GLB carry each
|
||||
> mesh and each texture exactly once no matter how many trees are planted.
|
||||
|
||||
`TreeVariant` 是 `(meshes, height, base_z)` 三元组(`tree.py:62-65`):
|
||||
|
||||
- `height` — 变体自身的高度,目标高度除以它得到缩放系数
|
||||
- `base_z` — 变体自身的地面线,**取负乘以缩放**就能把树干落到 `z=0`,
|
||||
不管源文件把原点放在哪(`tree.py:301-303`)
|
||||
|
||||
`assemble()` 返回种植数量,**0 表示模型缺失或 style 未知**,调用方据此回退到程序化
|
||||
树(`tree.py:277-279`)。
|
||||
|
||||
### 材质在本地重建,不沿用源文件
|
||||
|
||||
`tree.py:12-16`:两个 vendored 模型的材质都不能直接用。apple 的贴图 57% 是透明的
|
||||
(那是叶片卡),没有 alpha-clip 设置的话整个树冠会渲染成一块。
|
||||
|
||||
**vendored 资产的材质一律重建**,不要 `append` 源文件的材质。
|
||||
|
||||
### 已删除的第三种 style 有记录
|
||||
|
||||
`tree.py:18-25` 记着 `polyhaven` style 被删的原因(LOD1 对象不是整棵树,是给几何节点
|
||||
散布用的树枝和叶簇,直接种出来是一地树枝,连同 78MB 资产一起删了)。
|
||||
|
||||
**这类"试过、不行、为什么"的记录要保留。** 删掉它,下一个人会重新引入同一个资产。
|
||||
|
||||
---
|
||||
|
||||
## 确定性:用无理数周期代替 RNG
|
||||
|
||||
这是全仓最容易被无意破坏的约定。`tree.py:290-292`:
|
||||
|
||||
```python
|
||||
# Irrational periods stand in for an RNG: no repeat over any realistic
|
||||
# tree count, and a pure function of the index, so rebuilding an area
|
||||
# plants the identical forest.
|
||||
scale_wobble = 1.0 + SCALE_JITTER * math.sin(index * 2.399963)
|
||||
yaw = ((index * GOLDEN_TURN) % 1.0) * math.tau
|
||||
tilt_x = TILT_JITTER * math.sin(index * 1.114517)
|
||||
tilt_y = TILT_JITTER * math.cos(index * 0.927295)
|
||||
```
|
||||
|
||||
黄金角 `GOLDEN_TURN`(`tree.py:47-49`)让相邻的树朝向永不重复也永不成规律——
|
||||
一排树看起来像种的,不像盖章盖的。
|
||||
|
||||
**规则**:需要"随机"外观时,用 `index` 的纯函数(无理数周期 / 黄金角),
|
||||
或者收一个显式 `seed`(`geom.sample_polygon_interior` 就是这么做的)。
|
||||
|
||||
**绝不要**用无种子的 `random` 或任何时间相关的量——
|
||||
重建同一片区域必须得到逐字节相同的结构,否则
|
||||
[parity 校验](../guides/artifact-parity-guide.md)永久性地红。
|
||||
|
||||
---
|
||||
|
||||
## Cesium 导出:一层独立的调色
|
||||
|
||||
`export_cesium.py:1-9`:创作用的场景刻意使用了一些 Blender 专有节点
|
||||
(草地 tint、程序化树冠变化),而 glTF 的材质词汇小得多。所以导出器
|
||||
**新建临时的、仅供导出的 PBR 材质**,展 UV,把引用到的图片全部内嵌进 GLB。
|
||||
|
||||
导出材质带 `EXPORT_PREFIX = "Cesium "` 前缀(`export_cesium.py:30`),
|
||||
这样第二遍扫到实例化网格的共享材质槽时能认出自己的产物、跳过不重复处理。
|
||||
|
||||
模型保持在**局部 ENU 坐标系**(X 东、Y 北、Z 上),靠伴生 JSON 配合
|
||||
`Cesium.Transforms.eastNorthUpToFixedFrame` 摆放。
|
||||
|
||||
### 四张覆盖表(按材质名字符串)
|
||||
|
||||
| 表 | 位置 | 作用 |
|
||||
|---|---|---|
|
||||
| `EXPORT_TINTS` | `:68` | 往某个颜色混合 |
|
||||
| `EXPORT_METALLIC_OVERRIDES` | `:80` | 平铺的金属度覆盖 |
|
||||
| `EXPORT_BASE_COLOR_OVERRIDES` | `:86` | 直接替换基色 |
|
||||
| `EXPORT_EMISSION_OVERRIDES` | `:95` | 自发光兜底 |
|
||||
|
||||
⚠️ `export_cesium.py` **不 import `catalog`**,靠材质名字符串匹配。
|
||||
`catalog.CESIUM_EXPORT` 是**死代码**。改材质名前先读
|
||||
[模块结构](./module-structure.md#已知现状两个入口靠材质名字符串对接)。
|
||||
|
||||
### 为什么新资产总是"发黑"
|
||||
|
||||
`export_cesium.py:38-54` 记录了这个反复出现的问题:
|
||||
|
||||
> Cesium 的默认光照偏白,**场景里每一个材质都被手工提亮过**——草往亮绿混 72%、
|
||||
> 带肋墙面往白混 86%、建筑自发光 0.18。一个没调过的新资产是唯一如实渲染的东西,
|
||||
> 放在旁边就显得发黑。
|
||||
|
||||
所以**加新资产时,"它在 Blender 里看着对"不代表在 Cesium 里对**,必须去四张表里
|
||||
给它配一份调校。
|
||||
|
||||
抠图植被走的是另一套(`FOLIAGE_ALBEDO_GAIN = 2.1` + `FOLIAGE_SATURATION = 1.75`,
|
||||
`:55, 66`),用**增益**而不是 tint——因为那是一张同时装着叶片、树皮、果实的图集,
|
||||
往绿色混会把树干也染绿。增益保留色相关系,只把整体曝光抬到和邻居一致。
|
||||
|
||||
`FOLIAGE_EMISSION = 0.25` 的职责只是给背光面兜底,**不是主要提亮手段**
|
||||
(`:32-36`)。想让植被更亮就调增益,别调自发光。
|
||||
|
||||
---
|
||||
|
||||
## 反模式
|
||||
|
||||
| 反模式 | 后果 |
|
||||
|---|---|
|
||||
| 每个形体建一个对象而不用 `MeshBatch` | 对象数与 glTF 节点数爆炸 |
|
||||
| 改 `Tree_` / `Scrub_` 命名前缀 | 平滑着色静默失效 |
|
||||
| 用无种子 `random` 或时间量做抖动 | parity 校验永久红 |
|
||||
| 每棵树复制一份 mesh/贴图 | .blend 与 GLB 体积按棵数线性膨胀 |
|
||||
| 直接 append vendored 资产的材质 | alpha-clip 缺失,树冠渲染成一块 |
|
||||
| 删掉"试过不行"的注释 | 下一个人重新踩同一个坑 |
|
||||
| 从 `scene-layers.js` 的 hex 换算 Blender 颜色 | 抹掉独立调过的配色 |
|
||||
| 加新资产不配 Cesium 调色 | Cesium 里显得发黑 |
|
||||
| 靠调 `FOLIAGE_EMISSION` 提亮植被 | 用错了旋钮,该调 albedo gain |
|
||||
| 在 `MATERIALS` 中间插入条目 | GLB 材质索引整体平移 |
|
||||
|
||||
---
|
||||
|
||||
## 相关
|
||||
|
||||
- [模块结构](./module-structure.md):往哪儿放新代码
|
||||
- [测试](./testing.md):纯几何部分怎么测
|
||||
- [图层表](../pipeline/layer-registry.md):道路九层的材质从哪来
|
||||
- [产物一致性指南](../guides/artifact-parity-guide.md):改完怎么验证产物没变
|
||||
146
.trellis/spec/blender/index.md
Normal file
146
.trellis/spec/blender/index.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Blender:Python 场景生成层
|
||||
|
||||
> 覆盖 `blender/**/*.py`。
|
||||
> 运行时:**两个**——Blender 内嵌 Python(bpy 层)和系统 Python(纯 Python 层)。
|
||||
> 这条内部边界是本层最重要的结构约束。
|
||||
|
||||
---
|
||||
|
||||
## 先读哪一篇
|
||||
|
||||
| 你要做的事 | 读 |
|
||||
|---|---|
|
||||
| 新建模块、挪代码、加一种 OSM 要素 | [模块结构](./module-structure.md) ← **先确认放在哪一层** |
|
||||
| 改几何构建、材质、实例化、Cesium 调色 | [资产生成](./asset-generation.md) |
|
||||
| 改 `geom.py` / `osm.py` 或加纯函数 | [测试](./testing.md) |
|
||||
| 改材质名、动 `MATERIALS` 顺序 | [模块结构 · 材质名对接](./module-structure.md#已知现状两个入口靠材质名字符串对接) |
|
||||
| 声称"纯重构,产物不变" | [产物一致性指南](../guides/artifact-parity-guide.md) |
|
||||
|
||||
---
|
||||
|
||||
## 依赖分层
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 纯 Python 层 —— 无 bpy,系统 python 可跑可测 │
|
||||
│ │
|
||||
│ osmassets/osm.py OSM XML 解析 + 局部米制投影 │
|
||||
│ osmassets/geom.py 平面几何(米制) │
|
||||
│ osmassets/catalog.py 图层与材质的声明 │
|
||||
│ │
|
||||
│ ▲ blender/tests/test_pure.py 覆盖这一层(51 个用例) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
▲ 只能单向依赖
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ bpy 层 —— 只在 Blender 内运行,无单元测试 │
|
||||
│ │
|
||||
│ osmassets/mesh.py MeshBatch 等几何构建 │
|
||||
│ osmassets/materials.py 材质构建(消费 catalog 的声明) │
|
||||
│ osmassets/tree.py 树实例化 │
|
||||
│ osmassets/water.py grass.py scrub.py 要素装配 │
|
||||
│ │
|
||||
│ generate_scene.py export_cesium.py 两个入口 │
|
||||
│ tools/scene_digest.py 结构摘要工具 │
|
||||
│ │
|
||||
│ ▲ 回归防线是 parity 校验,不是单元测试 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**在纯 Python 层里 `import bpy` 会静默废掉整个测试套件**——它不会失败,
|
||||
而是 import 阶段就崩,看起来像环境问题。
|
||||
|
||||
**推论**:能挪进纯 Python 层的逻辑就挪。一个函数只要不碰 `bpy`,放进 `geom.py`
|
||||
就立刻获得被测试覆盖的资格。
|
||||
|
||||
---
|
||||
|
||||
## 两个入口
|
||||
|
||||
| | `generate_scene.py` | `export_cesium.py` |
|
||||
|---|---|---|
|
||||
| 行数 | 999 | 624 |
|
||||
| 调用 | `--background --factory-startup --python` | `--background --python` |
|
||||
| 输入 | `--osm` + `--geojson`(可选) | `--blend` |
|
||||
| 输出 | `--output`(.blend)、`--render`(.png) | `--glb`、`--metadata`(.json) |
|
||||
| 完成标记 | `SCENE_DONE` | `CESIUM_EXPORT_DONE` |
|
||||
| 由谁调起 | `build-area.js` 的 `blender` 阶段 | `build-area.js` 的 `cesium` 阶段 |
|
||||
|
||||
两个 stdout 标记是 [parity 契约](../guides/artifact-parity-guide.md)的一部分
|
||||
(`scripts/parity.js:121` 解析它们),**改动打印格式等于改动契约**。
|
||||
|
||||
### `--factory-startup` 只在 generate 阶段用
|
||||
|
||||
它屏蔽本机 Blender 的 preferences 和 addon,保证场景生成不受用户配置影响。
|
||||
副作用是脚本自己的目录不在 `sys.path` 上,所以两个入口开头都有那段
|
||||
`sys.path.insert` 样板 + `# noqa: E402`——**不是可以整理掉的坏味道**。
|
||||
|
||||
---
|
||||
|
||||
## 场景构建的输入约定
|
||||
|
||||
`generate_scene.py:9-12` 记录了一个容易踩的坑:
|
||||
|
||||
> **范围只认 OSM 的 `bounds` 元素**,不用全部节点算包围盒。OSM 导出可能带上
|
||||
> 请求范围之外的 relation 成员,用全部节点会得到一个大得离谱的模型。
|
||||
|
||||
`--geojson` 是可选的。给了就用 osm2streets 的精细道路面、人行道、车道标线、
|
||||
斑马线;不给则回退到简单的 OSM `highway` 折线(`generate_scene.py:14-16`,
|
||||
回退逻辑在 `:849`)。
|
||||
|
||||
植被映射(`generate_scene.py:18-20`):
|
||||
|
||||
| OSM 标签 | 产物 |
|
||||
|---|---|
|
||||
| `natural=tree`(节点) | 单棵树 |
|
||||
| `natural=tree_row`(way) | 等距成排的树 |
|
||||
| `landuse=grass` | 绿地 + 可选草簇散布 |
|
||||
| `natural=scrub` | 低矮灌木覆盖 |
|
||||
| `amenity=fountain` | 低模喷泉水池 |
|
||||
|
||||
---
|
||||
|
||||
## 三条贯穿全层的约定
|
||||
|
||||
1. **构建必须确定性**
|
||||
用 `index` 的纯函数(无理数周期 / 黄金角)或显式 `seed` 代替 RNG,
|
||||
绝不用无种子 `random` 或时间量。重建同一片区域必须得到相同结构,
|
||||
否则 parity 校验永久红。见[资产生成](./asset-generation.md#确定性用无理数周期代替-rng)。
|
||||
|
||||
2. **压低对象数**
|
||||
`MeshBatch` 批处理 + 树实例化共享 datablock。GLB 要在浏览器里跑,
|
||||
节点数和贴图数都是硬成本。
|
||||
|
||||
3. **退化输入返回空,不抛异常**
|
||||
一个坏多边形不该中断整片区域的构建。要素模块 `len(ring) < 3` 直接返回 0,
|
||||
`MeshBatch` 静默 return,`geom.py` 的函数返回 `[]`。
|
||||
|
||||
---
|
||||
|
||||
## 文件速查
|
||||
|
||||
| 文件 | 行数 | 层 |
|
||||
|---|---|---|
|
||||
| `generate_scene.py` | 999 | bpy · 入口 |
|
||||
| `export_cesium.py` | 624 | bpy · 入口 |
|
||||
| `osmassets/tree.py` | 318 | bpy |
|
||||
| `osmassets/geom.py` | 241 | 纯 |
|
||||
| `osmassets/materials.py` | 218 | bpy |
|
||||
| `osmassets/catalog.py` | 195 | 纯 |
|
||||
| `osmassets/mesh.py` | 126 | bpy |
|
||||
| `osmassets/osm.py` | 89 | 纯 |
|
||||
| `osmassets/water.py` / `grass.py` / `scrub.py` | 15 / 22 / 13 | bpy |
|
||||
| `tools/scene_digest.py` | 171 | bpy · 工具 |
|
||||
| `tests/test_pure.py` | 372 | 纯 · 测试 |
|
||||
|
||||
`blender/README.md` 是面向使用者的运行说明,与本目录互补——**用法写那边,
|
||||
改法写这边**。
|
||||
|
||||
---
|
||||
|
||||
## 技术选型现状
|
||||
|
||||
- **Blender 4.x**,`bpy` + `mathutils`;`export_cesium.py` 额外用 `numpy`(Blender 自带)
|
||||
- **纯 Python 层只用标准库**(`math`、`json`、`os`、`xml.etree`),
|
||||
这是它能用系统 python 跑的前提——**不要给它加第三方依赖**
|
||||
- **无类型标注、无 lint 配置**。保持现状;引入工具链是独立决定
|
||||
- **`unittest` 而非 pytest**,零依赖跑得起来
|
||||
185
.trellis/spec/blender/module-structure.md
Normal file
185
.trellis/spec/blender/module-structure.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# osmassets 模块结构
|
||||
|
||||
> 适用:在 `blender/` 下新增或移动代码。
|
||||
> 核心是一条**按依赖切的边界**——切错了,整个测试套件就跑不起来。
|
||||
|
||||
---
|
||||
|
||||
## 按依赖分包,不按功能
|
||||
|
||||
`osmassets/__init__.py:3-12` 写明了这个包的切分原则:
|
||||
|
||||
> The package is split by dependency, not by feature:
|
||||
> `osm` and `geom` are pure Python. They import no `bpy` and can be run and
|
||||
> tested with a plain interpreter. Everything else may touch `bpy` and only
|
||||
> runs inside Blender.
|
||||
|
||||
```
|
||||
┌─ 纯 Python 层(无 bpy,可用系统 python 直接跑和测)
|
||||
│ osmassets/osm.py OSM XML 解析 + 局部米制投影
|
||||
│ osmassets/geom.py 平面几何:裁剪、采样、面积、点在多边形内
|
||||
│ osmassets/catalog.py 图层与材质的声明(只有 json/os,无 bpy)
|
||||
│
|
||||
└─ bpy 层(只能在 Blender 内运行)
|
||||
osmassets/mesh.py MeshBatch、prism、polyline
|
||||
osmassets/materials.py 材质构建
|
||||
osmassets/tree.py 树实例化
|
||||
osmassets/water.py grass.py scrub.py 要素装配
|
||||
generate_scene.py export_cesium.py 两个入口脚本
|
||||
tools/scene_digest.py 结构摘要工具
|
||||
```
|
||||
|
||||
**这条线是几何可测试的唯一前提。** 拆分之前,验证 `clip_polygon` 或 `sample_tree_row`
|
||||
的唯一办法是渲染整片区域然后看图(`__init__.py:9-12`、`test_pure.py:5-8`)。
|
||||
|
||||
> **在纯 Python 模块里写 `import bpy` 会静默废掉 51 个单元测试**——它们不会失败,
|
||||
> 而是 import 阶段就崩,看起来像环境问题。
|
||||
|
||||
`catalog.py` 属于纯 Python 层是刻意的:它只声明"是什么",让任何不启动 Blender 的
|
||||
工具也能读到场景的材质定义(`materials.py:3-7`)。
|
||||
|
||||
---
|
||||
|
||||
## 各模块职责
|
||||
|
||||
| 模块 | 层 | 职责 |
|
||||
|---|---|---|
|
||||
| `osm.py` | 纯 | `parse_osm()` 读 OSM XML → (bounds, ways, points);`Projector` 局部米制投影;`parse_height()`、`tags()` |
|
||||
| `geom.py` | 纯 | 平面几何全家桶。**输入输出一律是投影后的米**,例外只有 `geometry_rings` / `feature_in_bounds`(收原始 GeoJSON 的经纬度) |
|
||||
| `catalog.py` | 纯 | `ROAD_LAYERS`、`MATERIALS`、`road_material_specs()`、`check_layers()` |
|
||||
| `mesh.py` | bpy | `MeshBatch`、`make_prism`、`add_roof`、`add_wall_panel`、`add_polyline`、集合管理 |
|
||||
| `materials.py` | bpy | 把 `catalog` 的规格变成真实材质:`from_spec()`、贴图、程序化噪声、tint、alpha-clip |
|
||||
| `tree.py` | bpy | 两个 vendored 模型 → 一套可实例化的运行时形状 |
|
||||
| `water.py` / `grass.py` / `scrub.py` | bpy | 单一 OSM 要素的装配 |
|
||||
|
||||
### `geom.py` 的两条隐含约定
|
||||
|
||||
- **环是 `(x, y)` 元组的列表**。重复的闭合点"到处容忍但从不要求"
|
||||
(`geom.py:8-9`)——新函数要维持这个宽容度
|
||||
- **单位是米**,除非函数名另有说明
|
||||
|
||||
---
|
||||
|
||||
## 要素模块的统一形状
|
||||
|
||||
`water.py` / `grass.py` / `scrub.py` 三个要素模块签名一致:
|
||||
|
||||
```python
|
||||
def assemble(ring, [way_id,] scene_xmin, scene_xmax, scene_ymin, scene_ymax,
|
||||
<颜色/材质...>, [回调...]):
|
||||
ring = clip_polygon(ring, scene_xmin, scene_xmax, scene_ymin, scene_ymax)
|
||||
if len(ring) < 3:
|
||||
return 0[, ...]
|
||||
...
|
||||
return <计数>[, <焦点用的点集>]
|
||||
```
|
||||
|
||||
四条约定:
|
||||
|
||||
1. **自己裁剪**。收边界参数而不是收已裁剪的环,不假设调用方做过
|
||||
(三个 `assemble` 的第一行都是 `clip_polygon`)
|
||||
2. **退化输入返回零,不抛异常**。`len(ring) < 3` 直接返回计数 0
|
||||
3. **返回计数**供调用方汇总统计;需要参与相机取景的还返回点集(`grass.py` / `scrub.py`
|
||||
的 `focus`)
|
||||
4. **不自己找数据**。ring 由 `generate_scene.py` 传入,模块只负责装配
|
||||
|
||||
### 加一种新 OSM 要素
|
||||
|
||||
目标形态(`docs/refactor-plan.md`):**新增一个模块 + 注册一行,不改 `build()`**。
|
||||
|
||||
1. 新建 `osmassets/<feature>.py`,写 `assemble(...)`,签名照抄上面
|
||||
2. 只 import 需要的:`from osmassets.geom import clip_polygon`、
|
||||
`from osmassets.mesh import MeshBatch`
|
||||
3. 材质规格加进 `catalog.MATERIALS`(**追加到末尾**,顺序决定 GLB 材质索引)
|
||||
4. `generate_scene.py` 的要素分发处加一行调用
|
||||
5. 纯几何部分若有新函数,放 `geom.py` 并**补 `blender/tests/test_pure.py`**
|
||||
|
||||
---
|
||||
|
||||
## 两个入口脚本
|
||||
|
||||
| | `generate_scene.py` (999行) | `export_cesium.py` (624行) |
|
||||
|---|---|---|
|
||||
| 调用 | `--background --factory-startup --python` | `--background --python` |
|
||||
| 输入 | `--osm` + `--geojson` | `--blend` |
|
||||
| 输出 | `--output`(.blend) + `--render`(.png) | `--glb` + `--metadata`(.json) |
|
||||
| 完成标记 | `SCENE_DONE` | `CESIUM_EXPORT_DONE` |
|
||||
|
||||
**完成标记是 parity 契约的一部分**(`parity.js:121` 解析它们),改动打印格式等于改动
|
||||
契约。
|
||||
|
||||
### `sys.path` 那段样板不能删
|
||||
|
||||
两个脚本开头都有(`generate_scene.py:30-35`、`export_cesium.py:19-24`):
|
||||
|
||||
```python
|
||||
# --factory-startup does not put the script's own directory on sys.path, so the
|
||||
# osmassets package next to this file is not importable without this.
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
if _HERE not in sys.path:
|
||||
sys.path.insert(0, _HERE)
|
||||
```
|
||||
|
||||
因此其后的 import 全部带 `# noqa: E402`。这不是可以"整理"掉的坏味道。
|
||||
|
||||
### CLI 参数解析
|
||||
|
||||
两个脚本各有一份 `cli_args()`,都从 `--` 之后取参数:
|
||||
|
||||
```python
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
```
|
||||
|
||||
`export_cesium.py:118-124` 对三个必填项逐个 `raise RuntimeError`。**必填项显式抛错,
|
||||
不要给静默默认值**——Blender 子进程里一个错的默认路径会写到意想不到的地方。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 已知现状:两个入口靠材质名字符串对接
|
||||
|
||||
**这是当前实际状态,不是设计目标。改动材质名之前必读。**
|
||||
|
||||
`export_cesium.py` **不 import `catalog`**(它只 import
|
||||
`from osmassets.materials import link_alpha_clip`)。它自己维护四张以**材质名字符串**
|
||||
为键的覆盖表:
|
||||
|
||||
| 表 | 位置 |
|
||||
|---|---|
|
||||
| `EXPORT_TINTS` | `export_cesium.py:68` |
|
||||
| `EXPORT_METALLIC_OVERRIDES` | `:80` |
|
||||
| `EXPORT_BASE_COLOR_OVERRIDES` | `:86` |
|
||||
| `EXPORT_EMISSION_OVERRIDES` | `:95` |
|
||||
|
||||
后果:
|
||||
|
||||
- **改 `catalog.MATERIALS` 里的 `name` 会静默断开这些覆盖**。没有任何校验,
|
||||
材质只是悄悄退回未调过的样子
|
||||
- `catalog.CESIUM_EXPORT`(`catalog.py:142`)**是死代码**——定义了但全仓无人引用。
|
||||
它是一次未完成的迁移,不要以为改它会生效
|
||||
- `"Office White Metal Facade"` 在四张表里都有,但 `catalog` 里**已无此材质**
|
||||
(`docs/refactor-plan.md` 记为缺陷 D1,本轮只记录不修)
|
||||
|
||||
**改材质名时**:四张表 + `catalog.MATERIALS` + `catalog.ROAD_LAYERS` 全部 grep 一遍。
|
||||
|
||||
---
|
||||
|
||||
## 反模式
|
||||
|
||||
| 反模式 | 后果 |
|
||||
|---|---|
|
||||
| 在 `osm.py` / `geom.py` / `catalog.py` 里 `import bpy` | 51 个单元测试整体崩,且像环境问题 |
|
||||
| 按功能而非依赖新建模块(把几何和 bpy 混在一起) | 该几何从此不可测 |
|
||||
| 删掉 `sys.path.insert` 样板或 `# noqa: E402` | Blender 里 import 不到 osmassets |
|
||||
| 要素模块假设 ring 已裁剪 | 越界几何进场景 |
|
||||
| 要素模块对退化输入抛异常 | 一个坏多边形中断整片区域 |
|
||||
| 改材质名只改一处 | Cesium 侧调色静默失效 |
|
||||
| 以为改 `catalog.CESIUM_EXPORT` 会影响导出 | 它是死代码 |
|
||||
|
||||
---
|
||||
|
||||
## 相关
|
||||
|
||||
- [资产生成](./asset-generation.md):`MeshBatch`、材质、实例化
|
||||
- [测试](./testing.md):纯 Python 层怎么测
|
||||
- [图层表](../pipeline/layer-registry.md):`catalog.ROAD_LAYERS` 与 JS 侧的对账
|
||||
- [产物一致性指南](../guides/artifact-parity-guide.md):bpy 层的回归靠它兜底
|
||||
180
.trellis/spec/blender/testing.md
Normal file
180
.trellis/spec/blender/testing.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# 测试
|
||||
|
||||
> 适用:改动 `osmassets/osm.py`、`osmassets/geom.py`,或往里加新的纯函数。
|
||||
|
||||
---
|
||||
|
||||
## 怎么跑
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover blender/tests
|
||||
```
|
||||
|
||||
**不需要 Blender**,用系统 Python 就行。当前 51 个用例,运行约 0.01 秒。
|
||||
|
||||
这是全仓唯一的自动化测试。快到没有理由不在每次改动后跑一遍。
|
||||
|
||||
能这么跑的前提是 `osmassets` 的[依赖分层](./module-structure.md)——
|
||||
`test_pure.py:19` 手动把 `blender/` 塞进 `sys.path`,然后只 import 纯 Python 模块。
|
||||
|
||||
---
|
||||
|
||||
## 最重要的一条:期望值必须从几何推导
|
||||
|
||||
`test_pure.py:9-11` 写得很直白:
|
||||
|
||||
> The expected values are derived from the geometry, not captured from the
|
||||
> implementation — **a test that just records current output would ratify a bug.**
|
||||
|
||||
具体做法是在测试里写清楚**为什么**是这个数:
|
||||
|
||||
```python
|
||||
def test_half_outside_polygon_is_cut_at_the_boundary(self):
|
||||
clipped = clip_polygon(SQUARE, 0.0, 5.0, 0.0, 10.0)
|
||||
self.assertTrue(all(x <= 5.0 + 1e-9 for x, _ in clipped))
|
||||
# A 10x10 square clipped to half its width is a 5x10 rectangle.
|
||||
self.assertAlmostEqual(polygon_area(clipped), 50.0, places=6)
|
||||
```
|
||||
|
||||
反例——**不要这样写**:
|
||||
|
||||
```python
|
||||
def test_clip(self):
|
||||
# 跑一遍把输出粘过来
|
||||
self.assertEqual(clip_polygon(SQUARE, 0.0, 5.0, 0.0, 10.0),
|
||||
[(0.0, 0.0), (5.0, 0.0), (5.0, 10.0), (0.0, 10.0)])
|
||||
```
|
||||
|
||||
这种测试在实现正确时和实现错误时**同样会通过**。它固化的是当前行为,不是需求。
|
||||
|
||||
**自检**:把被测的那个特性从实现里删掉,测试还能过吗?能过就是无效测试。
|
||||
|
||||
---
|
||||
|
||||
## 每个几何函数都要覆盖退化输入
|
||||
|
||||
这是本套测试最系统的部分。`geom.py` 的函数会收到真实 OSM 数据里的各种畸形几何,
|
||||
所以每个函数都有一个 `test_degenerate_input`:
|
||||
|
||||
| 退化情形 | 例子 |
|
||||
|---|---|
|
||||
| 空输入 | `clip_polygon([], ...)` → `[]` (`:73`) |
|
||||
| 点数不足成面 | `clip_polygon([(0,0),(1,1)], ...)` → `[]` (`:74`) |
|
||||
| 完全在裁剪框外 | `clip_polygon(SQUARE, 20,30,20,30)` → `[]` (`:70`) |
|
||||
| 零长线段 | `sample_tree_row` 跳过而非除零 (`:203`) |
|
||||
| 重复顶点 | `distance_to_ring` 不除零 (`:178`) |
|
||||
| 参数为零 | `sample_ring_boundary(SQUARE, spacing=0.0)` → `[]` (`:123`) |
|
||||
| 单点输入 | `sample_polygon_interior([(0,0)], ...)` → `[]` (`:146`) |
|
||||
|
||||
**加新几何函数就配一个 `test_degenerate_input`。** 约定是"返回空/零"而不是抛异常——
|
||||
一个坏多边形不该中断整片区域的构建。
|
||||
|
||||
### 除零守卫要指名道姓
|
||||
|
||||
覆盖某个具体守卫时,注释写清楚针对哪一行:
|
||||
|
||||
```python
|
||||
def test_axis_aligned_edge_does_not_divide_by_zero(self):
|
||||
# A vertical edge crossing the x clip plane exercises the b[0] == a[0]
|
||||
# guard in the intersection lambdas.
|
||||
```
|
||||
|
||||
这样守卫被误删时,失败的测试能直接说明它保护的是什么(`test_pure.py:76-78`)。
|
||||
|
||||
---
|
||||
|
||||
## 其余几条约定
|
||||
|
||||
### 随机采样必须验 seed 可复现
|
||||
|
||||
```python
|
||||
def test_seed_is_deterministic(self):
|
||||
first = sample_polygon_interior(SQUARE, spacing=3.0, seed=7)
|
||||
second = sample_polygon_interior(SQUARE, spacing=3.0, seed=7)
|
||||
self.assertEqual(first, second)
|
||||
```
|
||||
|
||||
不可复现的采样会让 [parity 校验](../guides/artifact-parity-guide.md)永久性地红。
|
||||
任何带随机的新函数都要收 `seed` 参数并配这个测试(`test_pure.py:135-138`)。
|
||||
|
||||
### 绕向无关的性质要两个方向都测
|
||||
|
||||
`sample_ring_boundary` 的 `inset` 对顺时针和逆时针都必须往内缩:
|
||||
|
||||
```python
|
||||
ccw = sample_ring_boundary(SQUARE, spacing=10.0, inset=1.0)
|
||||
cw = sample_ring_boundary(list(reversed(SQUARE)), spacing=10.0, inset=1.0)
|
||||
self.assertTrue(all(point_in_polygon((x, y), SQUARE) for x, y, _, _ in ccw))
|
||||
self.assertTrue(all(point_in_polygon((x, y), SQUARE) for x, y, _, _ in cw))
|
||||
```
|
||||
|
||||
同理 `polygon_area` 对绕向不敏感、`signed_polygon_area` 敏感,两者分别验
|
||||
(`test_pure.py:88-91, 109-115`)。
|
||||
|
||||
### 跨边界的连续性要单独测
|
||||
|
||||
真实几何常见的坑是"分段处理时每段各自重新开始":
|
||||
|
||||
```python
|
||||
def test_spacing_carries_across_segment_joins(self):
|
||||
# Two 3m segments with 4m spacing: the second sample must land 1m into
|
||||
# the second segment, not restart at its origin.
|
||||
```
|
||||
|
||||
(`test_pure.py:190-196`)
|
||||
|
||||
### 解析器的容错语义要写死
|
||||
|
||||
`parse_osm` 的两档行为必须都有测试(`test_pure.py:343-356`):
|
||||
|
||||
- **坏节点跳过,不致命**:`lon='oops'` 的节点被忽略,其余照常解析
|
||||
- **缺 `bounds` 直接抛 `RuntimeError`**:没有 bounds 就无法建立投影,继续下去毫无意义
|
||||
|
||||
分档理由见 `generate_scene.py:9-12`:OSM 导出可能带上区域外的 relation 成员,
|
||||
所以范围只认 `bounds` 元素,不用全部节点算包围盒。
|
||||
|
||||
### 用真实格式的 fixture
|
||||
|
||||
`OSM_SAMPLE`(`test_pure.py:285`)是一段真的 OSM XML,故意塞进了坏节点、
|
||||
`action='delete'` 的 way、引用了不存在节点的 way。写进临时文件再解析,
|
||||
`tearDown` 里 `os.unlink`。
|
||||
|
||||
**别 mock 解析器。** 解析器的价值就在于处理真实世界的脏数据。
|
||||
|
||||
---
|
||||
|
||||
## 命名
|
||||
|
||||
- 类名 = 被测函数的驼峰 + `Test`:`ClipPolygonTest`、`SampleTreeRowTest`
|
||||
- 方法名是**陈述句,说明这个行为是什么**,不是 `test_case_1`:
|
||||
`test_polygon_keeps_only_the_exterior_ring`、
|
||||
`test_trailing_point_is_skipped_when_it_would_double_plant`
|
||||
|
||||
方法名读起来就是这个函数的规格说明。
|
||||
|
||||
---
|
||||
|
||||
## 测不到的部分怎么办
|
||||
|
||||
`bpy` 层(`mesh.py`、`materials.py`、`tree.py`、两个入口脚本)**没有单元测试**,
|
||||
也不打算有——它们需要真实的 Blender 运行时。
|
||||
|
||||
这一层的回归防线是 **parity 校验**:结构摘要比对,而不是单元测试。
|
||||
见[产物一致性指南](../guides/artifact-parity-guide.md)。
|
||||
|
||||
**推论**:能挪进纯 Python 层的逻辑就挪。一个函数只要不碰 `bpy`,
|
||||
放进 `geom.py` 就立刻获得测试覆盖的资格。
|
||||
|
||||
---
|
||||
|
||||
## 反模式
|
||||
|
||||
| 反模式 | 后果 |
|
||||
|---|---|
|
||||
| 把当前输出粘成期望值 | 实现有 bug 时测试照样绿 |
|
||||
| 新几何函数不配退化输入测试 | 真实数据一进来就崩 |
|
||||
| 退化输入抛异常而不是返回空 | 一个坏多边形中断整片区域 |
|
||||
| 带随机的函数不收 `seed` | parity 校验永久红 |
|
||||
| mock 掉 OSM 解析 | 测不到它唯一的价值 |
|
||||
| 在测试里 import `bpy` 侧模块 | 整个套件无法运行 |
|
||||
| 只测一种绕向 | 反向多边形进来才发现 |
|
||||
187
.trellis/spec/config/index.md
Normal file
187
.trellis/spec/config/index.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Config:区域配置
|
||||
|
||||
> 覆盖 `config/areas/*.json`、`config/examples/template.json`、`config/default.json`。
|
||||
> 无运行时——这是**管线三层共同消费的契约**,改一个字段会同时影响 Node、Blender、
|
||||
> 预览页。
|
||||
|
||||
---
|
||||
|
||||
## 两层配置
|
||||
|
||||
用户只写第一层,第二层是机器生成的中间产物:
|
||||
|
||||
```
|
||||
config/areas/<id>.json ← 你写的
|
||||
│ build-area.js: normalizeAreaConfig() 补默认值 + 推导 14 个输出路径
|
||||
▼
|
||||
<areaDir>/_pipeline/osm2streets-qgis.config.json ← 生成的,不要手改
|
||||
│
|
||||
▼ build-osm2streets-qgis.js / reimport-gpkg.js
|
||||
```
|
||||
|
||||
派生配置落在 `_pipeline/` 而不是临时目录——**构建失败时它还在**,可以直接拿去复现。
|
||||
|
||||
`config/default.json` 和 `config/hanyang-block.json` 是**低层脚本**
|
||||
(`npm run build:qgis`)用的旧格式配置,与 `config/areas/` 不是一回事。
|
||||
新工作一律用 `config/areas/`。
|
||||
|
||||
---
|
||||
|
||||
## 新增一个区域
|
||||
|
||||
```bash
|
||||
cp config/examples/template.json config/areas/my-area.json
|
||||
```
|
||||
|
||||
改 `id` 和 `input` 就能跑。其余全有默认值。
|
||||
|
||||
---
|
||||
|
||||
## 字段全表
|
||||
|
||||
### 顶层
|
||||
|
||||
| 字段 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `id` | ✅ | — | 区域标识。**同时是默认输出目录名和全部产物的文件名 stem** |
|
||||
| `input` | ✅ | — | OSM XML 的**绝对路径**。不存在直接抛错 |
|
||||
| `outputRoot` | | `<repo>/outputs` | 输出根目录 |
|
||||
| `qgisApp` | | `/Applications/QGIS.app` | 也可用环境变量 `QGIS_APP` |
|
||||
| `blenderApp` | | `/Applications/Blender.app` | |
|
||||
| `stages` | | 见下 | 各阶段默认开关 |
|
||||
| `qgis` | | 见下 | QGIS/osm2streets 旋钮 |
|
||||
| `osm2streets` | | 见下 | 透传给 osm2streets 的选项 |
|
||||
| `blender` | | 见下 | Blender 侧选项 |
|
||||
| `outputs` | | 从 `id` 推导 | 输出路径覆盖,逃生舱 |
|
||||
|
||||
**路径一律绝对**。`normalizeAreaConfig` 对每一项都做 `path.resolve`,相对路径会
|
||||
相对于**进程 cwd** 解析,不是相对于配置文件——所以别用。
|
||||
|
||||
### `stages`
|
||||
|
||||
| 字段 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `intermediates` | `true` | 旧名 `qgis` 仍被接受 |
|
||||
| `blender` | `true` | |
|
||||
| `cesium` | `true` | |
|
||||
|
||||
`reimport` 和 `preview` **在这里配也没用**——`normalizeAreaConfig:117-118` 把它们
|
||||
硬编码为 `false`,只能靠 `--stages` 显式请求。
|
||||
|
||||
> 恢复动作(reimport)和补丁动作(preview)不该被一份配置文件变成默认行为。
|
||||
|
||||
`--stages` 会整体覆盖这里的默认值。
|
||||
|
||||
### `qgis`
|
||||
|
||||
| 字段 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `arrowScale` | `0.8` | 导出前对 osm2streets 车道箭头多边形的缩放 |
|
||||
| `arrowMergeTriangles` | `true` | 把箭头的三角网合并成一个合法多边形。**保留原箭头形状和转向**,同时消掉共享三角边处的渲染缝隙 |
|
||||
| `arrowOutlineSimplifyMeters` | `0.05` | 去掉合并后箭头外轮廓上的亚分米级折角。默认值刚好去掉两个畸形尾顶点而**不动箭头头部**,剩下的尾边与杆身垂直 |
|
||||
| `intersectionCornerSourceMaxDimensionMeters` | `2.6` | 只保留小尺寸的 `sidewalk corner` 多边形。**大的路口标记多边形不当人行道处理**,因为它们会盖住可行驶的路口 |
|
||||
| `clipPad` | `0.002` | 送进 osm2streets 的裁剪框外扩(度) |
|
||||
| `canvasPad` | `0.001` | QGIS 画布范围外扩(度) |
|
||||
| `previewPad` | `0.0007` | 预览图范围外扩(度) |
|
||||
| `canvasExtent` | `null` | 显式画布范围,覆盖 `canvasPad` |
|
||||
| `previewExtent` | `null` | 显式预览范围,覆盖 `previewPad` |
|
||||
| `layerPrefix` | `"osm2streets"` | QGIS 图层名前缀 |
|
||||
|
||||
三个 pad 单位是**度不是米**,且必须 `>= 0`(`build-osm2streets-qgis.js:49-53` 校验)。
|
||||
`arrowScale` 必须 `> 0`。
|
||||
|
||||
> 这四个 arrow/corner 旋钮的默认值都是调出来的,**改之前先看 README 里记的理由**。
|
||||
> 尤其 `arrowOutlineSimplifyMeters`——调大会开始削箭头头部。
|
||||
|
||||
### `osm2streets`
|
||||
|
||||
原样透传给 `JsStreetNetwork` 构造函数(`build-osm2streets-qgis.js:77`)。默认:
|
||||
|
||||
```json
|
||||
{
|
||||
"debug_each_step": false,
|
||||
"dual_carriageway_experiment": false,
|
||||
"sidepath_zipping_experiment": false,
|
||||
"inferred_sidewalks": true,
|
||||
"osm2lanes": true
|
||||
}
|
||||
```
|
||||
|
||||
⚠️ **给了就整体替换,不做逐字段合并**(`build-area.js:132`:`raw.osm2streets || {...}`)。
|
||||
只想改一个开关也必须把五个字段全写上,否则其余四个会退到 osm2streets 自己的默认值。
|
||||
|
||||
### `blender`
|
||||
|
||||
| 字段 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `treeStyle` | `"natural"` | 合法值见 `generate_scene.py:144` 的 `TREE_STYLES`(`natural`、`procedural`,加上 `tree.py` 注册的模型 style) |
|
||||
| `officeOverrides` | `""` | 旧名 `office_overrides` 仍被接受 |
|
||||
|
||||
### `outputs`(逃生舱)
|
||||
|
||||
默认全部从 `id` 推导为 `<outputRoot>/<id>/<fileStem>.<ext>`。需要定制时逐项覆盖:
|
||||
|
||||
```json
|
||||
{
|
||||
"outputs": {
|
||||
"areaDir": "/absolute/path/to/custom-area",
|
||||
"blend": "/absolute/path/to/custom.blend",
|
||||
"glb": "/absolute/path/to/custom.glb",
|
||||
"cesiumPreview": "/absolute/path/to/custom-preview.html"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
可覆盖的键(`build-area.js:87-102`):`areaDir`、`fileStem`、`geojsonDir`、`gpkg`、
|
||||
`qgisProject`、`qgisPreview`、`blend`、`render`、`glb`、`metadata`、`cesiumPreview`、
|
||||
`vehicleRoute`、`vehicleModel`、`pipelineDir`。
|
||||
|
||||
**优先改 `fileStem` 或 `areaDir`**——它们能一次性影响全部派生路径。逐个覆盖容易漏。
|
||||
|
||||
---
|
||||
|
||||
## 加一个配置字段
|
||||
|
||||
1. `normalizeAreaConfig`(`build-area.js:74`)里加进对应的分组,**用 `??` 不用 `||`**
|
||||
(`false` / `0` 可能是合法值)
|
||||
2. 只写两级 fallback:`raw.<group>?.<key> ?? 默认值`。
|
||||
**不要**制造新的顶层平铺别名——那三级写法是历史兼容,不是模式
|
||||
3. 若要传给低层脚本,加进 `writeDerivedConfig`(`:189`)的 `derivedConfig` 对象
|
||||
4. 若是数值,在消费侧加 `Number.isFinite` + 范围校验,**在任何副作用之前**
|
||||
5. 更新 `config/examples/template.json`
|
||||
6. 更新本文档的字段表
|
||||
|
||||
若新字段产出新文件,同时在 `outputs` 里加一行路径推导。
|
||||
|
||||
---
|
||||
|
||||
## 已沉淀的区域
|
||||
|
||||
- `config/areas/nantaizi-lake-innovation-valley.json`(默认构建目标)
|
||||
- `config/areas/hanyang-block.json`
|
||||
|
||||
两者都是 parity 校验的样本区域(`parity.js:26` 的 `DEFAULT_AREAS`)——
|
||||
**改动它们会影响基线比对**。
|
||||
|
||||
---
|
||||
|
||||
## 反模式
|
||||
|
||||
| 反模式 | 后果 |
|
||||
|---|---|
|
||||
| 用相对路径 | 相对 cwd 解析,换个目录跑就错 |
|
||||
| 手改 `_pipeline/*.config.json` | 下次构建被覆盖 |
|
||||
| 只写 `osm2streets` 的一个字段 | 其余四个静默退到 osm2streets 默认值 |
|
||||
| 布尔字段用 `\|\|` 兜底 | `false` 被翻转 |
|
||||
| 给新字段造顶层平铺别名 | 扩大历史包袱 |
|
||||
| 逐个覆盖 `outputs` 而不用 `fileStem` | 漏掉某个产物路径 |
|
||||
| 在 `stages` 里配 `reimport` / `preview` | 无效,被硬编码为 false |
|
||||
| 加数值字段不做范围校验 | 错配置在中途才崩,输出已被破坏 |
|
||||
|
||||
---
|
||||
|
||||
## 相关
|
||||
|
||||
- [CLI 与阶段](../pipeline/cli-and-stages.md):配置怎么被读取和派生
|
||||
- [外部工具调用](../pipeline/external-tools.md):`qgisApp` / `blenderApp` 怎么用
|
||||
- README「区域配置」节:面向使用者的说明
|
||||
201
.trellis/spec/guides/artifact-parity-guide.md
Normal file
201
.trellis/spec/guides/artifact-parity-guide.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# 产物一致性(Parity)指南
|
||||
|
||||
> **触发条件**:任何声称"纯重构、产物不变"的改动。
|
||||
>
|
||||
> 这条管线的产物是 `.blend` / `.glb` / `.png`——**二进制、无法 code review、
|
||||
> 肉眼看不出 5% 的几何漂移**。parity 校验是这一层唯一的回归防线。
|
||||
|
||||
---
|
||||
|
||||
## 为什么不能直接比字节
|
||||
|
||||
三类文件全都**不是位级可复现**的,同一份代码跑两次就会不一样:
|
||||
|
||||
| 产物 | 为什么不稳定 |
|
||||
|---|---|
|
||||
| `.blend` | 内嵌绝对路径;图片按哈希表顺序打包 |
|
||||
| `.png` | EEVEE 渲染非位级可复现 |
|
||||
| `.glb` | glTF 导出器会去重相同的 accessor,而 `smart_project` 的 UV 带浮点噪声——实测两次跑出 399 vs 398 个 accessor、差 720 字节,而 node/mesh/primitive/material/image **完全一致** |
|
||||
|
||||
所以比对的是**结构摘要**,不是字节。
|
||||
|
||||
---
|
||||
|
||||
## 三件套
|
||||
|
||||
| 工具 | 位置 | 作用 |
|
||||
|---|---|---|
|
||||
| 场景摘要 | `blender/tools/scene_digest.py` | 在 Blender 内打开 `.blend`,输出稳定 JSON:对象名/顶点数/面数/材质槽/自定义属性、材质参数、场景属性 |
|
||||
| GLB 摘要 | `scripts/glb-digest.js` | 纯 Node 读 GLB 的 JSON chunk,输出 node/mesh/material 清单与 PBR 参数,附 buffer 字节长度 |
|
||||
| 驱动 | `scripts/parity.js` | 跑构建 → 采集摘要 → 快照 / 比对 |
|
||||
|
||||
```bash
|
||||
node scripts/parity.js capture <label> [--areas a,b] [--stages blender,cesium]
|
||||
node scripts/parity.js compare <labelA> <labelB>
|
||||
```
|
||||
|
||||
基线落在 `outputs/_refactor-baseline/<label>/`,在 `.gitignore` 里——
|
||||
**本地草稿,不是产物,不入库**(`parity.js:16-17`)。
|
||||
|
||||
默认样本区域两个(`parity.js:26`):`nantaizi-lake-innovation-valley`(主,
|
||||
OSM + osm2streets GeoJSON 齐全)、`hanyang-block`(次)。
|
||||
|
||||
---
|
||||
|
||||
## 必须先做 control 实验
|
||||
|
||||
**这是最容易被跳过、跳过之后整个校验就是假的一步。**
|
||||
|
||||
用**未改动**的代码连跑两次,diff 两份摘要。这一步确定哪些字段天然不确定,
|
||||
把它们列入忽略名单。
|
||||
|
||||
```bash
|
||||
node scripts/parity.js capture control-1
|
||||
node scripts/parity.js capture control-2
|
||||
node scripts/parity.js compare control-1 control-2 # 必须全绿
|
||||
```
|
||||
|
||||
没做这步就开始改代码,你会分不清一个差异是"重构引入的 bug"还是"本来就每次都不一样"。
|
||||
|
||||
已完成的 control 结论(`docs/refactor-plan.md`):
|
||||
|
||||
- `.blend` **结构摘要两次完全一致** ← 这是主校验信号,可信
|
||||
- `.blend` 文件 sha256 不一致
|
||||
- 渲染 PNG sha256 不一致
|
||||
- GLB 结构(node / mesh / primitive / material / image)两次完全一致,
|
||||
但 accessor 数 399 vs 398、buffer 差 720 字节
|
||||
|
||||
---
|
||||
|
||||
## 忽略名单:必须附理由
|
||||
|
||||
`parity.js:25-54` 的 `IGNORED_PATHS`,每一条上面都写着为什么被忽略:
|
||||
|
||||
```
|
||||
files.blend.sha256 / files.glb.{sha256,bytes} / files.render.{sha256,bytes}
|
||||
glbDigest.fileBytes / glbDigest.buffers / glbDigest.counts.accessors
|
||||
capturedAt / durationMs / label
|
||||
```
|
||||
|
||||
这些字段**仍然被记录**——人读快照时想看到它们——只是不参与比对
|
||||
(`parity.js:25-27`)。
|
||||
|
||||
> **往忽略名单里加东西是有代价的动作。**
|
||||
> 加之前先确认这个字段是**真的**每次都变(用 control 实验证明),
|
||||
> 而不是你的改动让它变了。注释里必须写清楚证据。
|
||||
|
||||
---
|
||||
|
||||
## 真正的契约
|
||||
|
||||
忽略名单之外剩下的就是契约,**改动它们 = 改动产物**:
|
||||
|
||||
| 契约项 | 谁产生 |
|
||||
|---|---|
|
||||
| `SCENE_DONE` stdout 标记及其 JSON 内容 | `generate_scene.py` |
|
||||
| `CESIUM_EXPORT_DONE` stdout 标记及其 JSON 内容 | `export_cesium.py` |
|
||||
| `.blend` 全量结构摘要(对象、网格、材质、自定义属性) | `scene_digest.py` |
|
||||
| GLB 的 node / mesh / material / image 结构 | `glb-digest.js` |
|
||||
| `<area>.json` 放置元数据 | `export_cesium.py` |
|
||||
|
||||
**改动 stage 的打印格式会静默破坏 parity 契约**——`parity.js:118-121` 解析这两个标记。
|
||||
|
||||
---
|
||||
|
||||
## 摘要工具本身的两条约定
|
||||
|
||||
改 `scene_digest.py` / `glb-digest.js` 时:
|
||||
|
||||
1. **浮点数四舍五入到 6 位**(`scene_digest.py:17-18`)。Blender 会把浮点数
|
||||
round-trip 过单精度,repr 的最后几位不是有意义的信号
|
||||
2. **不稳定字段属于 `UNSTABLE_*` / `IGNORED_*` 名单,不属于摘要**
|
||||
(`scene_digest.py:11-13`)。Blender 自己加的对象自定义属性
|
||||
(`_RNA_UI`、`cycles`)就是这么排除的(`scene_digest.py:24-25`)
|
||||
|
||||
> 否则校验就是噪音,然后就会被忽略。一个天天报红的检查等于没有检查。
|
||||
|
||||
---
|
||||
|
||||
## 什么时候必须跑
|
||||
|
||||
| 改动 | 要不要跑 |
|
||||
|---|---|
|
||||
| 挪函数、拆模块、改导入 | **必须**——纯重构的定义就是产物不变 |
|
||||
| 调整 `ROAD_LAYERS` / `MATERIALS` 的**顺序** | **必须**——会平移 GLB 材质索引 |
|
||||
| 改材质名 | **必须**——可能静默断开 Cesium 侧的四张覆盖表 |
|
||||
| 改几何构建、采样、实例化逻辑 | **必须** |
|
||||
| 改 stage 的 stdout 打印 | **必须**——标记本身是契约 |
|
||||
| 改 `.trellis/` 下的文档 | 不用 |
|
||||
| 改 README / changelog | 不用 |
|
||||
| **有意**改变产物(新功能、修渲染 bug) | 跑,但目的是**看清差异范围**,不是要全绿 |
|
||||
|
||||
最后一行很重要:parity 不只是"证明没变"的工具,也是"确认只变了预期的那部分"的工具。
|
||||
加一种新植被,应该只看到新增对象,不该看到道路网格的顶点数也动了。
|
||||
|
||||
---
|
||||
|
||||
## 有意改变产物时怎么做
|
||||
|
||||
1. 先 capture 一份改动前的基线
|
||||
2. 改
|
||||
3. capture 改动后
|
||||
4. compare,**逐条读差异**
|
||||
5. 差异要么是预期的,要么就是 bug——**没有第三种**
|
||||
6. 把结论写进 `docs/changelog.md`
|
||||
|
||||
---
|
||||
|
||||
## 风险高的改动要分次提交
|
||||
|
||||
`docs/refactor-plan.md` 对风险最高的一期写着:
|
||||
|
||||
> 逐要素分次提交,每次单独跑 parity。
|
||||
|
||||
一次改十个要素然后发现摘要有差异,你不知道是哪个引起的。**一次一个,每次跑校验。**
|
||||
|
||||
---
|
||||
|
||||
## 当前重构进度(`docs/refactor-plan.md`)
|
||||
|
||||
那份计划是**临时工作文档**,P3 收尾后会并入 changelog 并删除。当前状态:
|
||||
|
||||
| 期 | 内容 | 状态 |
|
||||
|---|---|---|
|
||||
| P0 | 抽纯函数到 `osmassets/{osm,geom}.py` | ✅ 已完成 |
|
||||
| P1 | `catalog.py` 单一定义源 + `check_layers` | ✅ 已完成 |
|
||||
| P2 | 要素注册表 | ⚠️ **部分**——`water/grass/scrub/tree.py` 已拆出,但**没有 `features/` 注册表**,`building` / `fountain` / `roads` 仍在 `generate_scene.py` 里 |
|
||||
| P3 | 材质契约化(自定义属性传递 spec) | ❌ **未做**——`export_cesium.py` 仍不 import `catalog`,靠四张材质名表;`catalog.CESIUM_EXPORT` 是死代码 |
|
||||
|
||||
### 已知缺陷(记录在案,本轮不修)
|
||||
|
||||
| # | 位置 | 现象 |
|
||||
|---|---|---|
|
||||
| D1 | `export_cesium.py:74,82,91,100` | `"Office White Metal Facade"` 四张表里都有,但 `catalog` 里已无此材质——死条目 |
|
||||
| D2 | `scene-layers.js` vs `catalog.py` | 同一批图层的颜色两侧各自手调,无一致性保证(**这是刻意的**,见[图层表](../pipeline/layer-registry.md#为什么颜色刻意不同步)) |
|
||||
| D3 | `generate_scene.py` `tuft_density_wave` | 注释仍在跟已删除的 hedge banding 作对比 |
|
||||
|
||||
**碰到它们不要顺手修**——修复会改变产物或扩大 diff,属于独立决定。
|
||||
|
||||
---
|
||||
|
||||
## 反模式
|
||||
|
||||
| 反模式 | 后果 |
|
||||
|---|---|
|
||||
| 跳过 control 实验直接开始改 | 分不清真回归和天然噪声 |
|
||||
| 因为"老是报红"往忽略名单里加字段 | 把真回归一起忽略掉 |
|
||||
| 忽略名单不写理由 | 下一个人无法判断该不该移出来 |
|
||||
| 直接 diff 文件字节 | 永远红,然后所有人都不看了 |
|
||||
| 一次改十个地方再跑校验 | 差异定位不到具体改动 |
|
||||
| 改 stage 打印格式 | 静默破坏契约 |
|
||||
| 摘要里保留不稳定字段 | 检查变噪音,最终被忽略 |
|
||||
| 顺手修 D1–D3 | 改变产物或扩大 diff |
|
||||
|
||||
---
|
||||
|
||||
## 相关
|
||||
|
||||
- [模块结构](../blender/module-structure.md):bpy 层为什么没有单元测试
|
||||
- [测试](../blender/testing.md):纯 Python 层的防线
|
||||
- [资产生成](../blender/asset-generation.md):为什么构建必须确定性
|
||||
- [图层表](../pipeline/layer-registry.md):顺序为什么是承重的
|
||||
158
.trellis/spec/guides/code-reuse-thinking-guide.md
Normal file
158
.trellis/spec/guides/code-reuse-thinking-guide.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# 代码复用思考指南
|
||||
|
||||
> 目的:在新增 helper、常量、配置字段或枚举表之前,先判断这个项目里"应该复用"和
|
||||
> "刻意重复"的边界。这里的关键不是追求抽象,而是避免事实漂移。
|
||||
|
||||
---
|
||||
|
||||
## 先搜索,再决定
|
||||
|
||||
改任何值或新增类似逻辑前先跑:
|
||||
|
||||
```bash
|
||||
grep -rn "关键字或现有值" scripts blender config
|
||||
```
|
||||
|
||||
本项目的重复有两类:
|
||||
|
||||
- **危险重复**:同一事实被多处维护,漏改会静默错产物
|
||||
- **可接受重复**:运行时边界不同或独立入口需要保留,抽象会扩大耦合
|
||||
|
||||
判断之前不要凭直觉抽取。
|
||||
|
||||
---
|
||||
|
||||
## 必须复用的事实源
|
||||
|
||||
### 九个 osm2streets 图层
|
||||
|
||||
JS 侧只认 `scripts/lib/scene-layers.js:15` 的 `SCENE_LAYERS`。
|
||||
需要文件名、合并场景、style JSON、QGIS 颜色时,使用同文件导出的派生函数:
|
||||
|
||||
- `layerFile(layer)`(`scene-layers.js:103`)
|
||||
- `mergeScene(getCollection)`(`scene-layers.js:109`)
|
||||
- `sceneStyle()`(`scene-layers.js:126`)
|
||||
- `qgisRgba(hex, alpha)`(`scene-layers.js:143`)
|
||||
|
||||
不要在 `build-osm2streets-qgis.js`、`reimport-gpkg.js` 或 QGIS 项目生成代码里再枚举
|
||||
九个图层。旧问题正是同一顺序复制到四处,漏一处不报错,只让 Blender/Cesium 场景错栈。
|
||||
|
||||
Python 侧必须有 `blender/osmassets/catalog.py:28` 的 `ROAD_LAYERS`,因为它还声明
|
||||
Blender 高度与线性颜色。两侧靠 `catalog.check_layers()` 对账集合和顺序;颜色故意不同步。
|
||||
|
||||
### 区域输出路径
|
||||
|
||||
输出路径只在 `scripts/build-area.js:74` 的 `normalizeAreaConfig()` 推导。
|
||||
低层脚本读取 `_pipeline/osm2streets-qgis.config.json`,不要重新读取
|
||||
`config/areas/*.json` 或在阶段函数里现场拼路径。
|
||||
|
||||
新增产物时,在 `normalizeAreaConfig` 的 `outputs` 里加一项,再按需写入
|
||||
`writeDerivedConfig()`(`build-area.js:189`)。这样 `intermediates`、`reimport`、
|
||||
`blender`、`cesium`、`preview` 仍然只通过磁盘产物耦合。
|
||||
|
||||
### 材质声明
|
||||
|
||||
Blender 内材质声明集中在 `catalog.MATERIALS`(`catalog.py:56`)。
|
||||
真实 `bpy.types.Material` 由 `materials.from_spec()`(`materials.py:198`)构建。
|
||||
|
||||
注意当前有一个未完成迁移:`export_cesium.py:68`、`:80`、`:86`、`:95` 的四张表
|
||||
仍按材质名字符串匹配。改材质名时不能只改 `catalog`;必须全仓 grep 材质名。
|
||||
|
||||
---
|
||||
|
||||
## 可接受的重复
|
||||
|
||||
### 三份 `parseArgs`
|
||||
|
||||
`parseArgs` 现在重复在三个独立入口:
|
||||
|
||||
- `scripts/build-area.js:50`
|
||||
- `scripts/build-osm2streets-qgis.js:153`
|
||||
- `scripts/reimport-gpkg.js:93`
|
||||
|
||||
语义一致:`--kebab-case value` 变 `kebabCase: "value"`,无值 flag 变字符串 `"true"`。
|
||||
|
||||
这份重复目前是可接受技术债,因为三个脚本都能独立运行。改其中一处解析语义时,不要顺手
|
||||
只改一份;要么保持三份一致,要么把"抽公共模块"作为独立重构并跑 parity。
|
||||
|
||||
### JS 与 Python 的图层颜色
|
||||
|
||||
`scene-layers.js` 的颜色是 QGIS 2D 调试 sRGB hex;`catalog.py` 的颜色是 Blender
|
||||
线性 RGB。`catalog.py:11-15` 明确说颜色不是同步目标。
|
||||
|
||||
把两边颜色抽成同一个表不是复用,是破坏两个运行时各自调过的视觉结果。
|
||||
|
||||
---
|
||||
|
||||
## 重复模式检查
|
||||
|
||||
### 看到第二份枚举表
|
||||
|
||||
问:
|
||||
|
||||
- 这份表是否已经能从 `SCENE_LAYERS`、`ROAD_LAYERS`、`MATERIALS` 或配置派生?
|
||||
- 如果必须跨语言重复,是否已有对账机制?
|
||||
- 追加顺序是否影响 GLB 材质索引?
|
||||
|
||||
没有对账机制的重复表必须特别谨慎。材质名覆盖就是当前已知风险:
|
||||
`generate_scene.py` 创建材质,`export_cesium.py` 靠字符串覆盖,没有校验。
|
||||
|
||||
### 看到多个模块同样预处理
|
||||
|
||||
`water.py:9`、`grass.py:9`、`scrub.py:8` 都调用 `clip_polygon`,这是对要素模块签名的
|
||||
统一要求:模块接收边界、自己裁剪、退化输入返回 0。
|
||||
|
||||
新增第四个要素模块时先照这个形状写,不要把裁剪逻辑上移到调用方。否则旧模块和新模块
|
||||
的边界会不同,真实 OSM 的越界几何会按要素类型表现不一致。
|
||||
|
||||
### 看到多个地方解析同一格式
|
||||
|
||||
优先找已有解析器:
|
||||
|
||||
- OSM XML → `osmassets/osm.py:parse_osm()`
|
||||
- 米制几何 → `osmassets/geom.py`
|
||||
- GeoJSON 场景合并 → `scene-layers.js:mergeScene(getCollection)`
|
||||
- 区域配置 → `build-area.js:normalizeAreaConfig()`
|
||||
|
||||
如果确实需要新解析器,把输入格式、容错语义和调用者写清楚,并给纯 Python 逻辑补测试。
|
||||
|
||||
---
|
||||
|
||||
## 什么时候抽象
|
||||
|
||||
抽象只在满足至少一条时做:
|
||||
|
||||
- 同一事实会被三处以上消费,且有真实漏改风险
|
||||
- 同一段校验逻辑跨多个入口影响产物安全
|
||||
- 抽出来后能保留运行时边界,比如纯 Python 逻辑进入 `geom.py` 后可被
|
||||
`python3 -m unittest discover blender/tests` 覆盖
|
||||
|
||||
不要因为代码相似就抽象:
|
||||
|
||||
- 三份 `parseArgs` 当前保持独立入口价值
|
||||
- `ROAD_LAYERS` 与 `SCENE_LAYERS` 跨语言且承载不同字段
|
||||
- 每个要素模块各自调用 `clip_polygon` 是模块边界,不是可消除重复
|
||||
|
||||
---
|
||||
|
||||
## 提交前自检
|
||||
|
||||
- [ ] 已 grep 关键值或新字段
|
||||
- [ ] 没有新增第二份九图层枚举
|
||||
- [ ] 没有在阶段函数里重新拼输出路径
|
||||
- [ ] 改材质名时已检查 `catalog.py`、`generate_scene.py`、`export_cesium.py`
|
||||
- [ ] 新纯几何逻辑放进 `geom.py` 并补 `test_pure.py`
|
||||
- [ ] 声称产物不变的重构已按[产物一致性指南](./artifact-parity-guide.md)校验
|
||||
|
||||
---
|
||||
|
||||
## 反模式
|
||||
|
||||
| 反模式 | 后果 |
|
||||
|---|---|
|
||||
| 新增一份图层名列表 | 回到旧的四份同步,漏改静默错栈 |
|
||||
| 把两套颜色表统一 | 破坏 QGIS 与 Blender 各自调过的视觉结果 |
|
||||
| 低层脚本直接读 `config/areas/*.json` | 两层配置边界失效 |
|
||||
| 只改一份 `parseArgs` 的语义 | 三个入口行为分裂 |
|
||||
| 把要素模块裁剪逻辑挪到调用方 | 不同要素的越界处理开始漂移 |
|
||||
| 只在 `catalog.CESIUM_EXPORT` 加导出覆盖 | 当前不会生效;导出器没读它 |
|
||||
217
.trellis/spec/guides/cross-layer-thinking-guide.md
Normal file
217
.trellis/spec/guides/cross-layer-thinking-guide.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# 跨层思考指南
|
||||
|
||||
> **目的**:动手前把数据流走一遍,把"没想到"变成"想过了"。
|
||||
>
|
||||
> 本项目的层是**跨语言、跨进程、跨运行时**的,边界比普通应用多得多。
|
||||
|
||||
---
|
||||
|
||||
## 本项目的层与边界
|
||||
|
||||
```
|
||||
config/areas/*.json JSON 数据
|
||||
↓ ①
|
||||
build-area.js Node(宿主机)
|
||||
↓ ② 派生配置 JSON
|
||||
build-osm2streets-qgis.js Node + osm2streets WASM
|
||||
↓ ③ 子进程 + 环境变量
|
||||
ogr2ogr / ogrinfo / QGIS Python GDAL/QGIS 运行时
|
||||
↓ ④ GeoJSON / GeoPackage 文件
|
||||
generate_scene.py Blender 内嵌 Python
|
||||
↓ ⑤ .blend 文件 + 材质名字符串
|
||||
export_cesium.py Blender 内嵌 Python
|
||||
↓ ⑥ GLB + JSON
|
||||
cesium-preview.js 浏览器
|
||||
```
|
||||
|
||||
| # | 边界 | 常见问题 |
|
||||
|---|---|---|
|
||||
| ① | 用户配置 → 归一化 | `??` vs `\|\|`、相对路径、字段整体替换 |
|
||||
| ② | 两层配置 | 低层脚本读错配置源 |
|
||||
| ③ | Node → 外部进程 | 环境变量缺失、退出码与信号、0 字节产物 |
|
||||
| ④ | 文件交换 | 图层集合/顺序漂移、精度丢失 |
|
||||
| ⑤ | Python → Python | **材质名字符串**,无校验 |
|
||||
| ⑥ | Blender → 浏览器 | 坐标系约定、材质在两种光照下的差异 |
|
||||
|
||||
---
|
||||
|
||||
## 什么时候该读这篇
|
||||
|
||||
- [ ] 改动同时出现在 `scripts/` 和 `blender/` 里
|
||||
- [ ] 你在改九个 osm2streets 图层中的任何一个
|
||||
- [ ] 你在改材质名、材质顺序
|
||||
- [ ] 你在往配置里加字段
|
||||
- [ ] 你在改任何被 `execFileSync` / `spawnSync` 调起的东西
|
||||
- [ ] 你在改 stage 的 stdout 打印
|
||||
- [ ] 你要新增一种在 Blender 里生成、要在 Cesium 里看的资产
|
||||
|
||||
---
|
||||
|
||||
## Step 1:画数据流
|
||||
|
||||
对每一个箭头问三件事:
|
||||
|
||||
- **格式是什么**——JSON?GeoJSON FeatureCollection?GeoPackage 图层?字符串键?
|
||||
- **可能出什么错**——文件不存在?0 字节?字段名对不上?
|
||||
- **谁负责校验**——上游写的时候,还是下游读的时候?
|
||||
|
||||
本项目的答案通常是:**上游写完就走,下游读的时候校验**。因为上游经常是外部工具
|
||||
(ogr2ogr、Blender),改不动。
|
||||
|
||||
## Step 2:找出"约定型"边界
|
||||
|
||||
最危险的不是有 schema 的边界,是**靠约定连接**的边界:
|
||||
|
||||
| 边界 | 靠什么连接 | 有没有校验 |
|
||||
|---|---|---|
|
||||
| `scene-layers.js` ↔ `catalog.py` | 图层 `id` 的集合与顺序 | ✅ `check_layers()`(warn) |
|
||||
| `generate_scene.py` ↔ `export_cesium.py` | **材质名字符串** | ❌ **无** |
|
||||
| GeoJSON 文件名 ↔ 图层 id | `layerFile()` 拼 `<id>.geojson` | 部分(reimport 会检查 gpkg 图层是否齐全) |
|
||||
| stage stdout ↔ `parity.js` | `SCENE_DONE` / `CESIUM_EXPORT_DONE` 字面量 | ❌ 无 |
|
||||
| GLB 材质索引 ↔ `MATERIALS` 顺序 | 隐式的创建顺序 | ❌ 无(靠 parity 事后发现) |
|
||||
|
||||
**没有校验的那几行就是本项目最容易静默出错的地方。**
|
||||
|
||||
## Step 3:定契约
|
||||
|
||||
对每个边界写清楚:输入格式、输出格式、能出什么错。
|
||||
本项目已定的契约见[产物一致性指南](./artifact-parity-guide.md#真正的契约)。
|
||||
|
||||
---
|
||||
|
||||
## 本项目真实踩过的坑
|
||||
|
||||
### 坑 1:同一份事实存了四份
|
||||
|
||||
九个图层的顺序曾同时存在于 z_index 表、样式 JSON、QGIS 工程、README。
|
||||
改一处漏三处,**不报错**,只是下游场景悄悄错栈。
|
||||
|
||||
**修法**:`scripts/lib/scene-layers.js` 单一事实源 + 四个派生函数。
|
||||
跨语言那一份(`catalog.py`)无法消除,改用运行时对账。
|
||||
|
||||
→ [图层表](../pipeline/layer-registry.md)
|
||||
|
||||
### 坑 2:外部工具失败但留下了文件
|
||||
|
||||
`ogr2ogr` 遇到不存在的图层退出码非零,**但已经创建了一个 0 字节文件**。
|
||||
直接覆盖目标目录就会用空文件冲掉好数据,而且看起来像成功。
|
||||
|
||||
**修法**:staging 目录 → 全部校验 → 才落盘。
|
||||
|
||||
→ [外部工具调用](../pipeline/external-tools.md#2-导出失败会留下-0-字节文件)
|
||||
|
||||
### 坑 3:为了"输出干净"加了个参数
|
||||
|
||||
给 `ogr2ogr` 显式设 `COORDINATE_PRECISION`,触发了 GDAL 的精度裁剪,
|
||||
7 个箭头多边形丢了 28 个顶点。默认行为本来就能完整往返双精度。
|
||||
|
||||
**教训**:跨边界时,**显式设置一个"看起来更安全"的参数,可能触发上游的另一条代码路径**。
|
||||
|
||||
### 坑 4:新资产在 Blender 里对、在 Cesium 里发黑
|
||||
|
||||
场景里每一个材质都被手工提亮过(草往亮绿混 72%、建筑自发光 0.18),
|
||||
因为 Cesium 默认光照偏白。新资产没调过,是唯一如实渲染的东西,
|
||||
放在旁边就显得发黑。
|
||||
|
||||
**教训**:**同一份数据在两个运行时里的"正确"可能不一样**。
|
||||
第二个运行时如果有一整套补偿,新东西必须也进那套补偿。
|
||||
|
||||
→ [资产生成](../blender/asset-generation.md#为什么新资产总是发黑)
|
||||
|
||||
### 坑 5:两个 Python 脚本靠字符串对接
|
||||
|
||||
`export_cesium.py` 不 import `catalog`,靠材质名字符串匹配四张覆盖表。
|
||||
改个材质名,Cesium 侧的调色**静默失效**。`catalog.CESIUM_EXPORT` 想解决这个问题,
|
||||
但迁移没做完,它现在是死代码。
|
||||
|
||||
**教训**:**字符串键的跨模块耦合必须配一个对账机制**,否则重命名就是定时炸弹。
|
||||
|
||||
---
|
||||
|
||||
## 加东西时的检查清单
|
||||
|
||||
### 加一个图层
|
||||
|
||||
- [ ] `scene-layers.js:SCENE_LAYERS` 末尾追加
|
||||
- [ ] `catalog.py:ROAD_LAYERS` 末尾追加,**顺序一致**
|
||||
- [ ] 确认 osm2streets 拆分结果里有对应的 `splitKey`
|
||||
- [ ] 跑一次构建,确认日志里没有 `Layer catalog warning:`
|
||||
- [ ] 跑 parity,确认只多了预期的对象
|
||||
|
||||
### 加一个材质
|
||||
|
||||
- [ ] `catalog.MATERIALS` **末尾**追加(中间插入会平移 GLB 材质索引)
|
||||
- [ ] 若在 Cesium 里需要调色,去 `export_cesium.py` 的四张表加
|
||||
- [ ] 跑 parity
|
||||
|
||||
### 加一个配置字段
|
||||
|
||||
- [ ] `normalizeAreaConfig` 里用 `??` 不用 `||`
|
||||
- [ ] 需要传给低层脚本?加进 `writeDerivedConfig`
|
||||
- [ ] 数值?在消费侧加 `Number.isFinite` + 范围校验,**在任何副作用之前**
|
||||
- [ ] 更新 `config/examples/template.json`
|
||||
- [ ] 更新 [config spec](../config/index.md) 的字段表
|
||||
|
||||
### 加一个 stage
|
||||
|
||||
- [ ] `normalizeAreaConfig` 的 `stages` + `resolveStages` 的 `aliases`
|
||||
- [ ] 想清楚进不进 `all`(恢复类/补丁类不进)
|
||||
- [ ] 与已有 stage 有覆盖关系?加互斥检查
|
||||
- [ ] 产出新文件?加进 `outputs` 路径推导
|
||||
- [ ] 阶段函数开头 `ensureFile` 校验依赖产物
|
||||
|
||||
### 加一种 OSM 要素
|
||||
|
||||
- [ ] 新模块放 `osmassets/`,`assemble(...)` 签名照抄现有三个
|
||||
- [ ] 只 import 需要的,**纯几何逻辑放 `geom.py` 并补测试**
|
||||
- [ ] 材质加进 `catalog.MATERIALS` 末尾
|
||||
- [ ] `generate_scene.py` 分发处加一行
|
||||
- [ ] 需要"随机"外观?用 index 的纯函数或显式 seed,**不要用 `random`**
|
||||
- [ ] 跑 parity
|
||||
|
||||
---
|
||||
|
||||
## 通用的四个错误
|
||||
|
||||
### 隐式格式假设
|
||||
|
||||
跨边界时假设"上游肯定给的是 X 格式"。本项目的做法是**读的时候验**:
|
||||
`reimport-gpkg.js:175` 明确检查 `type === "FeatureCollection" && Array.isArray(features)`。
|
||||
|
||||
### 校验散在各处
|
||||
|
||||
同一个约束在三个地方各写一遍,改的时候漏一个。
|
||||
本项目把参数校验集中在脚本开头(`build-osm2streets-qgis.js:41-70`),
|
||||
**在任何副作用之前一次验完**。
|
||||
|
||||
### 抽象泄漏
|
||||
|
||||
低层脚本如果直接读 `config/areas/*.json`,两层配置的边界就白设了。
|
||||
它们只该读派生配置。
|
||||
|
||||
### 每个消费方各自解析同一份数据
|
||||
|
||||
看到两处代码用各自的方式从同一份 payload 里挖同一个字段,
|
||||
就该有一个共享的解析函数了。`mergeScene(getCollection)` 用回调而不是数组,
|
||||
就是为了让 build 和 reimport 共用一套合并逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 一条铁律
|
||||
|
||||
> **改任何值之前,先全仓 grep 一遍。**
|
||||
|
||||
```bash
|
||||
grep -rn "要改的值" scripts blender config
|
||||
```
|
||||
|
||||
本项目跨两种语言,IDE 的"查找引用"帮不上忙。这一个习惯能挡掉大部分
|
||||
"忘了同步 X" 的 bug。
|
||||
|
||||
---
|
||||
|
||||
## 相关
|
||||
|
||||
- [代码复用思考指南](./code-reuse-thinking-guide.md)
|
||||
- [产物一致性指南](./artifact-parity-guide.md)
|
||||
- [图层表](../pipeline/layer-registry.md)
|
||||
76
.trellis/spec/guides/index.md
Normal file
76
.trellis/spec/guides/index.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# 思考指南索引
|
||||
|
||||
> 目的:在改代码前补一遍"跨层会不会断、重复事实会不会漂、产物是否仍一致"。
|
||||
> 本目录不替代包级 spec;它用于那些单看一个文件容易误判的改动。
|
||||
|
||||
---
|
||||
|
||||
## 可用指南
|
||||
|
||||
| 指南 | 关注点 | 什么时候读 |
|
||||
|---|---|---|
|
||||
| [跨层思考指南](./cross-layer-thinking-guide.md) | JS、GDAL/QGIS、Blender Python、浏览器之间的数据契约 | 改图层、材质名、配置字段、stage 输出、外部工具调用 |
|
||||
| [代码复用思考指南](./code-reuse-thinking-guide.md) | 单一事实源、重复解析、可接受重复与应抽取重复的边界 | 改 `parseArgs`、图层表、配置归一化、几何工具 |
|
||||
| [产物一致性指南](./artifact-parity-guide.md) | `.blend` / `.glb` / metadata 的结构摘要校验 | 任何声称"纯重构、产物不变"的改动 |
|
||||
|
||||
---
|
||||
|
||||
## 本项目触发点
|
||||
|
||||
### 读跨层思考指南
|
||||
|
||||
- [ ] 改 `scripts/lib/scene-layers.js:15` 的 `SCENE_LAYERS`
|
||||
- [ ] 改 `blender/osmassets/catalog.py:28` 的 `ROAD_LAYERS` 或 `catalog.py:56` 的 `MATERIALS`
|
||||
- [ ] 改 `blender/export_cesium.py:68` 等四张按材质名字符串匹配的覆盖表
|
||||
- [ ] 改 `build-area.js:74` 的 `normalizeAreaConfig()` 或 `config/examples/template.json`
|
||||
- [ ] 改任何 `execFileSync` / `spawnSync` 调起的脚本或参数
|
||||
- [ ] 改 `SCENE_DONE` / `CESIUM_EXPORT_DONE` 的 stdout 标记
|
||||
|
||||
### 读代码复用思考指南
|
||||
|
||||
- [ ] 准备新增第二份或第三份图层、材质、配置字段枚举
|
||||
- [ ] 修改三份重复的 `parseArgs` 之一:
|
||||
`build-area.js:50`、`build-osm2streets-qgis.js:153`、`reimport-gpkg.js:93`
|
||||
- [ ] 多个要素模块都要做同一件几何预处理,比如
|
||||
`water.py:9`、`grass.py:9`、`scrub.py:8` 都先 `clip_polygon`
|
||||
- [ ] 低层脚本想直接读取 `config/areas/*.json`,绕开派生配置
|
||||
- [ ] 新增 helper 前没有先 `grep -rn` 找现有函数
|
||||
|
||||
### 读产物一致性指南
|
||||
|
||||
- [ ] 挪函数、拆模块、改导入,且声称产物不变
|
||||
- [ ] 重排 `ROAD_LAYERS` / `MATERIALS`
|
||||
- [ ] 改材质名或导出调色逻辑
|
||||
- [ ] 改几何、采样、实例化、UV、材质构建
|
||||
- [ ] 改 `scripts/parity.js`、`scripts/glb-digest.js`、`blender/tools/scene_digest.py`
|
||||
|
||||
---
|
||||
|
||||
## 改值前的固定动作
|
||||
|
||||
```bash
|
||||
grep -rn "要改的值" scripts blender config
|
||||
```
|
||||
|
||||
本仓库跨 JS、Blender Python、浏览器 JS 和 JSON,很多连接靠字符串或文件名约定。
|
||||
例如 `scene-layers.js` 与 `catalog.py` 只靠 `id` 集合和顺序对账;
|
||||
`generate_scene.py` 与 `export_cesium.py` 的材质覆盖目前靠材质名字符串,没有自动校验。
|
||||
|
||||
---
|
||||
|
||||
## 审查 AI 结果时
|
||||
|
||||
- 先看它有没有读到对应包的 index 和本目录指南
|
||||
- 对任何"行为没变"的结论,要求说明是否需要 parity;需要却没跑就是风险
|
||||
- 对任何"可以合并重复"的建议,先判断重复是不是刻意边界:
|
||||
三份 `parseArgs` 目前是可接受技术债,JS/Python 图层颜色则是刻意不同步
|
||||
- 对任何"加精度、加默认值、直接覆盖文件"的建议,回到真实代码注释验证;
|
||||
`reimport-gpkg.js:152-156` 和 `reimport-gpkg.js:11-13` 都是反直觉约束
|
||||
|
||||
---
|
||||
|
||||
## 维护规则
|
||||
|
||||
- 发现新的跨层坑,优先补到相关指南,再补包级 spec
|
||||
- 指南只写本项目已发生或代码已体现的约束,不写通用工程格言
|
||||
- 每条新约束至少带两个真实路径或函数名,方便后续 grep 定位
|
||||
85
.trellis/spec/index.md
Normal file
85
.trellis/spec/index.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Trellis 项目规范索引
|
||||
|
||||
> 本目录面向 AI 执行者,记录本仓库真实代码里的工程约束。
|
||||
> 用法说明仍放在 README;这里写的是**改代码前必须知道什么**。
|
||||
|
||||
---
|
||||
|
||||
## 项目边界
|
||||
|
||||
本仓库不是前端应用,而是 **OSM → QGIS/Blender/Cesium 的资产生成管线**:
|
||||
|
||||
- `scripts/build-area.js:74` 的 `normalizeAreaConfig()` 归一化区域配置并调度阶段
|
||||
- `scripts/lib/scene-layers.js:15` 的 `SCENE_LAYERS` 是 osm2streets 九个 2D 图层的 JS 侧事实源
|
||||
- `blender/osmassets/catalog.py:28` 的 `ROAD_LAYERS` 是 Blender 侧道路图层与材质顺序事实源
|
||||
- `scripts/lib/cesium-preview.js:1` 是无构建步骤的浏览器预览 IIFE
|
||||
|
||||
因此有效 spec layer 只有 `pipeline`、`blender`、`preview`、`config`,
|
||||
另有跨层思考指南 `guides`。
|
||||
|
||||
---
|
||||
|
||||
## 先读哪个包
|
||||
|
||||
| 你要改的内容 | 先读 |
|
||||
|---|---|
|
||||
| `scripts/*.js`、构建阶段、QGIS/GDAL/Blender 子进程、GeoPackage 往返 | [pipeline](./pipeline/index.md) |
|
||||
| `blender/**/*.py`、`osmassets` 模块、材质、几何、导出 | [blender](./blender/index.md) |
|
||||
| `scripts/lib/cesium-preview.js` / `.css`、预览 HTML 注入 | [preview](./preview/index.md) |
|
||||
| `config/areas/*.json`、`config/examples/template.json`、区域字段默认值 | [config](./config/index.md) |
|
||||
| 跨语言、跨进程、声称纯重构或产物不变的改动 | [guides](./guides/index.md) |
|
||||
|
||||
跨层改动至少读两个包的 index,再读 `guides/index.md`。例如:
|
||||
|
||||
- 加一个 osm2streets 图层:读 `pipeline/layer-registry.md` +
|
||||
`blender/asset-generation.md` + `guides/artifact-parity-guide.md`
|
||||
- 改材质名:读 `blender/module-structure.md` +
|
||||
`blender/asset-generation.md` + `guides/cross-layer-thinking-guide.md`
|
||||
- 加区域配置字段:读 `config/index.md` +
|
||||
`pipeline/cli-and-stages.md`
|
||||
|
||||
---
|
||||
|
||||
## 四条全仓硬约束
|
||||
|
||||
1. **改值先 grep**
|
||||
本项目跨 JS、Blender Python、浏览器 JS 和 JSON,IDE 引用不可靠。
|
||||
改 `SCENE_LAYERS`、`ROAD_LAYERS`、材质名、stage 标记或配置字段前,先:
|
||||
|
||||
```bash
|
||||
grep -rn "要改的值" scripts blender config
|
||||
```
|
||||
|
||||
2. **顺序可能是契约**
|
||||
`catalog.py:16-18` 明确说 `ROAD_LAYERS` / `MATERIALS` 的顺序决定导出 GLB 的材质索引。
|
||||
列表默认只能末尾追加;中间插入或重排必须跑 parity。
|
||||
|
||||
3. **外部工具产物先 staging 后覆盖**
|
||||
`reimport-gpkg.js:11-13` 记录了 `ogr2ogr` 失败会留下 0 字节文件。
|
||||
任何从外部工具生成文件再覆盖已有产物的代码,都要先写临时目录、校验、再拷回。
|
||||
|
||||
4. **纯重构必须证明产物一致**
|
||||
`scripts/parity.js`、`scripts/glb-digest.js`、`blender/tools/scene_digest.py`
|
||||
是结构摘要三件套;不要用二进制字节 diff 代替。
|
||||
|
||||
---
|
||||
|
||||
## 验证入口
|
||||
|
||||
- spec layer 扫描:
|
||||
`python3 ./.trellis/scripts/get_context.py --mode packages`
|
||||
- 纯 Python 测试:
|
||||
`python3 -m unittest discover blender/tests`
|
||||
- 纯重构产物一致性:
|
||||
`node scripts/parity.js capture <label>`,再
|
||||
`node scripts/parity.js compare <before> <after>`
|
||||
|
||||
---
|
||||
|
||||
## 维护本目录
|
||||
|
||||
- 每个包目录必须有 `index.md`
|
||||
- 每个规范文件至少引用两个真实项目文件或函数,优先写 `path:line` + 标识符
|
||||
- 文档语言保持中文;标识符、路径、命令和代码片段保持英文原样
|
||||
- 不写任何占位提示或未来补写标记;规范文件必须是可执行的当前约束
|
||||
- README 面向使用者,spec 面向修改者;不要把命令教程整段复制到 spec
|
||||
190
.trellis/spec/pipeline/cli-and-stages.md
Normal file
190
.trellis/spec/pipeline/cli-and-stages.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# CLI 与构建阶段
|
||||
|
||||
> 适用:新增/修改构建阶段、CLI 参数、区域配置字段。
|
||||
|
||||
---
|
||||
|
||||
## 三个入口脚本
|
||||
|
||||
| 脚本 | 角色 | 入口方式 |
|
||||
|---|---|---|
|
||||
| `scripts/build-area.js` | **主入口**。读区域配置,按阶段调度 | `npm run build` / `build:area` |
|
||||
| `scripts/build-osm2streets-qgis.js` | intermediates 阶段的实现 | 由 build-area 调起;`npm run build:qgis` 可单跑 |
|
||||
| `scripts/reimport-gpkg.js` | reimport 阶段的实现 | 由 build-area 调起 |
|
||||
|
||||
`scripts/parity.js` 和 `scripts/glb-digest.js` 是校验工具,不属于构建链,见
|
||||
[产物一致性指南](../guides/artifact-parity-guide.md)。
|
||||
|
||||
全部是 CommonJS(`package.json` 的 `"type": "commonjs"`),无构建步骤、无 TypeScript、
|
||||
零运行时依赖(唯一依赖 `osm2streets-js-node` 只被 `build-osm2streets-qgis.js` 用)。
|
||||
|
||||
---
|
||||
|
||||
## CLI 参数解析
|
||||
|
||||
三个脚本各有一份**完全相同**的 `parseArgs`
|
||||
(`build-area.js:50`、`build-osm2streets-qgis.js:153`、`reimport-gpkg.js:93`):
|
||||
|
||||
```js
|
||||
--kebab-case value → { kebabCase: "value" }
|
||||
--flag → { flag: "true" } // 后面没值或紧跟另一个 --
|
||||
```
|
||||
|
||||
两条必须知道的语义:
|
||||
|
||||
- **值永远是字符串**,`--flag` 得到的是字符串 `"true"` 不是布尔 `true`。消费方要么
|
||||
`Number(...)` 要么显式比较
|
||||
- **不做校验**。未知参数被静默收集,缺失参数由下游的 `requireText` / `Number.isFinite`
|
||||
报错
|
||||
|
||||
> 这份重复是已知的、**当前被接受的**技术债:三个脚本要能各自独立运行,抽公共模块的
|
||||
> 收益还不抵引入一层依赖。改其中一份时**不要**顺手把另外两份重构掉——那是独立的决定,
|
||||
> 且会扩大 diff。真要抽取,三处一起改并跑 parity。
|
||||
|
||||
---
|
||||
|
||||
## 两层配置
|
||||
|
||||
```
|
||||
config/areas/<id>.json 用户写的区域配置(面向人)
|
||||
│ build-area.js: normalizeAreaConfig() —— 补默认值、推导全部输出路径
|
||||
▼
|
||||
area(内存中的归一化对象)
|
||||
│ writeDerivedConfig()
|
||||
▼
|
||||
<areaDir>/_pipeline/osm2streets-qgis.config.json 派生配置(面向机器)
|
||||
│ --config
|
||||
▼
|
||||
build-osm2streets-qgis.js / reimport-gpkg.js
|
||||
```
|
||||
|
||||
**低层脚本从不读区域配置**,只读派生配置。这条边界让低层脚本能被独立调试,也让
|
||||
"输出路径怎么算出来的"只有一处答案(`normalizeAreaConfig`,`build-area.js:74`)。
|
||||
|
||||
派生配置**落在 `_pipeline/` 目录里而不是临时目录**——构建失败时它还在,可以直接拿去
|
||||
复现(`writeDerivedConfig`,`build-area.js:189`)。
|
||||
|
||||
### 输出路径全部从 `id` 推导
|
||||
|
||||
`normalizeAreaConfig` 一次性算出 14 个输出路径(`build-area.js:87-102`),规则统一是
|
||||
`<outputRoot>/<id>/<fileStem>.<ext>`,`fileStem` 默认等于 `id`。
|
||||
|
||||
每一项都可以被 `outputs.*` 单独覆盖,写法固定:
|
||||
|
||||
```js
|
||||
gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`)),
|
||||
```
|
||||
|
||||
**加新产物就加这一行**,不要在阶段函数里现拼路径。
|
||||
|
||||
### 缺失值:区分"必填"和"有默认"
|
||||
|
||||
| 场景 | 写法 | 出处 |
|
||||
|---|---|---|
|
||||
| 必填,缺了直接死 | `requireText(raw.id, "id")` | `build-area.js:143` |
|
||||
| 有默认值 | `raw.qgisApp \|\| "/Applications/QGIS.app"` | `:107` |
|
||||
| 有默认值且 `false`/`0` 合法 | `raw.stages?.blender ?? true` | `:114` |
|
||||
| 兼容旧字段名 | `raw.qgis?.arrowScale ?? raw.arrowScale ?? 0.8` | `:121` |
|
||||
|
||||
**`??` 和 `||` 不能混用**:`arrowMergeTriangles` 用 `??`,因为 `false` 是合法值,
|
||||
用 `||` 会把关掉的开关重新打开。
|
||||
|
||||
第三列的三级 fallback 是刻意的向后兼容:旧配置把 QGIS 旋钮平铺在顶层,新配置收进
|
||||
`qgis: {}`。加新旋钮时**只写两级**(`raw.qgis?.x ?? 默认值`),不要制造新的平铺别名。
|
||||
|
||||
---
|
||||
|
||||
## 五个阶段
|
||||
|
||||
| 阶段 | 做什么 | 读 | 写 |
|
||||
|---|---|---|---|
|
||||
| `intermediates` | OSM → osm2streets GeoJSON → GeoPackage → QGIS 工程 + 预览图 | `.osm` | `osm2streets_web_out/`、`.gpkg`、`.qgz`、`-preview.png` |
|
||||
| `reimport` | GeoPackage → GeoJSON(**反向**) | `.gpkg` | `osm2streets_web_out/` |
|
||||
| `blender` | OSM + GeoJSON → 场景 | `.osm`、`osm2streets_web_out/` | `.blend`、`.png` |
|
||||
| `cesium` | 场景 → GLB + 元数据 + 预览页 | `.blend` | `.glb`、`.json`、预览 HTML 及其静态资源 |
|
||||
| `preview` | 只补生成预览页 | `.glb`、`.json` | 预览 HTML 及其静态资源 |
|
||||
|
||||
调度是顶层的五个 `if`(`build-area.js:32-46`),顺序固定,**阶段之间不传内存状态,
|
||||
只通过磁盘产物耦合**。这就是单跑某个阶段能work 的原因。
|
||||
|
||||
`cesium` 阶段结束时会直接调 `writeCesiumPreview(area)`(`build-area.js:285`),所以
|
||||
`preview` 只在"已有 GLB、只想重生成 HTML"时才需要单独跑。
|
||||
|
||||
### 别名
|
||||
|
||||
`resolveStages`(`build-area.js:157`)接受一张别名表,同一个阶段有多个叫法
|
||||
(`qgis`/`osm2streets`/`geojson` → `intermediates`,`gpkg` → `reimport`,
|
||||
`scene` → `blender`,`glb` → `cesium`,`html`/`cesiumPreview` → `preview`)。
|
||||
|
||||
未知阶段名**抛错并列出合法值**(`:181`),不静默忽略。
|
||||
|
||||
### `all` 不含 `reimport`
|
||||
|
||||
```js
|
||||
// 'reimport' is deliberately absent from 'all': it is a recovery step for
|
||||
// hand-edited GeoPackages, never part of a full build. build-area.js:159-160
|
||||
all: ["intermediates", "blender", "cesium"],
|
||||
```
|
||||
|
||||
`preview` 同样不在 `all` 里——`cesium` 已经包含它。
|
||||
|
||||
### `intermediates` 与 `reimport` 互斥
|
||||
|
||||
在任何阶段执行**之前**就检查并抛错(`build-area.js:19-26`):
|
||||
|
||||
> intermediates 从 OSM 重建 GeoPackage,正好会抹掉 reimport 要读回的手工修改。
|
||||
|
||||
这是**显式拒绝而不是警告**——两者同时开,无论谁先跑,另一个的工作都白做。
|
||||
|
||||
`normalizeAreaConfig` 里 `stages.reimport` 和 `stages.preview` 硬编码为 `false`
|
||||
(`build-area.js:117-118`),**不能从配置文件打开**,只能靠 `--stages` 显式请求。
|
||||
恢复动作和补丁动作都不该被一份配置文件变成默认行为。
|
||||
|
||||
---
|
||||
|
||||
## 加一个新阶段
|
||||
|
||||
1. `normalizeAreaConfig` 的 `stages` 里加一项(默认值想清楚是 `true` 还是硬编码
|
||||
`false`)
|
||||
2. `resolveStages` 的 `aliases` 里注册名字(以及别名)
|
||||
3. 决定要不要进 `all`——**恢复类/补丁类动作不进**
|
||||
4. 顶层加一个 `if (stages.x) doX(area)`,位置按数据依赖排
|
||||
5. 写 `doX(area)`:先 `ensureFile` 校验依赖产物,`mkdirSync` 建目录,
|
||||
`console.log("Stage: x")`,再 `runCommand`
|
||||
6. 若与已有阶段存在"互相覆盖"关系,在顶层加互斥检查
|
||||
7. 若产出新文件,在 `normalizeAreaConfig` 的 `outputs` 里加路径
|
||||
|
||||
---
|
||||
|
||||
## 输出约定
|
||||
|
||||
- 开头三行固定打印 area / config / output 路径(`build-area.js:29-31`)
|
||||
- 每个阶段进入时打印 `Stage: <name>`
|
||||
- 结尾 `console.log("Done.")`
|
||||
- 外部工具的输出透传,不加工
|
||||
|
||||
parity 校验依赖 stage 的 stdout 标记来判断阶段是否跑到(如 `SCENE_DONE` /
|
||||
`CESIUM_EXPORT_DONE`),**改动这些打印等于改动 parity 契约**。
|
||||
|
||||
---
|
||||
|
||||
## 反模式
|
||||
|
||||
| 反模式 | 后果 |
|
||||
|---|---|
|
||||
| 在阶段函数里现拼输出路径 | 路径规则出现第二份定义 |
|
||||
| 低层脚本直接读 `config/areas/*.json` | 打破两层配置边界 |
|
||||
| 布尔配置用 `\|\|` 兜底 | `false` 被翻转成默认值 |
|
||||
| 让 `reimport` / `preview` 能从配置文件默认开启 | 恢复动作变成常规行为 |
|
||||
| 新阶段忘了 `ensureFile` 前置校验 | 单跑时报底层堆栈而非人话 |
|
||||
| 改 stage 的 stdout 标记 | 静默破坏 parity 契约 |
|
||||
| 顺手把三份 `parseArgs` 合并 | 扩大 diff,且三个脚本的独立性是刻意的 |
|
||||
|
||||
---
|
||||
|
||||
## 相关
|
||||
|
||||
- [外部工具调用](./external-tools.md):阶段内部如何调 QGIS/Blender
|
||||
- [图层表](./layer-registry.md):intermediates 与 reimport 共同维护的九个图层
|
||||
- [区域配置](../config/index.md):字段全表
|
||||
- README「主流程」节:面向使用者的命令示例(**不要**复制到这里)
|
||||
200
.trellis/spec/pipeline/external-tools.md
Normal file
200
.trellis/spec/pipeline/external-tools.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# 外部工具调用
|
||||
|
||||
> 适用:任何调用 QGIS / GDAL / Blender 子进程的代码。
|
||||
> 这一层是管线里**唯一**能启动外部进程的地方,也是踩过坑最多的地方——下面每条约束
|
||||
> 都对应一次实际的数据损坏或静默错误。
|
||||
|
||||
---
|
||||
|
||||
## QGIS 工具链
|
||||
|
||||
### 可执行文件路径
|
||||
|
||||
一律从配置的 `qgisApp` 推导,不写死绝对路径、不依赖 `PATH`:
|
||||
|
||||
```js
|
||||
const qgisMacOS = path.join(qgisApp, "Contents", "MacOS");
|
||||
const qgisPython = path.join(qgisMacOS, "python3.12"); // build-osm2streets-qgis.js:24
|
||||
const ogr2ogr = path.join(qgisMacOS, "ogr2ogr"); // :25
|
||||
const ogrinfo = path.join(qgisMacOS, "ogrinfo"); // reimport-gpkg.js:33
|
||||
```
|
||||
|
||||
**启动前必须 `existsSync` 校验并抛出带路径的错误**
|
||||
(`build-osm2streets-qgis.js:59-63`、`reimport-gpkg.js:37-41`)。让它在第一步就失败,
|
||||
而不是在 `execFileSync` 里抛一个没有上下文的 ENOENT。
|
||||
|
||||
### GDAL 环境变量(必须)
|
||||
|
||||
```js
|
||||
function qgisEnv() { // build-osm2streets-qgis.js:231
|
||||
return {
|
||||
PROJ_LIB: path.join(qgisApp, "Contents", "Resources", "qgis", "proj"),
|
||||
GDAL_DATA: path.join(qgisApp, "Contents", "Resources", "qgis", "gdal"),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
`reimport-gpkg.js:132` 有一份等价实现(`gdalEnv()`)。**每次 `execFileSync` 都要带上**,
|
||||
写法固定为 `env: { ...process.env, ...qgisEnv() }`。
|
||||
|
||||
漏掉不会立刻崩——GDAL 会退回内置的残缺数据,坐标系解析结果**静默出错**。
|
||||
|
||||
### 在 QGIS 的 Python 里跑脚本
|
||||
|
||||
`normalize-lane-arrows.py` 依赖 `osgeo.ogr`,只能用 QGIS 自带的解释器。除了
|
||||
`qgisEnv()` 还要额外注入三项(`build-osm2streets-qgis.js:1287-1305`):
|
||||
|
||||
| 变量 | 值 | 作用 |
|
||||
|---|---|---|
|
||||
| `QT_QPA_PLATFORM` | `offscreen` | 无头环境下不尝试连显示服务 |
|
||||
| `PYTHONHOME` | `<qgisApp>/Contents/Frameworks` | 指向 QGIS 的 Python 运行时 |
|
||||
| `PYTHONPATH` | `<...>/Resources/python` + `/plugins` | 找得到 `osgeo` 与插件 |
|
||||
|
||||
新增 QGIS-Python 脚本时照抄这套环境,不要只带 `qgisEnv()`。
|
||||
|
||||
---
|
||||
|
||||
## `ogr2ogr` 的三个陷阱
|
||||
|
||||
### 1. 不要设 `COORDINATE_PRECISION`
|
||||
|
||||
`reimport-gpkg.js:152-156` 有一段专门的注释说明:
|
||||
|
||||
> 默认行为已经能完整往返双精度。**显式设置反而会触发 GDAL 的精度裁剪那一遍**,
|
||||
> 把在给定分辨率下collapse 的顶点丢掉——实测在 7 个 lane-arrow 多边形上丢了 28 个点。
|
||||
|
||||
看到有人"为了输出干净"加上这个参数,删掉它。
|
||||
|
||||
### 2. 导出失败会留下 0 字节文件
|
||||
|
||||
`ogr2ogr` 遇到不存在的图层**退出码非零,但已经创建了一个空文件**。直接写目标目录
|
||||
就会用空文件覆盖掉好数据,而且看起来像成功。
|
||||
|
||||
所以 reimport 的落盘是三段式(`reimport-gpkg.js:11-13, 61-91`):
|
||||
|
||||
```
|
||||
1. 全部导出到 mkdtemp 的 staging 目录
|
||||
2. 逐个 JSON.parse + 校验 type === "FeatureCollection" && Array.isArray(features)
|
||||
3. 全部通过后,才逐个 copyFileSync 到 outDir
|
||||
```
|
||||
|
||||
任一图层失败 → 整批不落盘。**任何"从外部工具产出文件再覆盖已有数据"的新代码都照这个
|
||||
模式写。**
|
||||
|
||||
补充两点:
|
||||
|
||||
- 用 `copyFileSync` **不用** `rename`:staging 目录可能在另一个文件系统上
|
||||
(`reimport-gpkg.js:72-73`)
|
||||
- `finally` 里 `rmSync(stagingDir, {recursive: true, force: true})`,失败路径也要清理
|
||||
|
||||
### 3. 建包与追加是两组参数
|
||||
|
||||
第一个图层创建 GeoPackage,其余追加(`build-osm2streets-qgis.js:108-111`):
|
||||
|
||||
```js
|
||||
SCENE_LAYERS.forEach((layer, index) => {
|
||||
importLayer(gpkgPath, ..., layer.id, index > 0, ogrEnv); // update = index > 0
|
||||
});
|
||||
```
|
||||
|
||||
`importLayer`(`:1306`)在 `update` 为真时补 `-update -overwrite`。重建前先
|
||||
`unlinkSync` 掉旧的 gpkg(`:104-106`),不要依赖 `-overwrite` 清理整个文件。
|
||||
|
||||
---
|
||||
|
||||
## 前置校验的顺序
|
||||
|
||||
`build-osm2streets-qgis.js:41-70` 的开头是一段密集的校验,顺序是刻意的:
|
||||
|
||||
1. **数值参数**先验(`Number.isFinite` + 范围),错的配置立刻死
|
||||
2. **输入文件**存在性
|
||||
3. **外部可执行文件**存在性
|
||||
4. 全部通过后才 `mkdirSync` 建输出目录
|
||||
|
||||
原则:**在做任何有副作用的事情之前,把能验的都验完**。不要先建目录再发现 QGIS 装错了。
|
||||
|
||||
数值校验用 `Number.isFinite` 而不是 `!isNaN`——后者对 `Infinity` 返回 false,
|
||||
而 `Infinity` 是个合法的 `Number()` 结果。
|
||||
|
||||
---
|
||||
|
||||
## Blender 调用
|
||||
|
||||
### 两种调用姿势
|
||||
|
||||
| 阶段 | 参数 | 出处 |
|
||||
|---|---|---|
|
||||
| `blender` | `--background --factory-startup --python generate_scene.py --` | `build-area.js:242-247` |
|
||||
| `cesium` | `--background --python export_cesium.py --` | `build-area.js:276-280` |
|
||||
|
||||
**`--factory-startup` 只在 generate 阶段用**:它屏蔽用户的 preferences 和 addon,
|
||||
保证场景生成不受本机 Blender 配置影响。export 阶段不带,因为它要读已经建好的 `.blend`。
|
||||
|
||||
`--` 之后才是脚本自己的参数,Blender 不解析它们。脚本侧用
|
||||
`sys.argv[sys.argv.index("--") + 1:]` 取。
|
||||
|
||||
可执行文件路径同样从配置推导:
|
||||
`path.join(area.blenderApp, "Contents", "MacOS", "Blender")`(`build-area.js:291`)。
|
||||
|
||||
### 调用前的资源校验
|
||||
|
||||
`ensureFile()`(`build-area.js:295`)在每个阶段开头把依赖逐个验一遍,带 label:
|
||||
|
||||
```js
|
||||
ensureFile(blenderExecutable(area), "Blender executable");
|
||||
ensureFile(area.outputs.blend, "Blend scene"); // cesium 阶段依赖上一阶段产物
|
||||
ensureFile(path.join(repoRoot, "blender", "export_cesium.py"), "Cesium exporter");
|
||||
```
|
||||
|
||||
阶段间依赖靠这个显式表达,**不靠隐式的执行顺序**。`--stages cesium` 单跑时,缺
|
||||
`.blend` 会得到一句人话错误而不是 Blender 的堆栈。
|
||||
|
||||
---
|
||||
|
||||
## 子进程失败处理
|
||||
|
||||
统一走 `runCommand`(`build-area.js:301-311`):
|
||||
|
||||
```js
|
||||
const result = spawnSync(command, commandArgs, { stdio: "inherit" });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
const signal = result.signal ? ` signal=${result.signal}` : "";
|
||||
throw new Error(`Stage '${stage}' failed with status=${result.status}${signal}`);
|
||||
}
|
||||
```
|
||||
|
||||
三个要点:
|
||||
|
||||
- **`stdio: "inherit"`**:外部工具的输出直接透传,不缓冲、不吞。这条管线的调试
|
||||
高度依赖 QGIS/Blender 自己打的日志
|
||||
- **`result.error` 和 `result.status` 分别检查**:前者是启动失败(ENOENT 等),
|
||||
后者是运行失败,混在一起会丢信息
|
||||
- **带上 `signal`**:Blender 被 OOM killer 干掉时 `status` 是 null,只有 `signal`
|
||||
能说明发生了什么
|
||||
|
||||
`build-osm2streets-qgis.js` / `reimport-gpkg.js` 内部用 `execFileSync`(同步、非零
|
||||
自动抛),也一律 `stdio: "inherit"`。
|
||||
|
||||
---
|
||||
|
||||
## 反模式
|
||||
|
||||
| 反模式 | 后果 |
|
||||
|---|---|
|
||||
| 给 `ogr2ogr` 加 `COORDINATE_PRECISION` | 静默丢顶点 |
|
||||
| 外部工具产物直接写目标目录 | 失败时用 0 字节文件覆盖好数据 |
|
||||
| `execFileSync` 不带 `qgisEnv()` | 坐标系静默出错 |
|
||||
| `stdio: "pipe"` 或吞掉输出 | 失去唯一的调试信息来源 |
|
||||
| 只判 `status !== 0`,不看 `result.error` / `signal` | 启动失败和被信号杀死都变成同一句错 |
|
||||
| 用 `rename` 从临时目录搬文件 | 跨文件系统时 EXDEV |
|
||||
| 依赖 `PATH` 里的 `ogr2ogr` | 抓到系统 GDAL,版本与 QGIS 不匹配 |
|
||||
| 先建目录/删文件再校验参数 | 配置写错也会破坏已有输出 |
|
||||
|
||||
---
|
||||
|
||||
## 相关
|
||||
|
||||
- [CLI 与阶段](./cli-and-stages.md):这些调用被哪个阶段发起
|
||||
- [图层表](./layer-registry.md):进出 GeoPackage 的九个图层从哪来
|
||||
- [区域配置](../config/index.md):`qgisApp` / `blenderApp` 的配置位置
|
||||
97
.trellis/spec/pipeline/index.md
Normal file
97
.trellis/spec/pipeline/index.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Pipeline:Node 构建管线
|
||||
|
||||
> 覆盖 `scripts/*.js` 与 `scripts/lib/scene-layers.js`。
|
||||
> 运行时:宿主机 Node(CommonJS,无构建步骤)。
|
||||
> 这是管线里**唯一**能启动外部进程的层。
|
||||
|
||||
---
|
||||
|
||||
## 先读哪一篇
|
||||
|
||||
| 你要做的事 | 读 |
|
||||
|---|---|
|
||||
| 改九个 osm2streets 图层(增/删/改顺序/改色) | [图层表](./layer-registry.md) ← **最容易出静默错误** |
|
||||
| 调 QGIS / GDAL / Blender 子进程 | [外部工具调用](./external-tools.md) |
|
||||
| 加阶段、加 CLI 参数、改配置字段 | [CLI 与阶段](./cli-and-stages.md) |
|
||||
| 改预览页生成 | [../preview/](../preview/index.md) |
|
||||
| 声称"纯重构,产物不变" | [产物一致性指南](../guides/artifact-parity-guide.md) |
|
||||
|
||||
---
|
||||
|
||||
## 数据流全景
|
||||
|
||||
```
|
||||
config/areas/<id>.json
|
||||
│
|
||||
▼ build-area.js — normalizeAreaConfig() 推导全部输出路径
|
||||
_pipeline/osm2streets-qgis.config.json (派生配置)
|
||||
│
|
||||
├─[intermediates]─▶ build-osm2streets-qgis.js
|
||||
│ osm2streets-js-node 解析 .osm
|
||||
│ → splitLayers() 拆成九个图层
|
||||
│ → normalize-lane-arrows.py(QGIS Python)
|
||||
│ → osm2streets_web_out/*.geojson
|
||||
│ → osm2streets_scene.geojson + _scene_style.json
|
||||
│ → ogr2ogr 导入 <id>.gpkg
|
||||
│ → QGIS 生成 .qgz + -preview.png
|
||||
│
|
||||
├─[reimport]──────▶ reimport-gpkg.js (反向,与 intermediates 互斥)
|
||||
│ ogr2ogr 从 .gpkg 导出 → 校验 → 覆写 *.geojson
|
||||
│ → 重建 scene.geojson + scene_style.json
|
||||
│
|
||||
├─[blender]───────▶ Blender + blender/generate_scene.py
|
||||
│ 读 .osm + osm2streets_web_out/
|
||||
│ → <id>.blend + <id>.png
|
||||
│
|
||||
├─[cesium]────────▶ Blender + blender/export_cesium.py
|
||||
│ 读 .blend → <id>.glb + <id>.json
|
||||
│ → 并自动执行 preview
|
||||
│
|
||||
└─[preview]───────▶ 生成 <id>-cesium-preview.html
|
||||
+ 拷贝 lib/cesium-preview.{js,css}
|
||||
+ 车辆巡航路线与模型
|
||||
```
|
||||
|
||||
**阶段之间只通过磁盘产物耦合**,不传内存状态。这是单跑任意阶段能work 的前提。
|
||||
|
||||
---
|
||||
|
||||
## 三条贯穿全层的约定
|
||||
|
||||
1. **单一事实源优先于同步**
|
||||
九个图层的定义在 `lib/scene-layers.js`,四个派生函数覆盖了全部合法用法。看到第二处
|
||||
枚举这些图层,就是 bug 温床。详见 [图层表](./layer-registry.md)。
|
||||
|
||||
2. **有副作用之前先把能验的都验完**
|
||||
数值参数 → 输入文件 → 外部可执行文件 → 才 `mkdirSync`。
|
||||
见 `build-osm2streets-qgis.js:41-70`。
|
||||
|
||||
3. **外部工具的产物先落 staging,校验通过才覆盖**
|
||||
`ogr2ogr` 失败会留 0 字节文件。详见 [外部工具调用](./external-tools.md)。
|
||||
|
||||
---
|
||||
|
||||
## 文件速查
|
||||
|
||||
| 文件 | 行数 | 职责 |
|
||||
|---|---|---|
|
||||
| `build-area.js` | 774 | 主入口:配置归一化、阶段调度、Cesium 预览页与车辆巡航生成 |
|
||||
| `build-osm2streets-qgis.js` | 1468 | intermediates:osm2streets 解析、图层拆分、人行道转角合成、GeoPackage 与 QGIS 工程生成 |
|
||||
| `reimport-gpkg.js` | 179 | reimport:GeoPackage → GeoJSON 反向导出 |
|
||||
| `lib/scene-layers.js` | 164 | 九个图层的单一事实源 + 四个派生函数 |
|
||||
| `lib/cesium-preview.js` / `.css` | 672 / 230 | 预览页运行时,见 [../preview/](../preview/index.md) |
|
||||
| `normalize-lane-arrows.py` | 182 | 合并 osm2streets 的三角网箭头(跑在 QGIS Python 里) |
|
||||
| `parity.js` | 270 | 产物一致性校验驱动 |
|
||||
| `glb-digest.js` | 121 | GLB 结构摘要 |
|
||||
|
||||
---
|
||||
|
||||
## 技术选型现状
|
||||
|
||||
- **CommonJS,无构建、无 TypeScript、无 lint 配置**。保持现状;引入工具链是独立决定,
|
||||
不要夹带在功能改动里
|
||||
- **零运行时依赖**(`osm2streets-js-node` 是唯一 dependency)。加依赖前先确认标准库
|
||||
真的做不到
|
||||
- **同步 API 优先**(`execFileSync` / `spawnSync` / `readFileSync`)。这是一次性跑完
|
||||
的批处理工具,不是服务,异步只会增加错误处理复杂度
|
||||
- **macOS 专用路径假设**(`.app/Contents/MacOS/...`)。跨平台不在当前范围内
|
||||
164
.trellis/spec/pipeline/layer-registry.md
Normal file
164
.trellis/spec/pipeline/layer-registry.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# 图层表:跨语言的单一事实源
|
||||
|
||||
> 适用:改动 osm2streets 九个渲染图层的任何一方——新增图层、删图层、调顺序、调
|
||||
> 颜色、调高度。**动手前必读**,这里的错误不会报错,只会让产物静默错栈。
|
||||
|
||||
---
|
||||
|
||||
## 一句话
|
||||
|
||||
九个图层在 **JS 和 Python 各存一份表**,两份**故意只同步"集合与顺序"、不同步颜色**,
|
||||
一致性靠运行时的 `catalog.check_layers()` 用产物文件对账。
|
||||
|
||||
---
|
||||
|
||||
## 两份表分别管什么
|
||||
|
||||
| | JS 侧 | Python 侧 |
|
||||
|---|---|---|
|
||||
| 位置 | `scripts/lib/scene-layers.js:15` `SCENE_LAYERS` | `blender/osmassets/catalog.py:28` `ROAD_LAYERS` |
|
||||
| 服务于 | 2D 调试链路:GeoJSON 拆分、GeoPackage 导入、QGIS 工程符号 | 3D 场景链路:Blender 材质与几何高度 |
|
||||
| 关键字段 | `id`、`splitKey`、`zIndex`、`title`、`fill`/`outline`(sRGB hex) | `id`、`material`、`color`(线性 RGB)、`z`(米) |
|
||||
| 消费点 | `build-osm2streets-qgis.js:91,98,109,1315`、`reimport-gpkg.js:53,63,77` | `generate_scene.py:759,844` |
|
||||
|
||||
`id` 是两侧唯一的连接键,同时也是 GeoJSON 文件名的 stem(`layerFile()` 拼
|
||||
`<id>.geojson`,见 `scene-layers.js:103`)。
|
||||
|
||||
## 这份表从前复制了四遍
|
||||
|
||||
`scene-layers.js:3-13` 的注释写明了它存在的理由:同一批图层的顺序曾同时躺在
|
||||
merged-scene 的 z_index 表、场景样式 JSON、生成的 QGIS 工程、以及 README 的手工重建
|
||||
片段里。加一个图层要同步改四处,漏一处**不报错**,只是下游 Blender/Cesium 里的场景
|
||||
悄悄错栈。
|
||||
|
||||
`catalog.py:3-7` 记录的是 Python 侧的同一个病:九个图层的绘制顺序在 JS、Blender 高度
|
||||
在一个 `layer_z` dict、颜色在一个 `road_mats` dict——两种语言三份拷贝,手工对齐。
|
||||
|
||||
**推论**:看到任何地方开始第二次枚举这九个图层,那就是 bug 的温床,改成从这两份表
|
||||
之一派生。
|
||||
|
||||
## 为什么颜色刻意不同步
|
||||
|
||||
`catalog.py:11-15` 明确列为"deliberate non-goal":
|
||||
|
||||
- `scene-layers.js` 的 `fill` 是给 **QGIS 2D 调试地图**用的 sRGB hex
|
||||
- `catalog.py` 的 `color` 是给 **Blender 3D 场景**用的线性 RGB
|
||||
- 两套值是**分别调出来的**,不存在换算关系
|
||||
|
||||
所以 `check_layers()` 只校验图层的**集合与顺序**——那是必须一致的部分——**不碰调色板**。
|
||||
|
||||
> 不要"顺手统一"两边的颜色。那不是清理重复,是把两个独立的设计意图合并成一个错的。
|
||||
|
||||
## 对账机制
|
||||
|
||||
桥梁是产物文件 `osm2streets_scene_style.json`(两侧常量都叫 `SCENE_STYLE_FILE`,
|
||||
见 `scene-layers.js:101` 与 `catalog.py:49`):
|
||||
|
||||
```
|
||||
JS 侧 sceneStyle() ──写──▶ osm2streets_scene_style.json ──读──▶ catalog.check_layers()
|
||||
scene-layers.js:126 (落在 geojson 输出目录) catalog.py:162
|
||||
```
|
||||
|
||||
写入点:`build-osm2streets-qgis.js:100`(intermediates 阶段)、
|
||||
`reimport-gpkg.js:79`(reimport 阶段)。
|
||||
读取点:`generate_scene.py:842`,每次构建场景时执行。
|
||||
|
||||
`check_layers()` 报三类问题(`catalog.py:183-194`):
|
||||
|
||||
1. style 里有、`ROAD_LAYERS` 里没有 → 该图层**到不了 3D 场景**
|
||||
2. `ROAD_LAYERS` 里有、style 里没有 → **不会有 GeoJSON 产出**给它
|
||||
3. 集合相同但顺序不同 → 打印两侧的实际顺序
|
||||
|
||||
**这是 warn 不是 fail**(`catalog.py:168-169` 写明理由):过期或缺失的输出目录不该
|
||||
阻断一次重建。所以——
|
||||
|
||||
> 构建日志里的 `Layer catalog warning:` 不是噪音。它是这套双表设计**唯一**的自动
|
||||
> 报警,被忽略就等于没有。
|
||||
|
||||
## 顺序是承重的
|
||||
|
||||
`catalog.py:16-18`:
|
||||
|
||||
- **材质创建顺序固定了导出 GLB 里的材质索引**
|
||||
- 图层顺序固定了 mesh 创建顺序
|
||||
|
||||
所以 `ROAD_LAYERS` 和 `MATERIALS` 是 **list 不是 dict**,**追加是唯一安全的编辑**。
|
||||
在中间插入一个图层会平移其后所有材质索引——GLB 结构变了,parity 校验会红,
|
||||
Cesium 侧引用的材质会错位。
|
||||
|
||||
JS 侧的 `zIndex` 同样兼作绘制顺序(`scene-layers.js:12`),**最小值先画、位于栈底**。
|
||||
它同时是写进每个 feature 的 `z_index` 属性(`mergeScene()`,`scene-layers.js:117-119`)。
|
||||
|
||||
## 派生函数:只加派生,不要加第二份枚举
|
||||
|
||||
`scene-layers.js` 导出的四个派生函数是这份表的全部合法用法:
|
||||
|
||||
| 函数 | 位置 | 用途 |
|
||||
|---|---|---|
|
||||
| `layerFile(layer)` | `:103` | `<id>.geojson` 文件名 |
|
||||
| `mergeScene(getCollection)` | `:109` | 合成 `osm2streets_scene.geojson`,逐 feature 打上 `render_layer` / `z_index` |
|
||||
| `sceneStyle()` | `:126` | 生成对账用的 style JSON |
|
||||
| `qgisRgba(hex, alpha)` | `:143` | hex → QGIS 要的 `"r,g,b,a"` 字符串 |
|
||||
|
||||
`mergeScene` 收的是**回调**而不是数组,这样 build 阶段(从内存的 split 取)和
|
||||
reimport 阶段(从磁盘读回)能共用同一套合并逻辑(`scene-layers.js:107-108`)。
|
||||
新增第三种数据来源时沿用这个模式,不要复制合并循环。
|
||||
|
||||
`outline: null` 表示无描边,QGIS 侧由 `qgisRgba` 转成全透明(`scene-layers.js:13-14,145`)。
|
||||
|
||||
Python 侧同理:`road_material_specs()`(`catalog.py:156`)把 `ROAD_LAYERS` 转成
|
||||
`MATERIALS` 形状的规格,`generate_scene.py:759` 用 `zip` 与图层配对——保持这条派生链,
|
||||
不要在 `generate_scene.py` 里另起一份材质名列表。
|
||||
|
||||
---
|
||||
|
||||
## 改动清单
|
||||
|
||||
### 新增一个图层
|
||||
|
||||
1. `scene-layers.js:SCENE_LAYERS` **末尾追加**:`id`、`splitKey`、`zIndex`(大于现有
|
||||
最大值)、`title`、`fill`、`outline`、`outlineWidth`
|
||||
2. 确认 osm2streets 的拆分结果里确实有 `splitKey` 对应的键
|
||||
(`build-osm2streets-qgis.js:92` 取 `split[layer.splitKey]`)
|
||||
3. `catalog.py:ROAD_LAYERS` **末尾追加**:`id`(与第 1 步一致)、`material`(新名字)、
|
||||
`color`(线性 RGB,独立调)、`z`(米,高于前一层避免 z-fighting)
|
||||
4. 跑一次 `intermediates` + `blender`,确认日志里**没有** `Layer catalog warning:`
|
||||
5. 该图层的 GeoPackage 导入、QGIS 符号、场景合并、reimport 全部自动跟上,**无需**再
|
||||
改 `reimport-gpkg.js` 或 QGIS 工程生成代码
|
||||
|
||||
### 删除一个图层
|
||||
|
||||
两侧同时删。只删一侧的话 `check_layers()` 会 warn,但构建**照常出产物**——一份少了
|
||||
该图层的产物。
|
||||
|
||||
### 调整顺序
|
||||
|
||||
改 `zIndex` 的同时必须把 `ROAD_LAYERS` 的**元素位置**也调成一致。注意这会移动材质
|
||||
索引,属于会改变产物的变更,**必须跑 parity 校验**,见
|
||||
[产物一致性指南](../guides/artifact-parity-guide.md)。
|
||||
|
||||
### 只调颜色
|
||||
|
||||
改一侧即可,不要同步到另一侧(见上文"为什么颜色刻意不同步")。
|
||||
|
||||
---
|
||||
|
||||
## 反模式
|
||||
|
||||
| 反模式 | 后果 |
|
||||
|---|---|
|
||||
| 在 `generate_scene.py` / QGIS 生成代码里硬编码图层名列表 | 回到"复制四份"的旧病 |
|
||||
| 从 `scene-layers.js` 的 `fill` 换算 Blender 的 `color` | 抹掉两套独立调过的配色 |
|
||||
| 在 `ROAD_LAYERS` / `MATERIALS` **中间**插入条目 | GLB 材质索引整体平移 |
|
||||
| 把 `ROAD_LAYERS` / `MATERIALS` 改成 dict | 顺序语义丢失,见 `catalog.py:16-18` |
|
||||
| 把 `check_layers()` 从 warn 改成 raise | 输出目录过期就无法重建 |
|
||||
| 忽略 `Layer catalog warning:` | 双表设计唯一的报警失效 |
|
||||
|
||||
---
|
||||
|
||||
## 相关
|
||||
|
||||
- [外部工具调用](./external-tools.md):图层如何进出 GeoPackage
|
||||
- [CLI 与阶段](./cli-and-stages.md):哪个阶段写、哪个阶段读这些文件
|
||||
- [Blender 资产生成](../blender/asset-generation.md):`MATERIALS` 的其余部分
|
||||
- [产物一致性指南](../guides/artifact-parity-guide.md):改动顺序后如何验证
|
||||
219
.trellis/spec/preview/index.md
Normal file
219
.trellis/spec/preview/index.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# Preview:Cesium 预览层
|
||||
|
||||
> 覆盖 `scripts/lib/cesium-preview.js`(672 行)与 `cesium-preview.css`(230 行)。
|
||||
> 运行时:浏览器。全仓唯一的 DOM 环境。
|
||||
|
||||
---
|
||||
|
||||
## 定位
|
||||
|
||||
预览层是**验证性的,不是产物本身**。它加载 `cesium` 阶段导出的 `.glb` + `.json`,
|
||||
用来确认资产在真实 Cesium 里的样子。改这一层**不会**改变 Blender/GLB 主资产。
|
||||
|
||||
车辆巡航同理——README 里写明它是"用于验证高精度巡航可用性的预览层功能"。
|
||||
|
||||
---
|
||||
|
||||
## 没有构建步骤
|
||||
|
||||
```
|
||||
scripts/lib/cesium-preview.js ─── 原样 copyFileSync ──▶ outputs/<area>/cesium-preview.js
|
||||
scripts/lib/cesium-preview.css ─── 原样 copyFileSync ──▶ outputs/<area>/cesium-preview.css
|
||||
(build-area.js:328-335)
|
||||
<area>-cesium-preview.html ─── 模板字符串生成 ────▶ 同目录
|
||||
(build-area.js:697)
|
||||
```
|
||||
|
||||
所以:**没有打包、没有转译、没有 npm 依赖、没有模块系统**。浏览器直接吃。
|
||||
写代码时只能用目标浏览器原生支持的语法,`Cesium` 从 CDN 全局引入。
|
||||
|
||||
整个文件是一个 IIFE + `"use strict"`(`cesium-preview.js:1-2`)。
|
||||
|
||||
---
|
||||
|
||||
## 参数注入
|
||||
|
||||
JS 不硬编码任何文件名,全部从 HTML 注入的全局对象读:
|
||||
|
||||
```js
|
||||
const config = window.OSM_ASSET_PREVIEW_CONFIG || {}; // :4
|
||||
// config.areaId / .glbName / .metadataName / .routeName / .vehicleModelName
|
||||
```
|
||||
|
||||
生成侧在 `build-area.js:697 cesiumPreviewHtml()`,注入时**必须转义**:
|
||||
|
||||
| 场景 | 用 |
|
||||
|---|---|
|
||||
| HTML 文本/属性 | `escapeHtml()`(`build-area.js:759`) |
|
||||
| `<script>` 里的 JSON | `escapeScriptJson()`(`:767`) |
|
||||
|
||||
`|| {}` 的兜底不能删——它让 JS 在没有配置块时也不至于在第一行就崩。
|
||||
|
||||
**加一个新的可配置项**:`cesiumPreviewHtml()` 里加进注入的 JSON,JS 侧从 `config` 读,
|
||||
两边都要动。
|
||||
|
||||
---
|
||||
|
||||
## 加载流程
|
||||
|
||||
`main()`(`:30-52`)的顺序是刻意的:
|
||||
|
||||
```
|
||||
setLoadingMessage("Loading scene")
|
||||
→ fetchJson(metadata) 必需,失败即终止
|
||||
→ fetchOptionalJson(route) 可选,失败降级
|
||||
→ scenePlacement(metadata)
|
||||
→ createViewer()
|
||||
setLoadingMessage("Loading model")
|
||||
→ loadSceneAssets() 逐个资产加载,失败收集不中断
|
||||
→ addVehicleCruises() / createCameraPresets()
|
||||
→ buildAssetToggles() / bindRuntimeControls() / startDiagnostics()
|
||||
→ cameras.overview()
|
||||
→ baseStatus = summaryText(...)
|
||||
setLoadingMessage("Preparing view")
|
||||
→ await waitForStableFrames() 等画面稳定
|
||||
→ document.body.classList.add("scene-ready") ← CSS 靠这个类收起遮罩
|
||||
→ window.osmPreview = {...}
|
||||
```
|
||||
|
||||
### 三档失败语义
|
||||
|
||||
这一层的错误处理分得很清楚,**新增加载逻辑时要先想清楚落在哪一档**:
|
||||
|
||||
| 档 | 做法 | 出处 |
|
||||
|---|---|---|
|
||||
| **必需** | `fetchJson` 直接抛,预览起不来 | `:55-62` |
|
||||
| **可选** | `fetchOptionalJson` 捕获 → `console.warn` → 返回 `null` | `:63-73` |
|
||||
| **部分** | 逐条收集失败,汇总到诊断面板,其余照常显示 | `:163-165` |
|
||||
|
||||
两段注释把理由写清楚了:
|
||||
|
||||
> The route file is an extra on top of the scene, not a precondition for it.
|
||||
> A missing or unreadable route costs the cruise controls, not the preview.
|
||||
|
||||
> One broken entry in metadata.assets should not blank the whole preview, so
|
||||
> failures are collected and surfaced in the diagnostics panel instead.
|
||||
|
||||
### `scene-ready` 是加载态的唯一开关
|
||||
|
||||
`waitForStableFrames()`(`:107`)等若干帧稳定后才加 `scene-ready` 类,CSS 据此
|
||||
收起遮罩。**不要改成定时器或 `load` 事件**——材质编译完成之前画面是花的。
|
||||
|
||||
### `window.osmPreview` 是唯一对外句柄
|
||||
|
||||
```js
|
||||
// Handle for the browser console and for headless checks: everything else
|
||||
// in here is closed over by the IIFE and unreachable from outside. :50-51
|
||||
window.osmPreview = { viewer, metadata, placement, assets, cruise, cameras };
|
||||
```
|
||||
|
||||
调试和无头检查都靠它。**加新的顶层对象就往这里挂**,不要再开新全局。
|
||||
|
||||
---
|
||||
|
||||
## Viewer 配置:一切都关掉
|
||||
|
||||
`createViewer()`(`:74-97`)把 Cesium 的默认 UI 和地球全部关闭:
|
||||
|
||||
```js
|
||||
animation, timeline, baseLayerPicker, geocoder,
|
||||
navigationHelpButton, sceneModePicker, infoBox,
|
||||
selectionIndicator, baseLayer ← 全 false
|
||||
globe.show = false ← 不显示地球
|
||||
skyAtmosphere / skyBox / sun / moon ← 全 false
|
||||
backgroundColor = globe.baseColor = "#d9e0e2" ← PREVIEW_BACKGROUND
|
||||
```
|
||||
|
||||
理由:这是**看单个园区资产**的预览,不是地图应用。留着地球和大气会干扰对
|
||||
材质与几何的判断,也让背景色不可控。
|
||||
|
||||
`depthTestAgainstTerrain = false` —— 没有地形,开着只会让模型被裁。
|
||||
|
||||
保留的只有 `homeButton` 和 `fullscreenButton`。
|
||||
|
||||
---
|
||||
|
||||
## DOM 与状态
|
||||
|
||||
### DOM 句柄集中在顶部
|
||||
|
||||
`:5-20` 一次性取完全部元素引用,**不在函数里现查**。新增控件时加在这一批里。
|
||||
|
||||
### status 的两类消息
|
||||
|
||||
```js
|
||||
// Status carries two kinds of message: the scene summary, which is what the
|
||||
// panel should read whenever nothing else is going on, and transient notes
|
||||
// from a control the user just touched. Keep the summary so the transient
|
||||
// note can be replaced instead of destroying it. :21-24
|
||||
let baseStatus = "";
|
||||
```
|
||||
|
||||
`baseStatus` 存场景摘要,瞬时提示用完要能回到它。**加新的瞬时提示时不要直接覆写
|
||||
`baseStatus`**。
|
||||
|
||||
### 诊断读数跟渲染循环,不跟定时器
|
||||
|
||||
```js
|
||||
// Camera-dependent readouts have to track the camera, so refresh off the
|
||||
// render loop rather than a fixed timer, throttled to stay off the hot path. :589-590
|
||||
```
|
||||
|
||||
相机相关的读数必须跟着渲染循环刷新并**做节流**。用 `setInterval` 会在相机快速移动时
|
||||
读到过期值,不节流会拖慢帧率。
|
||||
|
||||
### 单资产 vs 多资产的开关
|
||||
|
||||
```js
|
||||
// A single-asset scene keeps the plain "Scene" checkbox; a multi-asset one
|
||||
// gets a child checkbox per model with "Scene" acting as the master. :194-195
|
||||
```
|
||||
|
||||
`buildAssetToggles()` 按资产数量决定 UI 形态,`syncSceneMaster()` 维护主从关系。
|
||||
|
||||
---
|
||||
|
||||
## 坐标系
|
||||
|
||||
GLB 停留在**局部 ENU 坐标系**(X 东、Y 北、Z 上),靠伴生 JSON 的放置信息配合
|
||||
`Cesium.Transforms.eastNorthUpToFixedFrame` 摆到地球上
|
||||
(`blender/export_cesium.py:8-9`)。
|
||||
|
||||
`scenePlacement(metadata)`(`:131`)负责这一步。**改动导出侧的坐标约定必须同步改这里。**
|
||||
|
||||
---
|
||||
|
||||
## 本地预览必须走 HTTP
|
||||
|
||||
```bash
|
||||
cd outputs/<area-id>
|
||||
python3 -m http.server 8765
|
||||
# → http://localhost:8765/<area-id>-cesium-preview.html
|
||||
```
|
||||
|
||||
`file://` 会被浏览器的同源策略拦掉 `fetch`,页面停在加载遮罩上。
|
||||
|
||||
---
|
||||
|
||||
## 反模式
|
||||
|
||||
| 反模式 | 后果 |
|
||||
|---|---|
|
||||
| 引入需要打包/转译的语法或 npm 依赖 | 没有构建步骤,直接跑不起来 |
|
||||
| 在 JS 里硬编码 `.glb` / `.json` 文件名 | 换区域就失效,绕过 config 注入 |
|
||||
| 注入 HTML 时不转义 | 区域名带特殊字符就破页面 |
|
||||
| 把可选资源当必需资源加载 | 缺一个路线文件整个预览打不开 |
|
||||
| 单个资产加载失败就中断全部 | 一条坏 metadata 让预览全白 |
|
||||
| 用定时器代替 `waitForStableFrames` | 遮罩在材质编译完成前就收起,画面是花的 |
|
||||
| 相机读数用 `setInterval` | 快速移动时读到过期值 |
|
||||
| 诊断刷新不节流 | 拖慢帧率 |
|
||||
| 再开一个全局变量 | 已有 `window.osmPreview` |
|
||||
| 用 `file://` 打开 | fetch 被拦,卡在加载中 |
|
||||
|
||||
---
|
||||
|
||||
## 相关
|
||||
|
||||
- [CLI 与阶段](../pipeline/cli-and-stages.md):`cesium` / `preview` 阶段如何生成这些文件
|
||||
- [资产生成](../blender/asset-generation.md):GLB 里的材质为什么要单独调色
|
||||
- README「实验:车辆巡航」节:面向使用者的说明
|
||||
196
.trellis/tasks/00-bootstrap-guidelines/design.md
Normal file
196
.trellis/tasks/00-bootstrap-guidelines/design.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# 技术设计:Trellis spec 重建
|
||||
|
||||
> 对应 `prd.md`。本文件定义目录结构、每个文件的内容边界与取材来源。
|
||||
|
||||
---
|
||||
|
||||
## 1. 为什么按这四个包切分
|
||||
|
||||
Trellis 单仓模式下,`.trellis/spec/` 的每个子目录自动成为一个 "spec layer"
|
||||
(`packages_context.py:30-41`,`guides` 被显式排除)。零配置,不需要动
|
||||
`config.yaml` 的 `packages:`(那是 monorepo 用的)。
|
||||
|
||||
切分依据是**运行时边界**,不是目录名:
|
||||
|
||||
| 包 | 覆盖代码 | 运行时 | 切分理由 |
|
||||
|---|---|---|---|
|
||||
| `pipeline/` | `scripts/*.js`, `scripts/lib/scene-layers.js` | Node(宿主机) | 唯一能调外部进程(QGIS/Blender/ogr2ogr)的层 |
|
||||
| `blender/` | `blender/**/*.py` | Blender 内嵌 Python + 系统 Python | 依赖 `bpy`,且内部还有一条纯 Python 子边界 |
|
||||
| `preview/` | `scripts/lib/cesium-preview.{js,css}` | 浏览器 | 无构建步骤的 IIFE,唯一的 DOM 环境 |
|
||||
| `config/` | `config/areas/*.json`, `config/examples/template.json` | 数据(无运行时) | 三层共同消费的契约,改一个字段会同时影响三层 |
|
||||
|
||||
`scripts/normalize-lane-arrows.py` 虽是 Python,但跑在 QGIS 的 Python 里、由
|
||||
`build-osm2streets-qgis.js` 调起,属于管线的外部工具调用,归 `pipeline/`。
|
||||
|
||||
## 2. 目标结构
|
||||
|
||||
```
|
||||
.trellis/spec/
|
||||
├── index.md # 顶层索引:四个包 + guides 的路由表
|
||||
├── pipeline/
|
||||
│ ├── index.md # 包索引 + 五个 stage 的数据流全景
|
||||
│ ├── cli-and-stages.md
|
||||
│ ├── layer-registry.md
|
||||
│ └── external-tools.md
|
||||
├── blender/
|
||||
│ ├── index.md # 包索引 + osmassets 依赖分层图
|
||||
│ ├── module-structure.md
|
||||
│ ├── asset-generation.md
|
||||
│ └── testing.md
|
||||
├── preview/
|
||||
│ └── index.md
|
||||
├── config/
|
||||
│ └── index.md
|
||||
└── guides/
|
||||
├── index.md # 更新:触发点换成本项目的
|
||||
├── code-reuse-thinking-guide.md # 保留,补本项目实例
|
||||
├── cross-layer-thinking-guide.md # 保留,补本项目实例
|
||||
└── artifact-parity-guide.md # 新增
|
||||
```
|
||||
|
||||
被删除:`.trellis/spec/frontend/` 全部 7 个文件(6 个模板 + index)。
|
||||
|
||||
## 3. 每个文件的内容边界与取材
|
||||
|
||||
### 3.1 `pipeline/cli-and-stages.md`
|
||||
|
||||
| 要写什么 | 取材 |
|
||||
|---|---|
|
||||
| `--kebab-case` → `camelCase` 的参数解析约定,重复实现在三个脚本里 | `build-area.js:50`, `reimport-gpkg.js:93`, `build-osm2streets-qgis.js` |
|
||||
| 五个 stage 的职责与依赖顺序 | `build-area.js:32-46` 的顶层调度 |
|
||||
| `reimport` 不在 `all` 里,是恢复步骤不是构建步骤 | `build-area.js:157-160` 注释 |
|
||||
| `intermediates` × `reimport` 互斥,显式抛错 | `build-area.js:19-26` |
|
||||
| 配置归一化:从 `id` 推导默认输出路径,`outputs` 可覆盖 | `build-area.js:74` `normalizeAreaConfig` |
|
||||
| 缺失必填项用 `requireText` 抛错而非默认值兜底 | `build-area.js:143`, `reimport-gpkg.js:125` |
|
||||
| 外部命令统一走 `runCommand`,非零退出即终止 | `build-area.js:301` |
|
||||
|
||||
**边界**:只写"怎么加一个 stage / 怎么加一个 CLI 参数",不复述 README 里的用法示例。
|
||||
|
||||
### 3.2 `pipeline/layer-registry.md`
|
||||
|
||||
最重要的一篇。核心是"九个图层在两种语言里各存一份"这个刻意设计。
|
||||
|
||||
| 要写什么 | 取材 |
|
||||
|---|---|
|
||||
| `SCENE_LAYERS` 是 JS 侧唯一事实源,`zIndex` 兼作绘制顺序 | `scene-layers.js:3-13` 顶部注释 |
|
||||
| 从前同一份表复制了四次(z_index 表 / 样式 JSON / QGIS 工程 / README),改一处漏一处会静默错栈 | `scene-layers.js:5-10` |
|
||||
| `outline: null` = 无描边,QGIS 侧转成全透明 | `scene-layers.js:13-14` |
|
||||
| `mergeScene(getCollection)` 用回调取集合,让 build(内存)和 reimport(磁盘)共用同一套合并 | `scene-layers.js:107-124` |
|
||||
| Python 侧 `catalog.ROAD_LAYERS` 存的是另一组事实(Blender 高度 `z` + 线性色) | `catalog.py:25-47` |
|
||||
| **颜色故意不同步**:JS 是 QGIS sRGB 调试色,Python 是 Blender 线性场景色,分别调过 | `catalog.py:11-15` |
|
||||
| `check_layers()` 只校验图层**集合与顺序**,读的是产物 `osm2streets_scene_style.json` | `catalog.py:162-195` |
|
||||
| 校验是 warn 不是 fail:过期或缺失的输出目录不该阻断重建 | `catalog.py:168-169` |
|
||||
| **加一个图层的完整清单**:改 `scene-layers.js` + `catalog.py` 两处,顺序必须一致 | 综合 |
|
||||
|
||||
### 3.3 `pipeline/external-tools.md`
|
||||
|
||||
| 要写什么 | 取材 |
|
||||
|---|---|
|
||||
| QGIS 可执行文件路径推导(`Contents/MacOS/{ogr2ogr,ogrinfo}`),启动前 `existsSync` 校验 | `reimport-gpkg.js:30-41` |
|
||||
| GDAL 必须注入 `PROJ_LIB` / `GDAL_DATA`,否则坐标系静默出错 | `reimport-gpkg.js:132-137` |
|
||||
| **不设 `COORDINATE_PRECISION`**:会触发精度裁剪,实测丢 28 个顶点 | `reimport-gpkg.js:152-157` |
|
||||
| `ogr2ogr` 失败会留 0 字节文件 → 必须 staging 目录先导出+校验,全通过才拷回 | `reimport-gpkg.js:11-13, 61-91` |
|
||||
| 用 `copyFileSync` 不用 `rename`:临时目录可能跨文件系统 | `reimport-gpkg.js:72-73` |
|
||||
| 导出后必须验 `type === "FeatureCollection"` 且 `features` 是数组 | `reimport-gpkg.js:168-179` |
|
||||
| Blender 以 `--background --factory-startup` 调起,`--` 之后才是脚本参数 | `build-area.js:237-290`, README 低层命令 |
|
||||
| 空图层是警告不是错误 | `reimport-gpkg.js:85-88` |
|
||||
|
||||
### 3.4 `blender/module-structure.md`
|
||||
|
||||
| 要写什么 | 取材 |
|
||||
|---|---|
|
||||
| **按依赖分包不按功能**:`osm.py`/`geom.py` 纯 Python,其余可 import `bpy` | `osmassets/__init__.py:1-12` |
|
||||
| 这条线是几何可测试的前提,拆分前只能靠渲染整片区域来验证 | `__init__.py:9-12`, `test_pure.py:5-8` |
|
||||
| 各模块职责一览(geom/osm/mesh/materials/catalog/tree/water/grass/scrub) | 各文件 docstring |
|
||||
| 要素模块统一 `assemble(...)` 签名,返回计数供调用方汇总 | `water.py:7`, `grass.py:7`, `scrub.py:6` |
|
||||
| 要素模块接收裁剪边界参数,自己调 `clip_polygon`,不假设调用方已裁剪 | 三个 `assemble` 的首行 |
|
||||
| `catalog` 声明"是什么"、`materials` 负责"怎么建"——这个拆分让 catalog 能被无 Blender 环境读取 | `materials.py:1-7` |
|
||||
| 新增一种 OSM 要素 = 新增一个模块 + 注册一行,不改 `build()` | `docs/refactor-plan.md` 目标节 |
|
||||
|
||||
### 3.5 `blender/asset-generation.md`
|
||||
|
||||
| 要写什么 | 取材 |
|
||||
|---|---|
|
||||
| `MeshBatch` 是主力:批成一个 mesh datablock,压低对象数和 glTF 节点数 | `mesh.py:1-9` |
|
||||
| 累积几何后调一次 `finish()` | `mesh.py:6-8` |
|
||||
| 树用实例化:import 一次 → bake 朝向 → 每棵树只链一个轻对象复用 datablock | `tree.py:1-8` |
|
||||
| 材质在本地重建而非沿用源文件,因为两个源模型都不可直接用(apple 57% 贴图透明,需 alpha-clip) | `tree.py:12-16` |
|
||||
| `MATERIALS` / `ROAD_LAYERS` 是 list,**顺序决定 GLB 材质索引**,只能追加 | `catalog.py:16-18` |
|
||||
| `kind` 选择构建器:`solid` / `textured`,`procedural` 叠加噪声 | `catalog.py:52-55` |
|
||||
| Cesium 侧的调色覆盖(`cesium` 字段 + `CESIUM_EXPORT`)为什么单独存在 | `catalog.py:139-153` |
|
||||
| Cesium 默认光照偏白,场景内材质普遍手工提亮过;新资产不提亮会显得发黑 | `docs/changelog.md` 2026-07-31 条目 |
|
||||
|
||||
### 3.6 `blender/testing.md`
|
||||
|
||||
| 要写什么 | 取材 |
|
||||
|---|---|
|
||||
| 运行方式:`python3 -m unittest discover blender/tests`,不需要 Blender | `test_pure.py:1-3` |
|
||||
| **期望值必须从几何推导,不能录制当前实现**——录制型测试会把 bug 固化成规范 | `test_pure.py:9-11` |
|
||||
| 每个几何函数都覆盖退化输入(空、单点、共线、重复顶点、零长线段) | `test_pure.py` 各 `test_degenerate_*` |
|
||||
| 覆盖除零守卫要写明针对哪一行代码 | `test_pure.py:76-78` |
|
||||
| 随机采样函数必须验 seed 可复现 | `test_pure.py:135-138` |
|
||||
| 解析器的容错语义:坏节点跳过不致命,缺 bounds 直接抛 | `test_pure.py:343-356` |
|
||||
| 只能测纯 Python 侧;`bpy` 侧的回归靠 parity 校验 | 指向 `guides/artifact-parity-guide.md` |
|
||||
|
||||
### 3.7 `preview/index.md`
|
||||
|
||||
| 要写什么 | 取材 |
|
||||
|---|---|
|
||||
| 无构建步骤:IIFE + `"use strict"`,通过 `window.OSM_ASSET_PREVIEW_CONFIG` 接参 | `cesium-preview.js:1-4` |
|
||||
| HTML 由 `build-area.js:697 cesiumPreviewHtml()` 生成,注入需转义(`escapeHtml` / `escapeScriptJson`) | `build-area.js:759-767` |
|
||||
| DOM 句柄在顶部集中获取,不散在函数里 | `cesium-preview.js:5-20` |
|
||||
| status 分两类消息(场景摘要 vs 瞬时提示),摘要要保留以便瞬时提示可被替换而非摧毁 | `cesium-preview.js:21-25` |
|
||||
| `Cesium.Ion.defaultAccessToken = ""`:不依赖 Ion 服务 | `cesium-preview.js:27` |
|
||||
| 必须用 HTTP 服务打开,`file://` 会被浏览器拦截 | README 预览节 |
|
||||
| 车辆巡航是预览层实验功能,不影响 Blender/GLB 主资产 | README 实验节 |
|
||||
|
||||
### 3.8 `config/index.md`
|
||||
|
||||
| 要写什么 | 取材 |
|
||||
|---|---|
|
||||
| 字段全表与默认值 | `config/examples/template.json`, `build-area.js:74` |
|
||||
| 路径必须绝对 | template.json |
|
||||
| `outputs` 覆盖是逃生舱,默认从 `id` 推导 | README 区域配置节 |
|
||||
| `qgis.*` 各旋钮的物理含义(arrowScale / arrowMergeTriangles / arrowOutlineSimplifyMeters / intersectionCornerSourceMaxDimensionMeters) | README QGIS knobs 节 |
|
||||
| `arrowOutlineSimplifyMeters: 0.05` 这个默认值的来历(去掉两个畸形尾顶点而不动箭头头部) | README |
|
||||
| `intersectionCornerSourceMaxDimensionMeters` 为什么要过滤大多边形(会盖住可行驶路口) | README |
|
||||
| 新增区域:从 `config/examples/template.json` 复制,落到 `config/areas/` | README |
|
||||
| 已沉淀的两个区域配置 | `config/areas/` |
|
||||
|
||||
### 3.9 `guides/artifact-parity-guide.md`(新增)
|
||||
|
||||
| 要写什么 | 取材 |
|
||||
|---|---|
|
||||
| 什么时候需要跑 parity:任何声称"纯重构"的改动 | `docs/refactor-plan.md` 硬约束节 |
|
||||
| 三件套分工:`scene_digest.py`(.blend 结构)/ `glb-digest.js`(GLB 结构)/ `parity.js`(驱动+比对) | refactor-plan 工具表 |
|
||||
| **必须先做 control 实验**(同代码跑两次)确定天然不稳定字段,跳过这步的 parity 校验是假的 | refactor-plan |
|
||||
| 已确定的不稳定字段与原因:blend sha256(内嵌绝对路径+图片打包顺序)、PNG(EEVEE 非位级可复现)、accessor 数(UV 浮点噪声影响去重) | refactor-plan control 结论 |
|
||||
| 忽略名单写在 `parity.js:IGNORED_PATHS` 且必须附原因 | `parity.js:28-32` |
|
||||
| 真正的契约:stage stdout 标记 + .blend 全量结构摘要 + GLB node/mesh/material/image + `<area>.json` | refactor-plan |
|
||||
| 基线落在 gitignore 的 `outputs/_refactor-baseline/`,是本地草稿不是产物 | `parity.js:16-17` |
|
||||
|
||||
### 3.10 `guides/` 现有两篇的本地化
|
||||
|
||||
保留通用内容,把"触发点"清单换成本项目的真实场景:
|
||||
|
||||
- `cross-layer-thinking-guide.md`:加图层表 JS↔Python 双向同步、GeoJSON→gpkg→GeoJSON 往返、材质名跨 `generate_scene.py`/`export_cesium.py` 对接
|
||||
- `code-reuse-thinking-guide.md`:加 `parseArgs` 在三个脚本重复实现、`clip_polygon` 在四个要素模块各调一次
|
||||
|
||||
### 3.11 `spec/index.md`(新增)
|
||||
|
||||
一张路由表:改哪类代码 → 先读哪个包的 index。加一句"跨语言/跨层改动先读 `guides/`"。
|
||||
|
||||
## 4. 风险与对策
|
||||
|
||||
| 风险 | 对策 |
|
||||
|---|---|
|
||||
| 写成"应该怎么做"的理想规范,与代码实际不符 | 每条约定必须能追到 `path:line`;PRD 验收标准里已定"每文件至少 2 处真实引用" |
|
||||
| 把 README 的用法说明搬进 spec,造成两处维护 | spec 只写"怎么改代码",用法一律指向 README |
|
||||
| 删 `frontend/` 时误删 `guides/` | 只删 `.trellis/spec/frontend/` 单个目录,删后立即用 `--mode packages` 验证 |
|
||||
| 行号引用随代码变动失效 | 行号只作定位提示,同时写函数名/常量名;关键处引用注释原文而非行号 |
|
||||
|
||||
## 5. 兼容性
|
||||
|
||||
- 不动 `config.yaml`:单仓模式自动扫描 `spec/` 子目录
|
||||
- 不动 `.template-hashes.json`:只有 2 个条目,均与本任务文件无关,`trellis update` 不会覆盖
|
||||
- 现有任务的 `implement.jsonl` / `check.jsonl`:本任务尚未生成,无需迁移
|
||||
154
.trellis/tasks/00-bootstrap-guidelines/implement.md
Normal file
154
.trellis/tasks/00-bootstrap-guidelines/implement.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# 执行计划:Trellis spec 重建
|
||||
|
||||
> 对应 `prd.md` / `design.md`。按序执行,每步带验证命令。
|
||||
|
||||
---
|
||||
|
||||
## 前置校验
|
||||
|
||||
```bash
|
||||
# 确认当前 spec layers 是错的(预期输出含 frontend)
|
||||
python3 ./.trellis/scripts/get_context.py --mode packages
|
||||
|
||||
# 确认纯 Python 测试基线是绿的(本任务不该改代码,收尾要复验)
|
||||
python3 -m unittest discover blender/tests -v 2>&1 | tail -5
|
||||
```
|
||||
|
||||
记录测试基线的用例数,收尾时比对。
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — 删除错配脚手架
|
||||
|
||||
- [ ] 删除 `.trellis/spec/frontend/`(7 个文件:index + 6 个模板)
|
||||
|
||||
**只删这一个目录**,`guides/` 必须保留。
|
||||
|
||||
```bash
|
||||
rm -rf .trellis/spec/frontend
|
||||
python3 ./.trellis/scripts/get_context.py --mode packages # Spec layers 应为空
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — `pipeline/` 包(4 个文件)
|
||||
|
||||
按依赖顺序写,`layer-registry.md` 是核心,先写它。
|
||||
|
||||
- [ ] 2.1 `pipeline/layer-registry.md` — 取材 `scene-layers.js` 全文 + `catalog.py:1-19,25-47,162-195`
|
||||
- [ ] 2.2 `pipeline/external-tools.md` — 取材 `reimport-gpkg.js` 全文 + `build-area.js:237-311`
|
||||
- [ ] 2.3 `pipeline/cli-and-stages.md` — 取材 `build-area.js:1-50,74-215`
|
||||
- [ ] 2.4 `pipeline/index.md` — 包索引 + 五个 stage 的数据流全景
|
||||
|
||||
**写 2.3 前需补读**:`build-osm2streets-qgis.js`(1468 行,尚未通读)确认 stage 内部
|
||||
细节与 `normalize-lane-arrows.py` 的调用方式。
|
||||
|
||||
验证:
|
||||
```bash
|
||||
grep -c "scripts/" .trellis/spec/pipeline/*.md # 每个文件应 >= 2
|
||||
grep -rn "To fill\|TODO\|待填" .trellis/spec/pipeline/ ; echo "exit=$?" # 应为 1(无匹配)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — `blender/` 包(4 个文件)
|
||||
|
||||
- [ ] 3.1 `blender/module-structure.md` — 取材 `osmassets/__init__.py` + 各模块 docstring
|
||||
- [ ] 3.2 `blender/testing.md` — 取材 `test_pure.py:1-11` + 各 `test_degenerate_*`
|
||||
- [ ] 3.3 `blender/asset-generation.md` — 取材 `mesh.py`, `materials.py`, `tree.py`, `catalog.py` + changelog 2026-07-31
|
||||
- [ ] 3.4 `blender/index.md` — 包索引 + 依赖分层图(纯 Python / bpy 两层)
|
||||
|
||||
**写 3.3 前需补读**:`generate_scene.py`(999 行)与 `export_cesium.py`(624 行)的
|
||||
结构,确认材质名跨文件对接的实际方式。
|
||||
|
||||
验证:
|
||||
```bash
|
||||
grep -c "blender/" .trellis/spec/blender/*.md
|
||||
grep -rn "To fill\|TODO\|待填" .trellis/spec/blender/ ; echo "exit=$?"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — `preview/` 与 `config/`(各 1 个文件)
|
||||
|
||||
- [ ] 4.1 `preview/index.md` — 取材 `cesium-preview.js` + `build-area.js:697-767`
|
||||
- [ ] 4.2 `config/index.md` — 取材 `config/examples/template.json` + `build-area.js:74-142` + README 配置节
|
||||
|
||||
**写 4.1 前需补读**:`cesium-preview.js`(672 行)主体,目前只读了前 30 行。
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — `guides/` 本地化 + 新增
|
||||
|
||||
- [ ] 5.1 新增 `guides/artifact-parity-guide.md` — 取材 `docs/refactor-plan.md` + `parity.js` + `glb-digest.js` + `scene_digest.py`
|
||||
- [ ] 5.2 更新 `guides/cross-layer-thinking-guide.md` 的触发点清单(保留通用内容)
|
||||
- [ ] 5.3 更新 `guides/code-reuse-thinking-guide.md` 的触发点清单
|
||||
- [ ] 5.4 更新 `guides/index.md` 的指南表格,加入新指南
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — 顶层索引与任务元数据
|
||||
|
||||
- [ ] 6.1 新增 `.trellis/spec/index.md` — 四包路由表
|
||||
- [ ] 6.2 修正 `task.json`:`relatedFiles` 改为四个新包目录,`notes` 去掉 "(frontend project)"
|
||||
|
||||
```bash
|
||||
python3 ./.trellis/scripts/task.py set-meta ... # 或直接编辑 task.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — 全量验收
|
||||
|
||||
对照 `prd.md` 验收标准逐条过:
|
||||
|
||||
```bash
|
||||
# 1. spec layers 正确
|
||||
python3 ./.trellis/scripts/get_context.py --mode packages
|
||||
# 预期:Spec layers: blender, config, pipeline, preview
|
||||
|
||||
# 2. 无占位文本
|
||||
grep -rn "To fill\|TODO\|待填\|FIXME\|<!-- fill" .trellis/spec/ ; echo "exit=$? (1=clean)"
|
||||
|
||||
# 3. frontend 已清除
|
||||
test -d .trellis/spec/frontend && echo "FAIL: still exists" || echo "OK: removed"
|
||||
|
||||
# 4. 每个包有 index.md
|
||||
for d in pipeline blender preview config; do
|
||||
test -f ".trellis/spec/$d/index.md" && echo "OK $d" || echo "FAIL $d"
|
||||
done
|
||||
|
||||
# 5. 关键约定可 grep 定位(8 条)
|
||||
grep -rl "COORDINATE_PRECISION" .trellis/spec/ # 约定 3
|
||||
grep -rl "check_layers" .trellis/spec/ # 约定 1
|
||||
grep -rl "PROJ_LIB\|GDAL_DATA" .trellis/spec/ # 约定 4 相关
|
||||
grep -rl "互斥" .trellis/spec/ # 约定 5
|
||||
grep -rl "bpy" .trellis/spec/blender/ # 约定 6
|
||||
grep -rl "parity" .trellis/spec/ # 约定 8
|
||||
|
||||
# 6. 代码未被误改
|
||||
git status --porcelain -- scripts/ blender/ config/ # 应为空
|
||||
python3 -m unittest discover blender/tests 2>&1 | tail -3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 回滚点
|
||||
|
||||
本任务只新增/删除 `.trellis/spec/` 下的文件,且删除的 `frontend/` 是**未提交的
|
||||
未跟踪文件**(`.trellis/` 整体尚未入库)。
|
||||
|
||||
回滚代价:`frontend/` 的 7 个空模板删掉后无法从 git 恢复。但它们是 `trellis init`
|
||||
生成的、内容为纯占位符,可用 `trellis init` 重新生成,或直接接受丢失——PRD 已确认
|
||||
它们对本项目无价值。
|
||||
|
||||
**保险起见**:Step 1 执行前先把 `.trellis/spec/frontend/` 打包到
|
||||
`/tmp/trellis-frontend-backup.tar.gz`,任务归档后再删。
|
||||
|
||||
---
|
||||
|
||||
## 审查门
|
||||
|
||||
- Step 2 完成后暂停,让用户看 `pipeline/layer-registry.md`——这篇最能反映
|
||||
spec 的目标风格。风格若不对,后面 7 个文件不必按同样方式写完再返工。
|
||||
- Step 7 全部通过后再报告完成。
|
||||
73
.trellis/tasks/00-bootstrap-guidelines/prd.md
Normal file
73
.trellis/tasks/00-bootstrap-guidelines/prd.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# 补全 Trellis 项目规范文档
|
||||
|
||||
**类型**:docs · **负责人**:dingkang · **创建**:2026-08-03
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
`trellis init` 在本仓库生成了 `.trellis/` 脚手架(v0.6.12),但把项目误判成了前端项目:
|
||||
|
||||
- `.trellis/spec/frontend/` 下 6 个文件全是 React/TypeScript 的空模板,状态栏统一写着 "To fill"
|
||||
- 本仓库实际是 **OSM → QGIS/Blender/Cesium 的资产生成管线**,没有任何前端代码
|
||||
- `task.json` 的 `notes` 直接写着 "First-time setup task created by trellis init (frontend project)"
|
||||
|
||||
后果是具体的:`trellis-implement` / `trellis-check` 子代理会按 `implement.jsonl` / `check.jsonl` 自动加载 spec 文件。当前 spec 为空且方向错误,等于子代理在无约束下写代码——而这个项目有大量**违反直觉、必须遵守**的约定(详见下方"关键约定"),一旦被子代理无意破坏,产物会静默出错而不是报错。
|
||||
|
||||
## 目标
|
||||
|
||||
用真实代码中提取的约定,替换错配的 spec 脚手架,使任何 AI 会话在动手前就能拿到本项目的实际工程约束。
|
||||
|
||||
## 非目标
|
||||
|
||||
- **不改动任何业务代码**。本任务只写 `.trellis/` 下的文档
|
||||
- **不修复代码中的已知缺陷**。`docs/refactor-plan.md` 记录的 D1/D2/D3 只做记录,不在此任务修
|
||||
- **不重写 README/changelog**。它们面向人类使用者,spec 面向 AI 执行者,两者共存不合并
|
||||
- **不引入新的检查脚本或 CI**
|
||||
|
||||
## 交付物
|
||||
|
||||
| # | 交付物 | 说明 |
|
||||
|---|---|---|
|
||||
| D1 | 删除 `.trellis/spec/frontend/` | 6 个空的 React 模板文件,全部移除 |
|
||||
| D2 | `.trellis/spec/pipeline/` | Node 构建管线约定,4 个文件 |
|
||||
| D3 | `.trellis/spec/blender/` | Blender Python 约定,4 个文件 |
|
||||
| D4 | `.trellis/spec/preview/` | Cesium 预览层约定,1 个文件 |
|
||||
| D5 | `.trellis/spec/config/` | 区域配置 JSON 约定,1 个文件 |
|
||||
| D6 | `.trellis/spec/guides/` 本地化 | 更新 index,新增产物一致性指南 |
|
||||
| D7 | `.trellis/spec/index.md` | 顶层索引 |
|
||||
| D8 | `task.json` 元数据修正 | `relatedFiles` / `notes` 去掉 frontend 误判 |
|
||||
|
||||
结构详见 `design.md`。
|
||||
|
||||
## 关键约定(必须被 spec 覆盖)
|
||||
|
||||
这些是从代码注释和 `docs/refactor-plan.md` 中确认的、**违反直觉且破坏后果静默**的约束。spec 的价值主要在这里:
|
||||
|
||||
1. **图层表跨语言单一事实源** — 九个 osm2streets 图层的顺序在 `scripts/lib/scene-layers.js`(JS 侧)和 `blender/osmassets/catalog.py`(Python 侧)各存一份,靠 `catalog.check_layers()` 运行时校验。颜色**故意不同步**(QGIS sRGB 调试色 vs Blender 线性场景色)。
|
||||
2. **顺序是承重的** — `ROAD_LAYERS` / `MATERIALS` 是 list 不是 dict,因为材质创建顺序决定导出 GLB 里的材质索引。只能追加。
|
||||
3. **`ogr2ogr` 不能设 `COORDINATE_PRECISION`** — 显式设置会触发 GDAL 的精度裁剪,实测丢失 7 个箭头多边形的 28 个顶点。
|
||||
4. **`ogr2ogr` 导出失败会留下 0 字节文件** — 所以 `reimport` 必须先导到临时目录、全部校验通过才拷回,不能直接写目标目录。
|
||||
5. **`intermediates` 与 `reimport` 互斥** — 前者从 OSM 重建 gpkg,正好抹掉后者要读回的手工修改。代码里是显式抛错,不是警告。
|
||||
6. **`osmassets` 按依赖分包,不按功能** — `osm.py` / `geom.py` 是纯 Python(可用系统 python 测试),其余可以 import `bpy`。这条线一旦被破坏,`blender/tests/test_pure.py` 就跑不起来。
|
||||
7. **测试的期望值必须从几何推导** — `test_pure.py` 开头明确写着"记录当前输出的测试会把 bug 固化成规范"。
|
||||
8. **重构必须过 parity 校验** — `scripts/parity.js` + `glb-digest.js` + `scene_digest.py` 三件套,比对的是结构摘要而非字节。哪些字段天然不稳定已经用 control 实验确定并列入忽略名单。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] `.trellis/spec/frontend/` 已删除,`python3 ./.trellis/scripts/get_context.py --mode packages` 输出的 Spec layers 为 `blender, config, pipeline, preview`
|
||||
- [ ] 每个 spec 文件都包含**至少 2 处指向真实文件的引用**(`path:line` 或 `path` + 函数名),没有假想路径
|
||||
- [ ] 上方"关键约定" 8 条全部落到具体 spec 文件中,可通过 grep 定位
|
||||
- [ ] 没有任何文件残留 "To fill" / "TODO" / 模板占位文本
|
||||
- [ ] 文档语言为中文;标识符、路径、代码示例保持英文原样
|
||||
- [ ] 每个包目录有 `index.md`,且顶层 `.trellis/spec/index.md` 能索引到全部包
|
||||
- [ ] `blender/tests/test_pure.py` 仍能通过(确认本任务未误改代码)
|
||||
|
||||
## 完成后
|
||||
|
||||
```bash
|
||||
python3 ./.trellis/scripts/task.py finish
|
||||
python3 ./.trellis/scripts/task.py archive 00-bootstrap-guidelines
|
||||
```
|
||||
|
||||
归档后,新加入的开发者会拿到 `00-join-<slug>` 引导任务而不是这个 bootstrap 任务。
|
||||
33
.trellis/tasks/00-bootstrap-guidelines/task.json
Normal file
33
.trellis/tasks/00-bootstrap-guidelines/task.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"id": "00-bootstrap-guidelines",
|
||||
"name": "00-bootstrap-guidelines",
|
||||
"title": "Bootstrap Guidelines",
|
||||
"description": "Fill in project development guidelines for AI agents",
|
||||
"status": "in_progress",
|
||||
"dev_type": "docs",
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P1",
|
||||
"creator": "dingkang",
|
||||
"assignee": "dingkang",
|
||||
"createdAt": "2026-08-03",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": null,
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [
|
||||
".trellis/spec/index.md",
|
||||
".trellis/spec/pipeline/",
|
||||
".trellis/spec/blender/",
|
||||
".trellis/spec/preview/",
|
||||
".trellis/spec/config/",
|
||||
".trellis/spec/guides/"
|
||||
],
|
||||
"notes": "First-time setup task for project-specific Trellis spec guidelines.",
|
||||
"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]
|
||||
|
||||
- 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]
|
||||
|
||||
### 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]
|
||||
|
||||
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]
|
||||
|
||||
**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]
|
||||
|
||||
Skip this step. Context is loaded directly by the `trellis-before-dev` skill in Phase 2.
|
||||
|
||||
[/codex-inline, Kilo, Antigravity, Devin]
|
||||
|
||||
#### 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]
|
||||
|
||||
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]
|
||||
|
||||
#### 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]
|
||||
|
||||
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]
|
||||
|
||||
**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)
|
||||
40
.trellis/workspace/dingkang/index.md
Normal file
40
.trellis/workspace/dingkang/index.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Workspace Index - dingkang
|
||||
|
||||
> 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
|
||||
7
.trellis/workspace/dingkang/journal-1.md
Normal file
7
.trellis/workspace/dingkang/journal-1.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Journal - dingkang (Part 1)
|
||||
|
||||
> AI development session journal
|
||||
> Started: 2026-08-03
|
||||
|
||||
---
|
||||
|
||||
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**.
|
||||
Reference in New Issue
Block a user