From 0b4f55c352a23cf7924f5fd22ec08e730fb05d37 Mon Sep 17 00:00:00 2001 From: "Mr.Xia" <1424473282@qq.com> Date: Mon, 25 May 2026 10:24:22 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=B0=86=20.trellis=20=E5=8A=A0?= =?UTF-8?q?=E5=85=A5=20.gitignore=20=E5=B9=B6=E7=A7=BB=E9=99=A4=E5=B7=B2?= =?UTF-8?q?=E8=B7=9F=E8=B8=AA=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .gitignore | 5 +- .trellis/.gitignore | 32 - .trellis/.template-hashes.json | 119 --- .trellis/.version | 1 - .trellis/config.yaml | 90 -- .trellis/scripts/__init__.py | 5 - .trellis/scripts/add_session.py | 547 ------------ .trellis/scripts/common/__init__.py | 92 -- .trellis/scripts/common/active_task.py | 626 ------------- .trellis/scripts/common/cli_adapter.py | 811 ----------------- .trellis/scripts/common/config.py | 445 ---------- .trellis/scripts/common/developer.py | 190 ---- .trellis/scripts/common/git.py | 31 - .trellis/scripts/common/git_context.py | 106 --- .trellis/scripts/common/io.py | 37 - .trellis/scripts/common/log.py | 45 - .trellis/scripts/common/packages_context.py | 238 ----- .trellis/scripts/common/paths.py | 447 ---------- .trellis/scripts/common/safe_commit.py | 285 ------ .trellis/scripts/common/session_context.py | 821 ------------------ .trellis/scripts/common/task_context.py | 223 ----- .trellis/scripts/common/task_queue.py | 188 ---- .trellis/scripts/common/task_store.py | 697 --------------- .trellis/scripts/common/task_utils.py | 274 ------ .trellis/scripts/common/tasks.py | 112 --- .trellis/scripts/common/trellis_config.py | 131 --- .trellis/scripts/common/types.py | 110 --- .trellis/scripts/common/workflow_phase.py | 215 ----- .trellis/scripts/get_context.py | 16 - .trellis/scripts/get_developer.py | 26 - .trellis/scripts/hooks/linear_sync.py | 243 ------ .trellis/scripts/init_developer.py | 51 -- .trellis/scripts/task.py | 500 ----------- .trellis/spec/backend/database-guidelines.md | 51 -- .trellis/spec/backend/directory-structure.md | 54 -- .trellis/spec/backend/error-handling.md | 51 -- .trellis/spec/backend/index.md | 38 - .trellis/spec/backend/logging-guidelines.md | 51 -- .trellis/spec/backend/quality-guidelines.md | 51 -- .../spec/frontend/component-guidelines.md | 59 -- .trellis/spec/frontend/directory-structure.md | 54 -- .trellis/spec/frontend/hook-guidelines.md | 51 -- .trellis/spec/frontend/index.md | 39 - .trellis/spec/frontend/quality-guidelines.md | 51 -- .trellis/spec/frontend/state-management.md | 87 -- .trellis/spec/frontend/type-safety.md | 51 -- .../spec/guides/code-reuse-thinking-guide.md | 105 --- .../spec/guides/cross-layer-thinking-guide.md | 162 ---- .trellis/spec/guides/index.md | 79 -- .trellis/tasks/00-bootstrap-guidelines/prd.md | 139 --- .../tasks/00-bootstrap-guidelines/task.json | 29 - .../05-22-defer-sort-seed-check/check.jsonl | 1 - .../implement.jsonl | 1 - .../05-22-defer-sort-seed-check/prd.md | 52 -- .../05-22-defer-sort-seed-check/task.json | 26 - .trellis/workflow.md | 690 --------------- .trellis/workspace/Mr.Xia/index.md | 41 - .trellis/workspace/Mr.Xia/journal-1.md | 40 - .trellis/workspace/index.md | 125 --- 59 files changed, 4 insertions(+), 9933 deletions(-) delete mode 100644 .trellis/.gitignore delete mode 100644 .trellis/.template-hashes.json delete mode 100644 .trellis/.version delete mode 100644 .trellis/config.yaml delete mode 100644 .trellis/scripts/__init__.py delete mode 100644 .trellis/scripts/add_session.py delete mode 100644 .trellis/scripts/common/__init__.py delete mode 100644 .trellis/scripts/common/active_task.py delete mode 100644 .trellis/scripts/common/cli_adapter.py delete mode 100644 .trellis/scripts/common/config.py delete mode 100644 .trellis/scripts/common/developer.py delete mode 100644 .trellis/scripts/common/git.py delete mode 100644 .trellis/scripts/common/git_context.py delete mode 100644 .trellis/scripts/common/io.py delete mode 100644 .trellis/scripts/common/log.py delete mode 100644 .trellis/scripts/common/packages_context.py delete mode 100644 .trellis/scripts/common/paths.py delete mode 100644 .trellis/scripts/common/safe_commit.py delete mode 100644 .trellis/scripts/common/session_context.py delete mode 100644 .trellis/scripts/common/task_context.py delete mode 100644 .trellis/scripts/common/task_queue.py delete mode 100644 .trellis/scripts/common/task_store.py delete mode 100644 .trellis/scripts/common/task_utils.py delete mode 100644 .trellis/scripts/common/tasks.py delete mode 100644 .trellis/scripts/common/trellis_config.py delete mode 100644 .trellis/scripts/common/types.py delete mode 100644 .trellis/scripts/common/workflow_phase.py delete mode 100644 .trellis/scripts/get_context.py delete mode 100644 .trellis/scripts/get_developer.py delete mode 100644 .trellis/scripts/hooks/linear_sync.py delete mode 100644 .trellis/scripts/init_developer.py delete mode 100644 .trellis/scripts/task.py delete mode 100644 .trellis/spec/backend/database-guidelines.md delete mode 100644 .trellis/spec/backend/directory-structure.md delete mode 100644 .trellis/spec/backend/error-handling.md delete mode 100644 .trellis/spec/backend/index.md delete mode 100644 .trellis/spec/backend/logging-guidelines.md delete mode 100644 .trellis/spec/backend/quality-guidelines.md delete mode 100644 .trellis/spec/frontend/component-guidelines.md delete mode 100644 .trellis/spec/frontend/directory-structure.md delete mode 100644 .trellis/spec/frontend/hook-guidelines.md delete mode 100644 .trellis/spec/frontend/index.md delete mode 100644 .trellis/spec/frontend/quality-guidelines.md delete mode 100644 .trellis/spec/frontend/state-management.md delete mode 100644 .trellis/spec/frontend/type-safety.md delete mode 100644 .trellis/spec/guides/code-reuse-thinking-guide.md delete mode 100644 .trellis/spec/guides/cross-layer-thinking-guide.md delete mode 100644 .trellis/spec/guides/index.md delete mode 100644 .trellis/tasks/00-bootstrap-guidelines/prd.md delete mode 100644 .trellis/tasks/00-bootstrap-guidelines/task.json delete mode 100644 .trellis/tasks/archive/2026-05/05-22-defer-sort-seed-check/check.jsonl delete mode 100644 .trellis/tasks/archive/2026-05/05-22-defer-sort-seed-check/implement.jsonl delete mode 100644 .trellis/tasks/archive/2026-05/05-22-defer-sort-seed-check/prd.md delete mode 100644 .trellis/tasks/archive/2026-05/05-22-defer-sort-seed-check/task.json delete mode 100644 .trellis/workflow.md delete mode 100644 .trellis/workspace/Mr.Xia/index.md delete mode 100644 .trellis/workspace/Mr.Xia/journal-1.md delete mode 100644 .trellis/workspace/index.md diff --git a/.gitignore b/.gitignore index 9491a2f..6ff73b3 100644 --- a/.gitignore +++ b/.gitignore @@ -360,4 +360,7 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd + +# Trellis task management +.trellis/ \ No newline at end of file diff --git a/.trellis/.gitignore b/.trellis/.gitignore deleted file mode 100644 index 5a991ea..0000000 --- a/.trellis/.gitignore +++ /dev/null @@ -1,32 +0,0 @@ -# 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 diff --git a/.trellis/.template-hashes.json b/.trellis/.template-hashes.json deleted file mode 100644 index 16d6c78..0000000 --- a/.trellis/.template-hashes.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "__version": 2, - "hashes": { - ".claude/agents/trellis-check.md": "d1359521f7f3e9bbbf10e856a3e0912c423581a88ac188b1f0523d6357962909", - ".claude/agents/trellis-implement.md": "61155f06ccdd26e5aeb8171face2a029a8fb77a3d1a2b277442ded186853446c", - ".claude/agents/trellis-research.md": "f95e69d638266056713e79c884ead1e99d376d70284f66255b6dd139a3e712be", - ".claude/settings.json": "01226db3027908dac1260955e205877ee46c1d410912172d8bae9c53527b3b0f", - ".claude/hooks/inject-subagent-context.py": "d8d69631b43ef469030ff78410c2e082f721f79b7d6eaa754b7a1a1c05810242", - ".claude/hooks/inject-workflow-state.py": "fe4cca4db7ca8c252f614efca16fa588009a4238542a2c9aa85167409f6f9a4c", - ".claude/hooks/session-start.py": "8b31b8e87f6a678ca58711b5558a5292cb0cfea0cb9c54a75b0c7186efcfb58a", - ".claude/commands/trellis/continue.md": "e609e940236f33a9b05a15173606cc3f72285994904d6723382c490d94994aba", - ".claude/commands/trellis/finish-work.md": "f11f661cff6d5d26dccb5e9574c3d2c7873a9dfaed7962d471ff5ea2fd48d691", - ".claude/skills/trellis-before-dev/SKILL.md": "310e0121d5915a8aa46596fc172b53a7bdbaae4fd11699500e3166783a15a180", - ".claude/skills/trellis-brainstorm/SKILL.md": "603d023a10dbee625d540df98020f2dde4b6e5c2bf981226253b88d8eff7c388", - ".claude/skills/trellis-break-loop/SKILL.md": "35afb53fef42cd494e566f1ef170dbf442ec2be7e19931f28a14079b4dda753f", - ".claude/skills/trellis-check/SKILL.md": "8ce33f85051a339e77722bab214562ba7aa041629e285381434bf51c7c710205", - ".claude/skills/trellis-update-spec/SKILL.md": "d975db7af166578488958751ae2c56edb827a68bddb569aa27acc3453f64e610", - ".claude/skills/trellis-meta/references/customize-local/add-project-local-conventions.md": "ef3380e71aa9f5103d37b467b1f725a8033ac516e4de31e4d790be02ec2c39e8", - ".claude/skills/trellis-meta/references/customize-local/change-agents.md": "7f2982162463f107f8b1a4fa1a41fee2bc7dbd0cc8e90c48559aba30c3ea403c", - ".claude/skills/trellis-meta/references/customize-local/change-context-loading.md": "aacdaaca13a4420b9fddf0023d90d3bf06d4aa96ae51c44a201f81b3f3723088", - ".claude/skills/trellis-meta/references/customize-local/change-hooks.md": "c8b35dda1530de521cf6bb043188f0cbbea0c9180b1aa44e64e31e20433ef4ca", - ".claude/skills/trellis-meta/references/customize-local/change-skills-or-commands.md": "b3009ef20a4f24e5d8b196109dc9bab6bd30fc030dbc4fb796afdd2ca912e1ea", - ".claude/skills/trellis-meta/references/customize-local/change-spec-structure.md": "b6facc3976df445ff478ca06459b87b67b7c494b98ccfc53a55bdb78a079babf", - ".claude/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "148b7442ef8106de907afd06f9d1ca96f7ec074caedced3dd4175b3a26698ca2", - ".claude/skills/trellis-meta/references/customize-local/change-workflow.md": "6f1707a2cc032c50e41e5624cef46071dd53dc9810bc6b3cae66d86508dea1cb", - ".claude/skills/trellis-meta/references/customize-local/overview.md": "465db9cecf085b37f7aed2fc5240c92c638e937f7960ca35b0f05a780dd4fdc9", - ".claude/skills/trellis-meta/references/local-architecture/context-injection.md": "31286b9c05e600db7d179100eca533f9b8a4aab3a9c255cb69e8dccacb4e8375", - ".claude/skills/trellis-meta/references/local-architecture/generated-files.md": "4356517517cef0ba7f3ba01965a4ba8953505702e4085f0797d3e36817c9669f", - ".claude/skills/trellis-meta/references/local-architecture/overview.md": "45ffd4ee95020f58201adc885f3dfc89b26483c2b350d96ca7f2f57f94d5ff5f", - ".claude/skills/trellis-meta/references/local-architecture/spec-system.md": "dd53adaf18374c8ce598092a24847c43a4e661b2708c379615e86defd21f107d", - ".claude/skills/trellis-meta/references/local-architecture/task-system.md": "c80af5ae864b86c33eac4442d1244451f6cbcf5f87effccd17cd1856aa00315e", - ".claude/skills/trellis-meta/references/local-architecture/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6", - ".claude/skills/trellis-meta/references/local-architecture/workspace-memory.md": "79786a1ca2980b1785a36aba8142f9d879459c47dc000c999f638e5c864d04d3", - ".claude/skills/trellis-meta/references/platform-files/agents.md": "700e1b7ba89b304f0ee7d26528d897f0c66e382801913e20b59323651f5ca675", - ".claude/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "6e2d6d88719c2779fe34004f63d36cff203d8f64e7fb620f7cb1cde15c37c462", - ".claude/skills/trellis-meta/references/platform-files/overview.md": "6479cd2393166b4b369b511c44b78cbc64975c8b1df96ee1d4d1bd06b75cd48d", - ".claude/skills/trellis-meta/references/platform-files/platform-map.md": "ded6751c06f31d0a701d33c9dd69c482a583539ad3ed464aaad9e705f793b212", - ".claude/skills/trellis-meta/references/platform-files/skills-and-commands.md": "85435eb8bb6921283575bca51268fc534c22fd3ca33782e841ee5c76140ae48f", - ".claude/skills/trellis-meta/SKILL.md": "942e898a6fd769a93a3ca6f43f9fe0412d0adae011654fd384e9cacbd2af4f34", - ".claude/skills/trellis-spec-bootstarp/references/mcp-setup.md": "df542fc8f279edd38046d26a7c8151804b708f57b24d4aa2733cea587a88c65e", - ".claude/skills/trellis-spec-bootstarp/references/repository-analysis.md": "0dae98d774f6e34559b9f3442888ac43e3a8af110c37cbefc49ce256986858b6", - ".claude/skills/trellis-spec-bootstarp/references/spec-task-planning.md": "ef493d028c3b0807a8a534bb71fb92a68129f273db763ad27ceb464a522e799d", - ".claude/skills/trellis-spec-bootstarp/references/spec-writing.md": "e9800fe9ed4a4cd87062ea1829cf2caa8d170ec15e141678a6a30e74c497f47d", - ".claude/skills/trellis-spec-bootstarp/SKILL.md": "81f400092b21392161e7d9dfa9111c9be36c81bc8641d252ed28e08373449ac0", - ".agents/skills/trellis-continue/SKILL.md": "122b45675d33e23e71540a591606d9ba8d028215b2abcd07db86bf45624841dd", - ".agents/skills/trellis-finish-work/SKILL.md": "79e6d165358253a7379cae647bbd50b6bf174a1107f451b768a2bb4ba7cc0b87", - ".agents/skills/trellis-before-dev/SKILL.md": "310e0121d5915a8aa46596fc172b53a7bdbaae4fd11699500e3166783a15a180", - ".agents/skills/trellis-brainstorm/SKILL.md": "0ade8c1cd37e107a2e878025ffc42e8a870d154bcad53f8c63399836aa40a635", - ".agents/skills/trellis-break-loop/SKILL.md": "35afb53fef42cd494e566f1ef170dbf442ec2be7e19931f28a14079b4dda753f", - ".agents/skills/trellis-check/SKILL.md": "8ce33f85051a339e77722bab214562ba7aa041629e285381434bf51c7c710205", - ".agents/skills/trellis-update-spec/SKILL.md": "003ce08a3404aeb50998029392c4d4e57b626edf526d3ebd585032bb92dcbb96", - ".agents/skills/trellis-meta/references/customize-local/add-project-local-conventions.md": "ef3380e71aa9f5103d37b467b1f725a8033ac516e4de31e4d790be02ec2c39e8", - ".agents/skills/trellis-meta/references/customize-local/change-agents.md": "7f2982162463f107f8b1a4fa1a41fee2bc7dbd0cc8e90c48559aba30c3ea403c", - ".agents/skills/trellis-meta/references/customize-local/change-context-loading.md": "aacdaaca13a4420b9fddf0023d90d3bf06d4aa96ae51c44a201f81b3f3723088", - ".agents/skills/trellis-meta/references/customize-local/change-hooks.md": "c8b35dda1530de521cf6bb043188f0cbbea0c9180b1aa44e64e31e20433ef4ca", - ".agents/skills/trellis-meta/references/customize-local/change-skills-or-commands.md": "b3009ef20a4f24e5d8b196109dc9bab6bd30fc030dbc4fb796afdd2ca912e1ea", - ".agents/skills/trellis-meta/references/customize-local/change-spec-structure.md": "b6facc3976df445ff478ca06459b87b67b7c494b98ccfc53a55bdb78a079babf", - ".agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "148b7442ef8106de907afd06f9d1ca96f7ec074caedced3dd4175b3a26698ca2", - ".agents/skills/trellis-meta/references/customize-local/change-workflow.md": "6f1707a2cc032c50e41e5624cef46071dd53dc9810bc6b3cae66d86508dea1cb", - ".agents/skills/trellis-meta/references/customize-local/overview.md": "465db9cecf085b37f7aed2fc5240c92c638e937f7960ca35b0f05a780dd4fdc9", - ".agents/skills/trellis-meta/references/local-architecture/context-injection.md": "31286b9c05e600db7d179100eca533f9b8a4aab3a9c255cb69e8dccacb4e8375", - ".agents/skills/trellis-meta/references/local-architecture/generated-files.md": "4356517517cef0ba7f3ba01965a4ba8953505702e4085f0797d3e36817c9669f", - ".agents/skills/trellis-meta/references/local-architecture/overview.md": "45ffd4ee95020f58201adc885f3dfc89b26483c2b350d96ca7f2f57f94d5ff5f", - ".agents/skills/trellis-meta/references/local-architecture/spec-system.md": "dd53adaf18374c8ce598092a24847c43a4e661b2708c379615e86defd21f107d", - ".agents/skills/trellis-meta/references/local-architecture/task-system.md": "c80af5ae864b86c33eac4442d1244451f6cbcf5f87effccd17cd1856aa00315e", - ".agents/skills/trellis-meta/references/local-architecture/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6", - ".agents/skills/trellis-meta/references/local-architecture/workspace-memory.md": "79786a1ca2980b1785a36aba8142f9d879459c47dc000c999f638e5c864d04d3", - ".agents/skills/trellis-meta/references/platform-files/agents.md": "700e1b7ba89b304f0ee7d26528d897f0c66e382801913e20b59323651f5ca675", - ".agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "6e2d6d88719c2779fe34004f63d36cff203d8f64e7fb620f7cb1cde15c37c462", - ".agents/skills/trellis-meta/references/platform-files/overview.md": "6479cd2393166b4b369b511c44b78cbc64975c8b1df96ee1d4d1bd06b75cd48d", - ".agents/skills/trellis-meta/references/platform-files/platform-map.md": "ded6751c06f31d0a701d33c9dd69c482a583539ad3ed464aaad9e705f793b212", - ".agents/skills/trellis-meta/references/platform-files/skills-and-commands.md": "85435eb8bb6921283575bca51268fc534c22fd3ca33782e841ee5c76140ae48f", - ".agents/skills/trellis-meta/SKILL.md": "942e898a6fd769a93a3ca6f43f9fe0412d0adae011654fd384e9cacbd2af4f34", - ".agents/skills/trellis-spec-bootstarp/references/mcp-setup.md": "df542fc8f279edd38046d26a7c8151804b708f57b24d4aa2733cea587a88c65e", - ".agents/skills/trellis-spec-bootstarp/references/repository-analysis.md": "0dae98d774f6e34559b9f3442888ac43e3a8af110c37cbefc49ce256986858b6", - ".agents/skills/trellis-spec-bootstarp/references/spec-task-planning.md": "ef493d028c3b0807a8a534bb71fb92a68129f273db763ad27ceb464a522e799d", - ".agents/skills/trellis-spec-bootstarp/references/spec-writing.md": "e9800fe9ed4a4cd87062ea1829cf2caa8d170ec15e141678a6a30e74c497f47d", - ".agents/skills/trellis-spec-bootstarp/SKILL.md": "81f400092b21392161e7d9dfa9111c9be36c81bc8641d252ed28e08373449ac0", - ".agents/skills/trellis-start/SKILL.md": "1079cf20a0c7b7decff5f992cb31c7a212debad819c290a95fc1bb855c0356b7", - ".codex/agents/trellis-check.toml": "a89c70b6ac6ee0a77bc215a22c3097d60605269818434e9f37949a01355a47d8", - ".codex/agents/trellis-implement.toml": "ba118cdc8ec8665b64de3fd7d6dc76788374807d3db2d40c7612b05df0506cdd", - ".codex/agents/trellis-research.toml": "73bf9654d99ee60cec9f6d77fe60fe2e32afbdd7d3f01c8f759c131364bf3c31", - ".codex/hooks/session-start.py": "3fd36631fc85a9d12a698d79b459008d568dc276bc0aa2bf0191acebb559c934", - ".codex/hooks/inject-workflow-state.py": "fe4cca4db7ca8c252f614efca16fa588009a4238542a2c9aa85167409f6f9a4c", - ".codex/hooks.json": "7bad6065612c5bd0d4e0bb587bf3c9f3950c8060f9a52f4defd7247dd9ba8aec", - ".codex/config.toml": "4224eb7df6802a623cb1bee522aed0a23ba6be862b90f1b597a313fc16864b06", - "AGENTS.md": "6cacfe99748b435d0660c2463c697bc323d53798aecf3492283ca8eac1b29682", - ".trellis/config.yaml": "5c9207418cecc390e9d86d589b4183b831f76697d92fb42fefd5221cd8772e51", - ".trellis/scripts/add_session.py": "f26b66a539d160c739d4b88fd926b3d7f6745be326cd57131e5ef17a7b011fbe", - ".trellis/scripts/common/active_task.py": "6c88ed40ef7289bca0f6d2ecba0f8b8aef46cd58788080fbeeea88de138a431f", - ".trellis/scripts/common/cli_adapter.py": "cd844d1e84b1a09b373b3a7609e4d5606ee9d4825154c002cc9bb3f54c8e2fb9", - ".trellis/scripts/common/config.py": "25c5a53ad20d6909be5209222e4208a84528805316a4d78350529459a364edb1", - ".trellis/scripts/common/developer.py": "b2141b0145a41f8cedb4f9a24c925796edb2f0f6fde7c86b559513ec30499368", - ".trellis/scripts/common/git.py": "e14817be7de122d3a106f509c2825aeb9669d962ba73ba241642d2931cfdf1d6", - ".trellis/scripts/common/git_context.py": "fa30ced454f1a91ffc9f8b2abeb32225e3447cbdc90bad783797374eba07265d", - ".trellis/scripts/common/io.py": "6480b181f2bc505323b28ed7a66963d7b7edc96251e83b4c8e7a45907cc721c8", - ".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": "8789bff4b30a9065469210f2efab3f59f03dddd77bef4e4b6a5bb641f93539f4", - ".trellis/scripts/common/session_context.py": "d669b96fd7a608808695b9e82e9bfd1693a9ae98ade03cc8dce6c24487696793", - ".trellis/scripts/common/tasks.py": "4436a8b0b53c270a35989e26d9dbd92669408c6562d88c02083a404562da85fe", - ".trellis/scripts/common/task_context.py": "1c16a7fa82d363010d0d0ebdc038296ae1552bf6e90214787d707f49567bc159", - ".trellis/scripts/common/task_queue.py": "0be61f713462b1fe4574927c82fc4704e678afe72dcb9813543aedf2f9e9e0c5", - ".trellis/scripts/common/task_store.py": "4a6ad7f15fd6fdca0da174804ee2d750919d42e46066a891c7525aa8d8a2c592", - ".trellis/scripts/common/task_utils.py": "f5ef4af87ba3e11d8b19630c0c96d009de1811fc9be56c2027a9c96e21ed103e", - ".trellis/scripts/common/trellis_config.py": "0839dcf90ebbd77712c276930a89335b3313927051650c91d220fb51ca2a6a3c", - ".trellis/scripts/common/types.py": "9962081cc2608fb9d1deb32c6880e336f62cdca6b338e7ae813304701e155ee9", - ".trellis/scripts/common/workflow_phase.py": "2b260f4a7770e9c3223129836716bd8e2c0f0568acd682224a57415bc1dc726b", - ".trellis/scripts/common/__init__.py": "3d5e9347141f0296319a5beb29d69ae714c5a474b9078caeb3edd7c5f6562e22", - ".trellis/scripts/get_context.py": "af3ea7cd563a453227cf2cb4ab04d667390046b7febfac2217348d0892781f4b", - ".trellis/scripts/get_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f", - ".trellis/scripts/hooks/linear_sync.py": "cfc270b7ff775caa5b2434823c45414a3b37f9ba2aa1e293a26daef9fd2e577a", - ".trellis/scripts/init_developer.py": "0943f1c240993649ab89b91a2c5b379e84daa8c53b35f0490774bff05a552873", - ".trellis/scripts/task.py": "e2614fbfc1308c90c0708a11475ca6684ea0a1e2a845140300192229589a2f1f", - ".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c", - ".trellis/workflow.md": "94810f640dcfe6fdaebcf6e9d0d6cec554610192c4f953b288029570ba8bc89d" - } -} \ No newline at end of file diff --git a/.trellis/.version b/.trellis/.version deleted file mode 100644 index aa49d36..0000000 --- a/.trellis/.version +++ /dev/null @@ -1 +0,0 @@ -0.5.19 \ No newline at end of file diff --git a/.trellis/config.yaml b/.trellis/config.yaml deleted file mode 100644 index f1e99eb..0000000 --- a/.trellis/config.yaml +++ /dev/null @@ -1,90 +0,0 @@ -# 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 - -#------------------------------------------------------------------------------- -# Codex (dispatch behavior) -#------------------------------------------------------------------------------- -# Codex-only knob; other platforms ignore it. Default ("inline") makes the -# main Codex agent edit code directly because Codex sub-agents run with -# `fork_turns="none"` isolation and can't inherit the parent session's -# task context. Set to "sub-agent" to opt into the legacy dispatch model -# (main agent spawns trellis-implement / trellis-check / trellis-research -# sub-agents). -# -# codex: -# dispatch_mode: inline # or "sub-agent" to dispatch trellis-* sub-agents diff --git a/.trellis/scripts/__init__.py b/.trellis/scripts/__init__.py deleted file mode 100644 index 815a137..0000000 --- a/.trellis/scripts/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -""" -Trellis Python Scripts - -This module provides Python implementations of Trellis workflow scripts. -""" diff --git a/.trellis/scripts/add_session.py b/.trellis/scripts/add_session.py deleted file mode 100644 index 7149739..0000000 --- a/.trellis/scripts/add_session.py +++ /dev/null @@ -1,547 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Add a new session to journal file and update index.md. - -Usage: - python add_session.py --title "Title" --commit "hash" --summary "Summary" [--package cli] - python add_session.py --title "Title" --branch "feat/my-branch" - - # Pipe detailed content via stdin (use --stdin to opt in): - cat << 'EOF' | python add_session.py --stdin --title "Title" --summary "Summary" - - EOF - -Branch resolution order: - 1. --branch CLI arg (explicit) - 2. task.json branch field (from active task) - 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 ( - 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.safe_commit import ( - print_gitignore_warning, - safe_git_add, - safe_trellis_paths_to_add, -) -from common.tasks import load_task -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 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 generate_session_content( - session_num: int, - title: str, - commit: str, - summary: str, - extra_content: str, - today: str, - package: str | None = None, - branch: 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 "" - - return f""" - -## Session {session_num}: {title} - -**Date**: {today} -**Task**: {title}{package_line}{branch_line} - -### Summary - -{summary} - -### Main Changes - -{extra_content} - -### Git Commits - -{commit_table} - -### Testing - -- [OK] (Add test results) - -### Status - -[OK] **Completed** - -### Next Steps - -- None - task complete -""" - - -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 + task paths and commit. - - Path scope is restricted to specific products (journal files, index.md, - active task dirs, the archive subtree). We never `git add` the whole - `.trellis/` tree, and 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) - paths = safe_trellis_paths_to_add(repo_root) - 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 = "(Add summary)", - extra_content: str = "(Add details)", - auto_commit: bool = True, - package: str | None = None, - branch: str | None = None, -) -> int: - """Add a new session.""" - repo_root = get_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, extra_content, today, package, - branch, - ) - 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="(Add summary)", 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("--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 = "(Add details)" - 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) - - # Resolve branch: CLI → task.json → git auto-detect → None - branch = args.branch - - if not branch: - if task_data and task_data.raw.get("branch"): - branch = task_data.raw["branch"] - else: - _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) - detected = branch_out.strip() - if detected: - branch = detected - - return add_session( - args.title, args.commit, args.summary, extra_content, - auto_commit=not args.no_commit, - package=package, - branch=branch, - ) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.trellis/scripts/common/__init__.py b/.trellis/scripts/common/__init__.py deleted file mode 100644 index 6d72360..0000000 --- a/.trellis/scripts/common/__init__.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -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, -) diff --git a/.trellis/scripts/common/active_task.py b/.trellis/scripts/common/active_task.py deleted file mode 100644 index e6597e8..0000000 --- a/.trellis/scripts/common/active_task.py +++ /dev/null @@ -1,626 +0,0 @@ -#!/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", -} - -_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")), -) -_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", -} - - -@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: - 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, -) -> 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. - """ - 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) - - env_context_key = _lookup_env_context_key(platform_name) - if env_context_key: - return env_context_key - - if 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, -) -> 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 class-2 platform sub-agents (codex, - 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) - 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 - - 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 the current 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) - context_path = _context_path(repo_root, 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 diff --git a/.trellis/scripts/common/cli_adapter.py b/.trellis/scripts/common/cli_adapter.py deleted file mode 100644 index b65f61a..0000000 --- a/.trellis/scripts/common/cli_adapter.py +++ /dev/null @@ -1,811 +0,0 @@ -""" -CLI Adapter for Multi-Platform Support. - -Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, Windsurf, 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) -- windsurf: Windsurf (workflow-based) -- qoder: Qoder -- codebuddy: CodeBuddy -- copilot: GitHub Copilot (VS Code) -- droid: Factory Droid (commands-based) -- pi: Pi Agent (extension-backed) - -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", - "windsurf", - "qoder", - "codebuddy", - "copilot", - "droid", - "pi", -] - - -@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', '.windsurf', '.qoder', '.codebuddy', '.github/copilot', '.factory', or '.pi') - """ - 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 == "windsurf": - return ".windsurf" - 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" - 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, .windsurf, .qoder, .codebuddy, .github/copilot, .factory, or .pi) - """ - 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-.md - Antigravity uses workflow directory: .agent/workflows/.md - Windsurf uses workflow directory: .windsurf/workflows/trellis-.md - Copilot uses prompt files: .github/prompts/.prompt.md - Pi uses prompt templates: .pi/prompts/trellis-.md - Claude/OpenCode use subdirectory: .claude/commands/trellis/.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) - - if self.platform == "windsurf": - 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/.md to trellis-.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-.md - Codex: .agents/skills/trellis-/SKILL.md - Kiro: .kiro/skills/trellis-/SKILL.md - Gemini: .gemini/commands/trellis/.toml - Antigravity: .agent/workflows/.md - Windsurf: .windsurf/workflows/trellis-.md - Pi: .pi/prompts/trellis-.md - Others: .{platform}/commands/trellis/.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 == "windsurf": - return f".windsurf/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" - 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 == "windsurf": - 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 {} - 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 == "windsurf": - raise ValueError( - "Windsurf 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] - - 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 == "windsurf": - raise ValueError( - "Windsurf 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] - 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 == "windsurf": - return "windsurf" - 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" - 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") - - @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', 'windsurf', 'qoder', 'codebuddy', 'copilot', 'droid', or 'pi') - - Returns: - CLIAdapter instance - - Raises: - ValueError: If platform is not supported - """ - if platform not in ( - "claude", - "opencode", - "cursor", - "iflow", - "codex", - "kilo", - "kiro", - "gemini", - "antigravity", - "windsurf", - "qoder", - "codebuddy", - "copilot", - "droid", - "pi", - ): - raise ValueError( - f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'windsurf', 'qoder', 'codebuddy', 'copilot', 'droid', or 'pi')" - ) - - return CLIAdapter(platform=platform) # type: ignore - - -_ALL_PLATFORM_CONFIG_DIRS = ( - ".claude", - ".cursor", - ".iflow", - ".opencode", - ".codex", - ".kilocode", - ".kiro", - ".gemini", - ".agent", - ".windsurf", - ".qoder", - ".codebuddy", - ".github/copilot", - ".factory", - ".pi", -) -"""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, Windsurf, 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. .codex exists and no other platform dirs → codex - 6. .kilocode directory exists → kilo - 7. .kiro/skills exists and no other platform dirs → kiro - 8. .gemini directory exists → gemini - 9. .agent/workflows exists and no other platform dirs → antigravity - 10. .windsurf/workflows exists and no other platform dirs → windsurf - 11. .codebuddy directory exists → codebuddy - 12. .qoder directory exists → qoder - 13. .pi directory exists → pi - 14. Default → claude - - Args: - project_root: Project root directory - - Returns: - Detected platform ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'windsurf', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', or default 'claude') - """ - import os - - # Check environment variable first - env_platform = os.environ.get("TRELLIS_PLATFORM", "").lower() - if env_platform in ( - "claude", - "opencode", - "cursor", - "iflow", - "codex", - "kilo", - "kiro", - "gemini", - "antigravity", - "windsurf", - "qoder", - "codebuddy", - "copilot", - "droid", - "pi", - ): - 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 Windsurf workflow directory only when no other platform config exists - if ( - project_root / ".windsurf" / "workflows" - ).is_dir() and not _has_other_platform_dir( - project_root, {".windsurf"} - ): - return "windsurf" - - # 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" - - # 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) diff --git a/.trellis/scripts/common/config.py b/.trellis/scripts/common/config.py deleted file mode 100644 index 93df643..0000000 --- a/.trellis/scripts/common/config.py +++ /dev/null @@ -1,445 +0,0 @@ -#!/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() - value = _unquote(value) - current_list = None - - if value: - # key: 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 - -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_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/" - 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 diff --git a/.trellis/scripts/common/developer.py b/.trellis/scripts/common/developer.py deleted file mode 100644 index f422778..0000000 --- a/.trellis/scripts/common/developer.py +++ /dev/null @@ -1,190 +0,0 @@ -#!/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// 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 - - -- **Active File**: `journal-1.md` -- **Total Sessions**: 0 -- **Last Active**: - - - ---- - -## Active Documents - - -| File | Lines | Status | -|------|-------|--------| -| `journal-1.md` | ~0 | Active | - - ---- - -## Session History - - -| # | Date | Title | Commits | Branch | -|---|------|-------|---------|--------| - - ---- - -## 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: python ./{DIR_WORKFLOW}/scripts/init_developer.py ", 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() diff --git a/.trellis/scripts/common/git.py b/.trellis/scripts/common/git.py deleted file mode 100644 index c4bf29f..0000000 --- a/.trellis/scripts/common/git.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -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) -> 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). - """ - 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", - ) - return result.returncode, result.stdout, result.stderr - except Exception as e: - return 1, "", str(e) diff --git a/.trellis/scripts/common/git_context.py b/.trellis/scripts/common/git_context.py deleted file mode 100644 index 23fc6ec..0000000 --- a/.trellis/scripts/common/git_context.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/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() diff --git a/.trellis/scripts/common/io.py b/.trellis/scripts/common/io.py deleted file mode 100644 index 44288f4..0000000 --- a/.trellis/scripts/common/io.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -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 -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. - - Returns True on success, False on error. - """ - try: - path.write_text( - json.dumps(data, indent=2, ensure_ascii=False), - encoding="utf-8", - ) - return True - except (OSError, IOError): - return False diff --git a/.trellis/scripts/common/log.py b/.trellis/scripts/common/log.py deleted file mode 100644 index 839c643..0000000 --- a/.trellis/scripts/common/log.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -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}") diff --git a/.trellis/scripts/common/packages_context.py b/.trellis/scripts/common/packages_context.py deleted file mode 100644 index e7d4e8c..0000000 --- a/.trellis/scripts/common/packages_context.py +++ /dev/null @@ -1,238 +0,0 @@ -#!/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// - 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, - } diff --git a/.trellis/scripts/common/paths.py b/.trellis/scripts/common/paths.py deleted file mode 100644 index 1c5a58e..0000000 --- a/.trellis/scripts/common/paths.py +++ /dev/null @@ -1,447 +0,0 @@ -#!/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/ - - 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)}") diff --git a/.trellis/scripts/common/safe_commit.py b/.trellis/scripts/common/safe_commit.py deleted file mode 100644 index 4174191..0000000 --- a/.trellis/scripts/common/safe_commit.py +++ /dev/null @@ -1,285 +0,0 @@ -""" -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 ` 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) -> 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//journal-*.md - - .trellis/workspace//index.md - - .trellis/tasks// (every active task directory) - - .trellis/tasks/archive/ (whole archive subtree, if present) - - Excluded (intentionally — these must not be staged): - - .trellis/.backup-*, .trellis/worktrees/, - .trellis/.template-hashes.json, .trellis/.runtime/, .trellis/.cache/ - """ - 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" - ) - - # Active tasks: each direct child of tasks/ that is a directory and not - # the archive root. The archive subtree is added as a single path below. - tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS - if tasks_dir.is_dir(): - 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 -- `` 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//{journal-*.md,index.md}", - file=sys.stderr, - ) - print( - "[WARN] .trellis/tasks//", - 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, - ) diff --git a/.trellis/scripts/common/session_context.py b/.trellis/scripts/common/session_context.py deleted file mode 100644 index 5039f68..0000000 --- a/.trellis/scripts/common/session_context.py +++ /dev/null @@ -1,821 +0,0 @@ -#!/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 -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 - - -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) - 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 - - _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_dir) - branch = branch_out.strip() or "unknown" - - _, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_dir) - changes = len([l for l in status_out.splitlines() if l.strip()]) - - _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_dir) - - 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) - branch = branch_out.strip() or "unknown" - - _, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root) - status_lines = [line for line in status_out.splitlines() if line.strip()] - - _, short_out, _ = run_git(["status", "--short"], cwd=repo_root) - - _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root) - - return { - "isRepo": True, - "branch": branch, - "isClean": 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] = [] - - 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: - 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(): - found.append(child_rel.as_posix()) - continue - scan(child_rel, depth + 1) - - scan(Path("."), 0) - 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\S+)\s*(?:→|->)\s*(?P\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}, " - f"run npm install -g {_PACKAGE_NAME}@latest" - ) - - -# ============================================================================= -# 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: python ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py " - ) - 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: python ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py " - ) - 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)) diff --git a/.trellis/scripts/common/task_context.py b/.trellis/scripts/common/task_context.py deleted file mode 100644 index fa88412..0000000 --- a/.trellis/scripts/common/task_context.py +++ /dev/null @@ -1,223 +0,0 @@ -#!/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 Phase 1.3 of - the workflow. See ``.trellis/workflow.md`` Phase 1.3 for the current - instructions. -""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path - -from .log import Colors, colored -from .paths import get_repo_root -from .task_utils import resolve_task_dir - - -# ============================================================================= -# 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() - - total_errors = 0 - for jsonl_name in ["implement.jsonl", "check.jsonl"]: - jsonl_file = target_dir / jsonl_name - errors = _validate_jsonl(jsonl_file, repo_root) - 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 _validate_jsonl(jsonl_file: Path, repo_root: Path) -> int: - """Validate a single JSONL file. - - Seed rows (no ``file`` field — typically ``{"_example": "..."}``) are - skipped silently; they are self-describing comments, not real entries. - """ - 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 - - 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 - else: - if not full_path.is_file(): - print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}") - errors += 1 - - 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 diff --git a/.trellis/scripts/common/task_queue.py b/.trellis/scripts/common/task_queue.py deleted file mode 100644 index f7485e2..0000000 --- a/.trellis/scripts/common/task_queue.py +++ /dev/null @@ -1,188 +0,0 @@ -#!/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']}") diff --git a/.trellis/scripts/common/task_store.py b/.trellis/scripts/common/task_store.py deleted file mode 100644 index 71d1bf1..0000000 --- a/.trellis/scripts/common/task_store.py +++ /dev/null @@ -1,697 +0,0 @@ -#!/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_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_packages, - get_session_auto_commit, - is_monorepo, - resolve_package, - validate_package, -) -from .git import 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, - 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 -# (Class-1 hook-inject + Class-2 pull-based preludes). Kilo / Antigravity / -# Windsurf are NOT in this list: they do not consume JSONL. -_SUBAGENT_CONFIG_DIRS: tuple[str, ...] = ( - ".claude", - ".cursor", - ".codex", - ".kiro", - ".gemini", - ".opencode", - ".qoder", - ".codebuddy", - ".factory", # Factory Droid - ".github/copilot", - ".pi", # Pi Agent -) - -_SEED_EXAMPLE = ( - "Fill with {\"file\": \"\", \"reason\": \"\"}. " - "Put spec/research files only — no code paths. " - "Run `python .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. Used - only to decide whether ``task.py create`` should seed empty - ``implement.jsonl`` / ``check.jsonl`` files. - """ - for config_dir in _SUBAGENT_CONFIG_DIRS: - if (repo_root / config_dir).is_dir(): - return True - 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") - - -# ============================================================================= -# 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 --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() - 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 current branch as base_branch (PR target) - _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) - current_branch = branch_out.strip() or "main" - - task_data = { - "id": slug, - "name": slug, - "title": args.title, - "description": args.description or "", - "status": "planning", - "dev_type": None, - "scope": None, - "package": package, - "priority": args.priority, - "creator": creator, - "assignee": assignee, - "createdAt": today, - "completedAt": None, - "branch": None, - "base_branch": current_branch, - "worktree_path": None, - "commit": None, - "pr_url": None, - "subtasks": [], - "children": [], - "parent": None, - "relatedFiles": [], - "notes": "", - "meta": {}, - } - - write_json(task_json_path, task_data) - - # Seed implement.jsonl / check.jsonl for sub-agent-capable platforms. - # Agent curates real entries in Phase 1.3 (see .trellis/workflow.md). - # Agent-less platforms (Kilo / Antigravity / Windsurf) 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. - try: - from .active_task import resolve_context_key, set_active_task - if resolve_context_key(): - try: - rel_dir = task_dir.relative_to(repo_root).as_posix() - except ValueError: - rel_dir = str(task_dir) - set_active_task(rel_dir, repo_root) - except Exception: - pass - - 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(" 1. Create prd.md with requirements", file=sys.stderr) - if seeded_jsonl: - print( - " 2. Curate implement.jsonl / check.jsonl (spec + research files only — " - "see .trellis/workflow.md Phase 1.3)", - file=sys.stderr, - ) - print(" 3. Run: python task.py start ", file=sys.stderr) - else: - print(" 2. Run: python task.py start ", 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 - - 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: - 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): - _auto_commit_archive(dir_name, repo_root, modified_children) - - # 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, -) -> None: - """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 - - 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 - - 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 - - # 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). - source_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}" - 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 - - 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) - else: - print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr) - - -# ============================================================================= -# 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: python task.py set-branch ") - 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: python task.py set-base-branch ") - print("Example: python task.py set-base-branch 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: python task.py set-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 diff --git a/.trellis/scripts/common/task_utils.py b/.trellis/scripts/common/task_utils.py deleted file mode 100644 index 62c215e..0000000 --- a/.trellis/scripts/common/task_utils.py +++ /dev/null @@ -1,274 +0,0 @@ -#!/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 - - -# ============================================================================= -# 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)}") diff --git a/.trellis/scripts/common/tasks.py b/.trellis/scripts/common/tasks.py deleted file mode 100644 index 7b44094..0000000 --- a/.trellis/scripts/common/tasks.py +++ /dev/null @@ -1,112 +0,0 @@ -""" -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]" diff --git a/.trellis/scripts/common/trellis_config.py b/.trellis/scripts/common/trellis_config.py deleted file mode 100644 index 5dbec7a..0000000 --- a/.trellis/scripts/common/trellis_config.py +++ /dev/null @@ -1,131 +0,0 @@ -#!/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() - value = _unquote(value) - current_list = None - - if value: - 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 {} diff --git a/.trellis/scripts/common/types.py b/.trellis/scripts/common/types.py deleted file mode 100644 index 5802e10..0000000 --- a/.trellis/scripts/common/types.py +++ /dev/null @@ -1,110 +0,0 @@ -""" -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 diff --git a/.trellis/scripts/common/workflow_phase.py b/.trellis/scripts/common/workflow_phase.py deleted file mode 100644 index 2b4acd0..0000000 --- a/.trellis/scripts/common/workflow_phase.py +++ /dev/null @@ -1,215 +0,0 @@ -#!/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 Phase Index + Phase 1/2/3 step bodies from workflow.md. - - Matches what the SessionStart hook injects into the `` block: - starts at `## Phase Index`, continues through `## Phase 1: Plan`, - `## Phase 2: Execute`, `## Phase 3: Finish`, stops at - `## Customizing Trellis (for forks)` (the docs-for-forks footer). - `[workflow-state:STATUS]` tag blocks (now embedded in Phase Index since - v0.5.0-rc.0) are consumed by the UserPromptSubmit 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 == "## Customizing Trellis (for forks)": - 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-inline"`` (default) - or ``"codex-sub-agent"`` based on ``.trellis/config.yaml`` ``codex.dispatch_mode``. - ``filter_platform`` then surfaces blocks whose marker lists include the - namespaced name (e.g. ``[codex-sub-agent, ...]`` or ``[codex-inline, Kilo, - Antigravity, Windsurf]``). - - Default is ``inline`` because Codex sub-agents run with ``fork_turns="none"`` - isolation and can't inherit the parent session's task context — inline - keeps the main agent in charge so context isn't lost. Invalid / missing - values also fall back to inline. - - Other platforms are returned unchanged. - """ - if platform == "codex": - mode = "inline" - codex_cfg = config.get("codex") if isinstance(config, dict) else None - if isinstance(codex_cfg, dict): - cfg_mode = codex_cfg.get("dispatch_mode") - if cfg_mode in ("inline", "sub-agent"): - mode = cfg_mode - return f"codex-{mode}" - 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" diff --git a/.trellis/scripts/get_context.py b/.trellis/scripts/get_context.py deleted file mode 100644 index 0bde5bf..0000000 --- a/.trellis/scripts/get_context.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python3 -""" -Get Session Context for AI Agent. - -Usage: - python get_context.py Output context in text format - python get_context.py --json Output context in JSON format -""" - -from __future__ import annotations - -from common.git_context import main - - -if __name__ == "__main__": - main() diff --git a/.trellis/scripts/get_developer.py b/.trellis/scripts/get_developer.py deleted file mode 100644 index f8a89eb..0000000 --- a/.trellis/scripts/get_developer.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/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() diff --git a/.trellis/scripts/hooks/linear_sync.py b/.trellis/scripts/hooks/linear_sync.py deleted file mode 100644 index 1fdce68..0000000 --- a/.trellis/scripts/hooks/linear_sync.py +++ /dev/null @@ -1,243 +0,0 @@ -#!/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): - python .trellis/scripts/hooks/linear_sync.py create - python .trellis/scripts/hooks/linear_sync.py start - python .trellis/scripts/hooks/linear_sync.py archive - -Manual usage: - TASK_JSON_PATH=.trellis/tasks//task.json python .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) diff --git a/.trellis/scripts/init_developer.py b/.trellis/scripts/init_developer.py deleted file mode 100644 index 557b289..0000000 --- a/.trellis/scripts/init_developer.py +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env python3 -""" -Initialize developer for workflow. - -Usage: - python init_developer.py - -This creates: - - .trellis/.developer file with developer info - - .trellis/workspace// 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]} ") - 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() diff --git a/.trellis/scripts/task.py b/.trellis/scripts/task.py deleted file mode 100644 index 81e4da8..0000000 --- a/.trellis/scripts/task.py +++ /dev/null @@ -1,500 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Task Management Script. - -Usage: - python task.py create "" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] [--package <pkg>] - python task.py add-context <dir> <file> <path> [reason] # Add jsonl entry - python task.py validate <dir> # Validate jsonl files - python task.py list-context <dir> # List jsonl entries - python task.py start <dir> # Set active task - python task.py current [--source] # Show active task - python task.py finish # Clear active task - python task.py set-branch <dir> <branch> # Set git branch - python task.py set-base-branch <dir> <branch> # Set PR target branch - python task.py set-scope <dir> <scope> # Set scope for PR title - python task.py archive <task-dir> # Archive completed task - python task.py list # List active tasks - python task.py list-archive [month] # List archived tasks - python task.py add-subtask <parent-dir> <child-dir> # Link child to parent - python task.py remove-subtask <parent-dir> <child-dir> # Unlink child from parent -""" - -from __future__ import annotations - -import argparse -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_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 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 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 - - 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() - - # 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()} - - # 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) - - # Package tag - pkg_tag = f" @{t.package}" if t.package else "" - - prefix = " " * indent + " - " - - if filter_mine: - print(f"{prefix}{dir_name}/ ({t.status}){pkg_tag}{progress}{marker}") - else: - print(f"{prefix}{dir_name}/ ({t.status}){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) - for dir_name in sorted(all_tasks.keys()): - if not all_tasks[dir_name].parent: - _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: - python task.py create <title> Create new task directory - python task.py create <title> --package <pkg> Create task for a specific package - python task.py create <title> --parent <dir> Create task as child of parent - python task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl - python task.py validate <dir> Validate jsonl files - python task.py list-context <dir> List jsonl entries - python task.py start <dir> Set active task - python task.py current [--source] Show active task - python task.py finish Clear active task - python task.py set-branch <dir> <branch> Set git branch - python task.py set-base-branch <dir> <branch> Set PR target branch - python task.py set-scope <dir> <scope> Set scope for PR title - python task.py archive <task-dir> Archive completed task - python task.py add-subtask <parent> <child> Link child task to parent - python task.py remove-subtask <parent> <child> Unlink child from parent - python task.py list [--mine] [--status <status>] List tasks - python 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) - -Examples: - python task.py create "Add login feature" --slug add-login - python task.py create "Add login feature" --slug add-login --package cli - python task.py create "Child task" --slug child --parent .trellis/tasks/01-21-parent - python task.py add-context <dir> implement .trellis/spec/cli/backend/auth.md "Auth guidelines" - python task.py set-branch <dir> task/add-login - python task.py start .trellis/tasks/01-21-add-login - python task.py current --source - python task.py finish - python task.py archive add-login - python task.py add-subtask parent-task child-task # Link existing tasks - python task.py remove-subtask parent-task child-task - python task.py list # List all active tasks - python task.py list --mine # List my tasks only - python 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 Phase 1.3.", - file=sys.stderr, - ) - print("See .trellis/workflow.md Phase 1.3 or run:", file=sys.stderr) - print( - " python ./.trellis/scripts/get_context.py --mode phase --step 1.3", - 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") - 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") - - # 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") - - # 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") - - # 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") - - # 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, - "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()) diff --git a/.trellis/spec/backend/database-guidelines.md b/.trellis/spec/backend/database-guidelines.md deleted file mode 100644 index b61aa78..0000000 --- a/.trellis/spec/backend/database-guidelines.md +++ /dev/null @@ -1,51 +0,0 @@ -# Database Guidelines - -> Database patterns and conventions for this project. - ---- - -## Overview - -<!-- -Document your project's database conventions here. - -Questions to answer: -- What ORM/query library do you use? -- How are migrations managed? -- What are the naming conventions for tables/columns? -- How do you handle transactions? ---> - -(To be filled by the team) - ---- - -## Query Patterns - -<!-- How should queries be written? Batch operations? --> - -(To be filled by the team) - ---- - -## Migrations - -<!-- How to create and run migrations --> - -(To be filled by the team) - ---- - -## Naming Conventions - -<!-- Table names, column names, index names --> - -(To be filled by the team) - ---- - -## Common Mistakes - -<!-- Database-related mistakes your team has made --> - -(To be filled by the team) diff --git a/.trellis/spec/backend/directory-structure.md b/.trellis/spec/backend/directory-structure.md deleted file mode 100644 index 9bb253d..0000000 --- a/.trellis/spec/backend/directory-structure.md +++ /dev/null @@ -1,54 +0,0 @@ -# Directory Structure - -> How backend code is organized in this project. - ---- - -## Overview - -<!-- -Document your project's backend directory structure here. - -Questions to answer: -- How are modules/packages organized? -- Where does business logic live? -- Where are API endpoints defined? -- How are utilities and helpers organized? ---> - -(To be filled by the team) - ---- - -## Directory Layout - -``` -<!-- Replace with your actual structure --> -src/ -├── ... -└── ... -``` - ---- - -## Module Organization - -<!-- How should new features/modules be organized? --> - -(To be filled by the team) - ---- - -## Naming Conventions - -<!-- File and folder naming rules --> - -(To be filled by the team) - ---- - -## Examples - -<!-- Link to well-organized modules as examples --> - -(To be filled by the team) diff --git a/.trellis/spec/backend/error-handling.md b/.trellis/spec/backend/error-handling.md deleted file mode 100644 index bcd5533..0000000 --- a/.trellis/spec/backend/error-handling.md +++ /dev/null @@ -1,51 +0,0 @@ -# Error Handling - -> How errors are handled in this project. - ---- - -## Overview - -<!-- -Document your project's error handling conventions here. - -Questions to answer: -- What error types do you define? -- How are errors propagated? -- How are errors logged? -- How are errors returned to clients? ---> - -(To be filled by the team) - ---- - -## Error Types - -<!-- Custom error classes/types --> - -(To be filled by the team) - ---- - -## Error Handling Patterns - -<!-- Try-catch patterns, error propagation --> - -(To be filled by the team) - ---- - -## API Error Responses - -<!-- Standard error response format --> - -(To be filled by the team) - ---- - -## Common Mistakes - -<!-- Error handling mistakes your team has made --> - -(To be filled by the team) diff --git a/.trellis/spec/backend/index.md b/.trellis/spec/backend/index.md deleted file mode 100644 index 1c0b4c4..0000000 --- a/.trellis/spec/backend/index.md +++ /dev/null @@ -1,38 +0,0 @@ -# Backend Development Guidelines - -> Best practices for backend development in this project. - ---- - -## Overview - -This directory contains guidelines for backend development. Fill in each file with your project's specific conventions. - ---- - -## Guidelines Index - -| Guide | Description | Status | -|-------|-------------|--------| -| [Directory Structure](./directory-structure.md) | Module organization and file layout | To fill | -| [Database Guidelines](./database-guidelines.md) | ORM patterns, queries, migrations | To fill | -| [Error Handling](./error-handling.md) | Error types, handling strategies | To fill | -| [Quality Guidelines](./quality-guidelines.md) | Code standards, forbidden patterns | To fill | -| [Logging Guidelines](./logging-guidelines.md) | Structured logging, log levels | To fill | - ---- - -## How to Fill These Guidelines - -For each guideline file: - -1. Document your project's **actual conventions** (not ideals) -2. Include **code examples** from your codebase -3. List **forbidden patterns** and why -4. Add **common mistakes** your team has made - -The goal is to help AI assistants and new team members understand how YOUR project works. - ---- - -**Language**: All documentation should be written in **English**. diff --git a/.trellis/spec/backend/logging-guidelines.md b/.trellis/spec/backend/logging-guidelines.md deleted file mode 100644 index bb930df..0000000 --- a/.trellis/spec/backend/logging-guidelines.md +++ /dev/null @@ -1,51 +0,0 @@ -# Logging Guidelines - -> How logging is done in this project. - ---- - -## Overview - -<!-- -Document your project's logging conventions here. - -Questions to answer: -- What logging library do you use? -- What are the log levels and when to use each? -- What should be logged? -- What should NOT be logged (PII, secrets)? ---> - -(To be filled by the team) - ---- - -## Log Levels - -<!-- When to use each level: debug, info, warn, error --> - -(To be filled by the team) - ---- - -## Structured Logging - -<!-- Log format, required fields --> - -(To be filled by the team) - ---- - -## What to Log - -<!-- Important events to log --> - -(To be filled by the team) - ---- - -## What NOT to Log - -<!-- Sensitive data, PII, secrets --> - -(To be filled by the team) diff --git a/.trellis/spec/backend/quality-guidelines.md b/.trellis/spec/backend/quality-guidelines.md deleted file mode 100644 index c1e1065..0000000 --- a/.trellis/spec/backend/quality-guidelines.md +++ /dev/null @@ -1,51 +0,0 @@ -# Quality Guidelines - -> Code quality standards for backend development. - ---- - -## Overview - -<!-- -Document your project's quality standards here. - -Questions to answer: -- What patterns are forbidden? -- What linting rules do you enforce? -- What are your testing requirements? -- What code review standards apply? ---> - -(To be filled by the team) - ---- - -## Forbidden Patterns - -<!-- Patterns that should never be used and why --> - -(To be filled by the team) - ---- - -## Required Patterns - -<!-- Patterns that must always be used --> - -(To be filled by the team) - ---- - -## Testing Requirements - -<!-- What level of testing is expected --> - -(To be filled by the team) - ---- - -## Code Review Checklist - -<!-- What reviewers should check --> - -(To be filled by the team) diff --git a/.trellis/spec/frontend/component-guidelines.md b/.trellis/spec/frontend/component-guidelines.md deleted file mode 100644 index 6836c3f..0000000 --- a/.trellis/spec/frontend/component-guidelines.md +++ /dev/null @@ -1,59 +0,0 @@ -# Component Guidelines - -> How components are built in this project. - ---- - -## Overview - -<!-- -Document your project's component conventions here. - -Questions to answer: -- What component patterns do you use? -- How are props defined? -- How do you handle composition? -- What accessibility standards apply? ---> - -(To be filled by the team) - ---- - -## Component Structure - -<!-- Standard structure of a component file --> - -(To be filled by the team) - ---- - -## Props Conventions - -<!-- How props should be defined and typed --> - -(To be filled by the team) - ---- - -## Styling Patterns - -<!-- How styles are applied (CSS modules, styled-components, Tailwind, etc.) --> - -(To be filled by the team) - ---- - -## Accessibility - -<!-- A11y requirements and patterns --> - -(To be filled by the team) - ---- - -## Common Mistakes - -<!-- Component-related mistakes your team has made --> - -(To be filled by the team) diff --git a/.trellis/spec/frontend/directory-structure.md b/.trellis/spec/frontend/directory-structure.md deleted file mode 100644 index 1eb57d1..0000000 --- a/.trellis/spec/frontend/directory-structure.md +++ /dev/null @@ -1,54 +0,0 @@ -# Directory Structure - -> How frontend code is organized in this project. - ---- - -## Overview - -<!-- -Document your project's frontend directory structure here. - -Questions to answer: -- Where do components live? -- How are features/modules organized? -- Where are shared utilities? -- How are assets organized? ---> - -(To be filled by the team) - ---- - -## Directory Layout - -``` -<!-- Replace with your actual structure --> -src/ -├── ... -└── ... -``` - ---- - -## Module Organization - -<!-- How should new features be organized? --> - -(To be filled by the team) - ---- - -## Naming Conventions - -<!-- File and folder naming rules --> - -(To be filled by the team) - ---- - -## Examples - -<!-- Link to well-organized modules as examples --> - -(To be filled by the team) diff --git a/.trellis/spec/frontend/hook-guidelines.md b/.trellis/spec/frontend/hook-guidelines.md deleted file mode 100644 index 60c6bb6..0000000 --- a/.trellis/spec/frontend/hook-guidelines.md +++ /dev/null @@ -1,51 +0,0 @@ -# Hook Guidelines - -> How hooks are used in this project. - ---- - -## Overview - -<!-- -Document your project's hook conventions here. - -Questions to answer: -- What custom hooks do you have? -- How do you handle data fetching? -- What are the naming conventions? -- How do you share stateful logic? ---> - -(To be filled by the team) - ---- - -## Custom Hook Patterns - -<!-- How to create and structure custom hooks --> - -(To be filled by the team) - ---- - -## Data Fetching - -<!-- How data fetching is handled (React Query, SWR, etc.) --> - -(To be filled by the team) - ---- - -## Naming Conventions - -<!-- Hook naming rules (use*, etc.) --> - -(To be filled by the team) - ---- - -## Common Mistakes - -<!-- Hook-related mistakes your team has made --> - -(To be filled by the team) diff --git a/.trellis/spec/frontend/index.md b/.trellis/spec/frontend/index.md deleted file mode 100644 index 7a870e8..0000000 --- a/.trellis/spec/frontend/index.md +++ /dev/null @@ -1,39 +0,0 @@ -# Frontend Development Guidelines - -> Best practices for frontend development in this project. - ---- - -## Overview - -This directory contains guidelines for frontend development. Fill in each file with your project's specific conventions. - ---- - -## Guidelines Index - -| Guide | Description | Status | -|-------|-------------|--------| -| [Directory Structure](./directory-structure.md) | Module organization and file layout | To fill | -| [Component Guidelines](./component-guidelines.md) | Component patterns, props, composition | To fill | -| [Hook Guidelines](./hook-guidelines.md) | Custom hooks, data fetching patterns | To fill | -| [State Management](./state-management.md) | Local state, global state, server state | Partial | -| [Quality Guidelines](./quality-guidelines.md) | Code standards, forbidden patterns | To fill | -| [Type Safety](./type-safety.md) | Type patterns, validation | To fill | - ---- - -## How to Fill These Guidelines - -For each guideline file: - -1. Document your project's **actual conventions** (not ideals) -2. Include **code examples** from your codebase -3. List **forbidden patterns** and why -4. Add **common mistakes** your team has made - -The goal is to help AI assistants and new team members understand how YOUR project works. - ---- - -**Language**: All documentation should be written in **English**. diff --git a/.trellis/spec/frontend/quality-guidelines.md b/.trellis/spec/frontend/quality-guidelines.md deleted file mode 100644 index 05a1411..0000000 --- a/.trellis/spec/frontend/quality-guidelines.md +++ /dev/null @@ -1,51 +0,0 @@ -# Quality Guidelines - -> Code quality standards for frontend development. - ---- - -## Overview - -<!-- -Document your project's quality standards here. - -Questions to answer: -- What patterns are forbidden? -- What linting rules do you enforce? -- What are your testing requirements? -- What code review standards apply? ---> - -(To be filled by the team) - ---- - -## Forbidden Patterns - -<!-- Patterns that should never be used and why --> - -(To be filled by the team) - ---- - -## Required Patterns - -<!-- Patterns that must always be used --> - -(To be filled by the team) - ---- - -## Testing Requirements - -<!-- What level of testing is expected --> - -(To be filled by the team) - ---- - -## Code Review Checklist - -<!-- What reviewers should check --> - -(To be filled by the team) diff --git a/.trellis/spec/frontend/state-management.md b/.trellis/spec/frontend/state-management.md deleted file mode 100644 index d2583bd..0000000 --- a/.trellis/spec/frontend/state-management.md +++ /dev/null @@ -1,87 +0,0 @@ -# State Management - -> How state is managed in this project. - ---- - -## Overview - -This WPF application uses view models as both UI state containers and command -workflow coordinators. Some view model methods are reused by interactive UI -paths and startup/headless capability checks, so state that triggers UI prompts -or file writes must be explicitly controlled by the caller. - ---- - -## State Categories - -### View Model Workflow Flags - -Use explicit boolean workflow flags when the same view model method can run in -different interaction contexts. - -Example: - -```csharp -var viewModel = new MainWindowViewModel -{ - ShouldCheckSortFileOnLoad = false -}; -``` - -`MainWindowViewModel.LoadToolsFromDrillTape` can parse a drill tape for the main -window, startup menu capability checks, and headless startup actions. Only the -main window adjustment workflow should allow load-time sort seed prompts. - ---- - -## When to Use Global State - -<!-- Criteria for promoting state to global --> - -(To be filled by the team) - ---- - -## Server State - -<!-- How server data is cached and synchronized --> - -(To be filled by the team) - ---- - -## Common Mistakes - -### Triggering Interactive Side Effects During Capability Checks - -Capability checks such as startup menu button visibility should not trigger -interactive prompts, file saves, Explorer windows, or reorder operations. - -Wrong: - -```csharp -var viewModel = new MainWindowViewModel -{ - IsStartupDrillTapeFile = true, - OriginalFilePath = filePath -}; - -viewModel.LoadToolsFromDrillTape(content); -``` - -Correct: - -```csharp -var viewModel = new MainWindowViewModel -{ - IsStartupDrillTapeFile = true, - OriginalFilePath = filePath, - ShouldCheckSortFileOnLoad = false -}; - -viewModel.LoadToolsFromDrillTape(content); -``` - -Before reusing a view model method in a startup, preview, export, or background -path, check whether it reads state that can show dialogs or mutate files. diff --git a/.trellis/spec/frontend/type-safety.md b/.trellis/spec/frontend/type-safety.md deleted file mode 100644 index 1b1b19e..0000000 --- a/.trellis/spec/frontend/type-safety.md +++ /dev/null @@ -1,51 +0,0 @@ -# Type Safety - -> Type safety patterns in this project. - ---- - -## Overview - -<!-- -Document your project's type safety conventions here. - -Questions to answer: -- What type system do you use? -- How are types organized? -- What validation library do you use? -- How do you handle type inference? ---> - -(To be filled by the team) - ---- - -## Type Organization - -<!-- Where types are defined, shared types vs local types --> - -(To be filled by the team) - ---- - -## Validation - -<!-- Runtime validation patterns (Zod, Yup, io-ts, etc.) --> - -(To be filled by the team) - ---- - -## Common Patterns - -<!-- Type utilities, generics, type guards --> - -(To be filled by the team) - ---- - -## Forbidden Patterns - -<!-- any, type assertions, etc. --> - -(To be filled by the team) diff --git a/.trellis/spec/guides/code-reuse-thinking-guide.md b/.trellis/spec/guides/code-reuse-thinking-guide.md deleted file mode 100644 index f9d5f99..0000000 --- a/.trellis/spec/guides/code-reuse-thinking-guide.md +++ /dev/null @@ -1,105 +0,0 @@ -# Code Reuse Thinking Guide - -> **Purpose**: Stop and think before creating new code - does it already exist? - ---- - -## The Problem - -**Duplicated code is the #1 source of inconsistency bugs.** - -When you copy-paste or rewrite existing logic: -- Bug fixes don't propagate -- Behavior diverges over time -- Codebase becomes harder to understand - ---- - -## Before Writing New Code - -### Step 1: Search First - -```bash -# Search for similar function names -grep -r "functionName" . - -# Search for similar logic -grep -r "keyword" . -``` - -### Step 2: Ask These Questions - -| Question | If Yes... | -|----------|-----------| -| Does a similar function exist? | Use or extend it | -| Is this pattern used elsewhere? | Follow the existing pattern | -| Could this be a shared utility? | Create it in the right place | -| Am I copying code from another file? | **STOP** - extract to shared | - ---- - -## Common Duplication Patterns - -### Pattern 1: Copy-Paste Functions - -**Bad**: Copying a validation function to another file - -**Good**: Extract to shared utilities, import where needed - -### Pattern 2: Similar Components - -**Bad**: Creating a new component that's 80% similar to existing - -**Good**: Extend existing component with props/variants - -### Pattern 3: Repeated Constants - -**Bad**: Defining the same constant in multiple files - -**Good**: Single source of truth, import everywhere - ---- - -## When to Abstract - -**Abstract when**: -- Same code appears 3+ times -- Logic is complex enough to have bugs -- Multiple people might need this - -**Don't abstract when**: -- Only used once -- Trivial one-liner -- Abstraction would be more complex than duplication - ---- - -## After Batch Modifications - -When you've made similar changes to multiple files: - -1. **Review**: Did you catch all instances? -2. **Search**: Run grep to find any missed -3. **Consider**: Should this be abstracted? - ---- - -## Gotcha: Asymmetric Mechanisms Producing Same Output - -**Problem**: When two different mechanisms must produce the same file set (e.g., recursive directory copy for init vs. manual `files.set()` for update), structural changes (renaming, moving, adding subdirectories) only propagate through the automatic mechanism. The manual one silently drifts. - -**Symptom**: Init works perfectly, but update creates files at wrong paths or misses files entirely. - -**Prevention checklist**: -- [ ] When migrating directory structures, search for ALL code paths that reference the old structure -- [ ] If one path is auto-derived (glob/copy) and another is manually listed, the manual one needs updating -- [ ] Add a regression test that compares outputs from both mechanisms - ---- - -## Checklist Before Commit - -- [ ] Searched for existing similar code -- [ ] No copy-pasted logic that should be shared -- [ ] Constants defined in one place -- [ ] Similar patterns follow same structure diff --git a/.trellis/spec/guides/cross-layer-thinking-guide.md b/.trellis/spec/guides/cross-layer-thinking-guide.md deleted file mode 100644 index 0a91f11..0000000 --- a/.trellis/spec/guides/cross-layer-thinking-guide.md +++ /dev/null @@ -1,162 +0,0 @@ -# Cross-Layer Thinking Guide - -> **Purpose**: Think through data flow across layers before implementing. - ---- - -## The Problem - -**Most bugs happen at layer boundaries**, not within layers. - -Common cross-layer bugs: -- API returns format A, frontend expects format B -- Database stores X, service transforms to Y, but loses data -- Multiple layers implement the same logic differently - ---- - -## Before Implementing Cross-Layer Features - -### Step 1: Map the Data Flow - -Draw out how data moves: - -``` -Source → Transform → Store → Retrieve → Transform → Display -``` - -For each arrow, ask: -- What format is the data in? -- What could go wrong? -- Who is responsible for validation? - -### Step 2: Identify Boundaries - -| Boundary | Common Issues | -|----------|---------------| -| API ↔ Service | Type mismatches, missing fields | -| Service ↔ Database | Format conversions, null handling | -| Backend ↔ Frontend | Serialization, date formats | -| Component ↔ Component | Props shape changes | - -### Step 3: Define Contracts - -For each boundary: -- What is the exact input format? -- What is the exact output format? -- What errors can occur? - ---- - -## Common Cross-Layer Mistakes - -### Mistake 1: Implicit Format Assumptions - -**Bad**: Assuming date format without checking - -**Good**: Explicit format conversion at boundaries - -### Mistake 2: Scattered Validation - -**Bad**: Validating the same thing in multiple layers - -**Good**: Validate once at the entry point - -### Mistake 3: Leaky Abstractions - -**Bad**: Component knows about database schema - -**Good**: Each layer only knows its neighbors - ---- - -## Checklist for Cross-Layer Features - -Before implementation: -- [ ] Mapped the complete data flow -- [ ] Identified all layer boundaries -- [ ] Defined format at each boundary -- [ ] Decided where validation happens - -After implementation: -- [ ] Tested with edge cases (null, empty, invalid) -- [ ] Verified error handling at each boundary -- [ ] Checked data survives round-trip - ---- - -## Cross-Platform Template Consistency - -In Trellis, command templates (e.g., `record-session.md`) exist in **multiple platforms** with identical or near-identical content. This is a cross-layer boundary. - -### Checklist: After Modifying Any Command Template - -- [ ] Find all platforms with the same command: `find src/templates/*/commands/trellis/ -name "<command>.*"` -- [ ] Update all platform copies (Markdown `.md` and TOML `.toml`) -- [ ] For Gemini TOML: adapt line continuations (`\\` vs `\`) and triple-quoted strings -- [ ] Run `/trellis:check-cross-layer` to verify nothing was missed - -**Real-world example**: Updated `record-session.md` in Claude to use `--mode record`, but forgot iFlow, Kilo, OpenCode, and Gemini — caught by cross-layer check. - ---- - -## Generated Runtime Template Upgrade Consistency - -Some generated files are both documentation and runtime input. In Trellis, -`.trellis/workflow.md` is parsed by `get_context.py`, `workflow_phase.py`, -SessionStart filters, and per-turn hooks. Template changes must be validated -against both fresh init and upgrade paths. - -### Checklist: After Modifying A Runtime-Parsed Template - -- [ ] Identify every runtime parser that reads the template, not just the file - writer that installs it -- [ ] Check whether relevant syntax lives outside obvious managed regions - such as tag blocks -- [ ] Verify fresh `init` output and a versioned `update` scenario that writes - the older `.trellis/.version` -- [ ] Add an upgrade regression using an older pristine template fixture, then - assert the installed file reaches the current packaged shape -- [ ] Update the backend spec that owns the runtime contract - -**Real-world example**: Codex inline mode changed workflow platform markers from -`[Codex]` / `[Kilo, Antigravity, Windsurf]` to `[codex-sub-agent]` / -`[codex-inline, Kilo, Antigravity, Windsurf]`. Fresh init was correct, but -`trellis update` only merged `[workflow-state:*]` blocks and preserved stale -markers outside those blocks. Result: upgraded projects got new hook scripts -but old workflow routing, so `get_context.py --mode phase --platform codex` -could return empty Phase 2.1 detail. - ---- - -## Mode-Detection Probe Checklist - -When a CLI auto-detects a mode by probing a remote resource (e.g., checking if `index.json` exists to decide marketplace vs direct download): - -### Before implementing: -- [ ] Probe runs in **ALL** code paths that use the result (interactive, `-y`, `--flag` combos) -- [ ] 404 vs transient error are distinguished — don't treat both as "not found" -- [ ] Transient errors **abort or retry**, never silently switch modes -- [ ] Shared state (caches, prefetched data) is **reset** when context changes (e.g., user switches source) -- [ ] **Shortcut paths** (e.g., `--template` skipping picker) must have the same error-handling quality as the probed path — check that downstream functions don't call catch-all wrappers - -### After implementing: -- [ ] Trace every path from probe result to the mode-decision branch — no fallthrough -- [ ] External format contracts (giget URI, raw URLs) are tested or at least documented as comments -- [ ] Metadata reads consume a complete response or use a streaming parser — never parse a fixed-size prefix as full JSON -- [ ] When reconstructing a composite identifier from parsed parts, verify **all** fields are included and in the **correct position** (e.g., `provider:repo/path#ref` not `provider:repo#ref/path`) -- [ ] Verify that **action functions** called after a shortcut don't internally use the old catch-all fetch — they must use the probe-quality variant when error distinction matters - -**Real-world example**: Custom registry flow had 8 bugs across 3 review rounds: (1) probe only ran in interactive mode, (2) transient errors fell through to wrong mode, (3) giget URI had `#ref` in wrong position, (4) prefetched templates leaked across source switches, (5) `--template` shortcut bypassed probe but `downloadTemplateById` internally used catch-all `fetchTemplateIndex`, turning timeouts into "Template not found". - -**Real-world example**: Agent-session update hints fetched npm `latest` metadata with `response.read(4096)` and then parsed it as complete JSON. The `@mindfoldhq/trellis` package metadata exceeded 4 KB, so the JSON was truncated, parse failed silently, and the first session injection showed no update hint. Fix: read the complete response before parsing, and add a regression where `version` is followed by an 8 KB metadata tail. - ---- - -## When to Create Flow Documentation - -Create detailed flow docs when: -- Feature spans 3+ layers -- Multiple teams are involved -- Data format is complex -- Feature has caused bugs before diff --git a/.trellis/spec/guides/index.md b/.trellis/spec/guides/index.md deleted file mode 100644 index 147c79b..0000000 --- a/.trellis/spec/guides/index.md +++ /dev/null @@ -1,79 +0,0 @@ -# Thinking Guides - -> **Purpose**: Expand your thinking to catch things you might not have considered. - ---- - -## Why Thinking Guides? - -**Most bugs and tech debt come from "didn't think of that"**, not from lack of skill: - -- Didn't think about what happens at layer boundaries → cross-layer bugs -- Didn't think about code patterns repeating → duplicated code everywhere -- Didn't think about edge cases → runtime errors -- Didn't think about future maintainers → unreadable code - -These guides help you **ask the right questions before coding**. - ---- - -## Available Guides - -| Guide | Purpose | When to Use | -|-------|---------|-------------| -| [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md) | Identify patterns and reduce duplication | When you notice repeated patterns | -| [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) | Think through data flow across layers | Features spanning multiple layers | - ---- - -## Quick Reference: Thinking Triggers - -### When to Think About Cross-Layer Issues - -- [ ] Feature touches 3+ layers (API, Service, Component, Database) -- [ ] Data format changes between layers -- [ ] Multiple consumers need the same data -- [ ] You're not sure where to put some logic - -→ Read [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) - -### When to Think About Code Reuse - -- [ ] You're writing similar code to something that exists -- [ ] You see the same pattern repeated 3+ times -- [ ] You're adding a new field to multiple places -- [ ] **You're modifying any constant or config** -- [ ] **You're creating a new utility/helper function** ← Search first! - -→ Read [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md) - ---- - -## Pre-Modification Rule (CRITICAL) - -> **Before changing ANY value, ALWAYS search first!** - -```bash -# Search for the value you're about to change -grep -r "value_to_change" . -``` - -This single habit prevents most "forgot to update X" bugs. - ---- - -## How to Use This Directory - -1. **Before coding**: Skim the relevant thinking guide -2. **During coding**: If something feels repetitive or complex, check the guides -3. **After bugs**: Add new insights to the relevant guide (learn from mistakes) - ---- - -## Contributing - -Found a new "didn't think of that" moment? Add it to the relevant guide. - ---- - -**Core Principle**: 30 minutes of thinking saves 3 hours of debugging. diff --git a/.trellis/tasks/00-bootstrap-guidelines/prd.md b/.trellis/tasks/00-bootstrap-guidelines/prd.md deleted file mode 100644 index 02ae501..0000000 --- a/.trellis/tasks/00-bootstrap-guidelines/prd.md +++ /dev/null @@ -1,139 +0,0 @@ -# Bootstrap Task: Fill Project Development Guidelines - -**You (the AI) are running this task. The developer does not read this file.** - -The developer just ran `trellis init` on this project for the first time. -`.trellis/` now exists with empty spec scaffolding, and this bootstrap task -exists under `.trellis/tasks/`. When they want to work on it, they should start -this task from a session that provides Trellis session identity. - -**Your job**: help them populate `.trellis/spec/` with the team's real -coding conventions. Every future AI session — this project's -`trellis-implement` and `trellis-check` sub-agents — auto-loads spec files -listed in per-task jsonl manifests. Empty spec = sub-agents write generic -code. Real spec = sub-agents match the team's actual patterns. - -Don't dump instructions. Open with a short greeting, figure out if the repo -has any existing convention docs (CLAUDE.md, .cursorrules, etc.), and drive -the rest conversationally. - ---- - -## Status (update the checkboxes as you complete each item) - -- [ ] Fill backend guidelines -- [ ] Fill frontend guidelines -- [ ] Add code examples - ---- - -## Spec files to populate - - -### Backend guidelines - -| File | What to document | -|------|------------------| -| `.trellis/spec/backend/directory-structure.md` | Where different file types go (routes, services, utils) | -| `.trellis/spec/backend/database-guidelines.md` | ORM, migrations, query patterns, naming conventions | -| `.trellis/spec/backend/error-handling.md` | How errors are caught, logged, and returned | -| `.trellis/spec/backend/logging-guidelines.md` | Log levels, format, what to log | -| `.trellis/spec/backend/quality-guidelines.md` | Code review standards, testing requirements | - - -### Frontend guidelines - -| File | What to document | -|------|------------------| -| `.trellis/spec/frontend/directory-structure.md` | Component/page/hook organization | -| `.trellis/spec/frontend/component-guidelines.md` | Component patterns, props conventions | -| `.trellis/spec/frontend/hook-guidelines.md` | Custom hook naming, patterns | -| `.trellis/spec/frontend/state-management.md` | State library, patterns, what goes where | -| `.trellis/spec/frontend/type-safety.md` | TypeScript conventions, type organization | -| `.trellis/spec/frontend/quality-guidelines.md` | Linting, testing, accessibility | - - -### Thinking guides (already populated) - -`.trellis/spec/guides/` contains general thinking guides pre-filled with -best practices. Customize only if something clearly doesn't fit this project. - ---- - -## How to fill the spec - -### Step 1: Import from existing convention files first (preferred) - -Search the repo for existing convention docs. If any exist, read them and -extract the relevant rules into the matching `.trellis/spec/` files — -usually much faster than documenting from scratch. - -| File / Directory | Tool | -|------|------| -| `CLAUDE.md` / `CLAUDE.local.md` | Claude Code | -| `AGENTS.md` | Codex / Claude Code / agent-compatible tools | -| `.cursorrules` | Cursor | -| `.cursor/rules/*.mdc` | Cursor (rules directory) | -| `.windsurfrules` | Windsurf | -| `.clinerules` | Cline | -| `.roomodes` | Roo Code | -| `.github/copilot-instructions.md` | GitHub Copilot | -| `.vscode/settings.json` → `github.copilot.chat.codeGeneration.instructions` | VS Code Copilot | -| `CONVENTIONS.md` / `.aider.conf.yml` | aider | -| `CONTRIBUTING.md` | General project conventions | -| `.editorconfig` | Editor formatting rules | - -### Step 2: Analyze the codebase for anything not covered by existing docs - -Scan real code to discover patterns. Before writing each spec file: -- Find 2-3 real examples of each pattern in the codebase. -- Reference real file paths (not hypothetical ones). -- Document anti-patterns the team clearly avoids. - -### Step 3: Document reality, not ideals - -**Critical**: write what the code *actually does*, not what it should do. -Sub-agents match the spec, so aspirational patterns that don't exist in the -codebase will cause sub-agents to write code that looks out of place. - -If the team has known tech debt, document the current state — improvement -is a separate conversation, not a bootstrap concern. - ---- - -## Quick explainer of the runtime (share when they ask "why do we need spec at all") - -- Every AI coding task spawns two sub-agents: `trellis-implement` (writes - code) and `trellis-check` (verifies quality). -- Each task has `implement.jsonl` / `check.jsonl` manifests listing which - spec files to load. -- The platform hook auto-injects those spec files + the task's `prd.md` - into every sub-agent prompt, so the sub-agent codes/reviews per team - conventions without anyone pasting them manually. -- Source of truth: `.trellis/spec/`. That's why filling it well now pays - off forever. - ---- - -## Completion - -When the developer confirms the checklist items above are done with real -examples (not placeholders), guide them to run: - -```bash -python ./.trellis/scripts/task.py finish -python ./.trellis/scripts/task.py archive 00-bootstrap-guidelines -``` - -After archive, every new developer who joins this project will get a -`00-join-<slug>` onboarding task instead of this bootstrap task. - ---- - -## Suggested opening line - -"Welcome to Trellis! Your init just set me up to help you fill the project -spec — a one-time setup so every future AI session follows the team's -conventions instead of writing generic code. Before we start, do you have -any existing convention docs (CLAUDE.md, .cursorrules, CONTRIBUTING.md, -etc.) I can pull from, or should I scan the codebase from scratch?" diff --git a/.trellis/tasks/00-bootstrap-guidelines/task.json b/.trellis/tasks/00-bootstrap-guidelines/task.json deleted file mode 100644 index 9326ad8..0000000 --- a/.trellis/tasks/00-bootstrap-guidelines/task.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "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": "Mr.Xia", - "assignee": "Mr.Xia", - "createdAt": "2026-05-22", - "completedAt": null, - "branch": null, - "base_branch": null, - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": null, - "relatedFiles": [ - ".trellis/spec/backend/", - ".trellis/spec/frontend/" - ], - "notes": "First-time setup task created by trellis init (fullstack project)", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-05/05-22-defer-sort-seed-check/check.jsonl b/.trellis/tasks/archive/2026-05/05-22-defer-sort-seed-check/check.jsonl deleted file mode 100644 index 9cd59d4..0000000 --- a/.trellis/tasks/archive/2026-05/05-22-defer-sort-seed-check/check.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/archive/2026-05/05-22-defer-sort-seed-check/implement.jsonl b/.trellis/tasks/archive/2026-05/05-22-defer-sort-seed-check/implement.jsonl deleted file mode 100644 index 9cd59d4..0000000 --- a/.trellis/tasks/archive/2026-05/05-22-defer-sort-seed-check/implement.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/archive/2026-05/05-22-defer-sort-seed-check/prd.md b/.trellis/tasks/archive/2026-05/05-22-defer-sort-seed-check/prd.md deleted file mode 100644 index 3d42ba0..0000000 --- a/.trellis/tasks/archive/2026-05/05-22-defer-sort-seed-check/prd.md +++ /dev/null @@ -1,52 +0,0 @@ -# 调整刀序前再检查排序种子 - -## Goal - -当程序带钻带文件路径启动时,启动菜单必须优先出现;排序种子文件的检查和提示只能发生在用户选择“调整刀序”之后,避免用户选择导出孔数、清空参数、生成 PP 钻带等启动菜单动作前被排序提示打断。 - -## Requirements - -* 带启动参数且参数为钻带文件时,先显示启动菜单,不在菜单出现前弹出排序种子提示。 -* 用户点击启动菜单的“调整刀序”后,加载主窗口和钻带文件时再按现有规则检查同目录的 `<文件名>-sort.txt` 和 `General_sort.txt`。 -* 用户选择启动菜单里的非调整刀序动作时,不触发排序种子检查和提示。 -* 保持现有排序种子优先级、提示文案、排序应用逻辑不变。 -* 不调整启动菜单布局、钻带解析规则、PP 钻带生成规则、导出孔数逻辑。 - -## Acceptance Criteria - -* [ ] 启动参数为钻带文件且同目录存在排序种子时,启动菜单先显示。 -* [ ] 在启动菜单点击“调整刀序”后,才出现排序种子检测提示。 -* [ ] 在启动菜单点击“导出孔数报表”或“生成 PP 钻带”时,不出现排序种子检测提示。 -* [ ] 普通打开文件、拖拽文件、主窗口内使用排序种子功能保持原行为。 - -## Definition of Done - -* 项目可成功构建。 -* 不引入新依赖。 -* 代码注释使用中文,关键行为有简短说明。 -* 行为改动限定在启动路径和加载时排序种子检查开关。 - -## Technical Approach - -在 `MainWindowViewModel` 中新增加载时排序种子检查开关,默认开启以保留主窗口内普通加载和“调整刀序”路径的现有行为。`LoadToolsFromDrillTape` 执行排序种子检查前读取该开关。`App` 中用于启动菜单按钮可用性预检查、导出孔数、生成 PP 钻带等非调整刀序路径创建 ViewModel 时关闭该开关。 - -## Decision (ADR-lite) - -**Context**: 当前排序种子检查挂在 `LoadToolsFromDrillTape` 末尾,只要 `OriginalFilePath` 有值就会提示。`App.CanGeneratePpDrillTape()` 在启动菜单显示前为了判断按钮可见性提前加载钻带,并设置了 `OriginalFilePath`,导致排序种子提示先于启动菜单出现。导出孔数和生成 PP 钻带也会在用户选择非调整刀序动作后触发同样的加载副作用。 - -**Decision**: 保留现有加载流程,在 ViewModel 增加显式开关,让启动菜单预检查和非调整刀序启动动作关闭加载时自动排序检查;点击“调整刀序”后进入主窗口时不关闭开关。 - -**Consequences**: 改动面小,现有排序种子应用逻辑无需重写。后续如果还有新的无界面/非排序加载路径,也需要显式关闭该开关。 - -## Out of Scope - -* 不重构 `MainWindowViewModel` 的整体职责。 -* 不改变排序种子文件格式和匹配规则。 -* 不新增自动化 UI 测试。 - -## Technical Notes - -* `App.xaml.cs`:启动参数、启动菜单、菜单动作分发。 -* `MainWindow.xaml.cs`:点击“调整刀序”后打开主窗口并加载初始文件。 -* `MainWindowViewModel.cs`:`LoadToolsFromDrillTape` 当前在解析完成后调用 `CheckAndApplySortFile`。 -* 项目无测试框架,`CLAUDE.md` 明确说明不写单元测试。 diff --git a/.trellis/tasks/archive/2026-05/05-22-defer-sort-seed-check/task.json b/.trellis/tasks/archive/2026-05/05-22-defer-sort-seed-check/task.json deleted file mode 100644 index a5f9cd7..0000000 --- a/.trellis/tasks/archive/2026-05/05-22-defer-sort-seed-check/task.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "id": "defer-sort-seed-check", - "name": "defer-sort-seed-check", - "title": "调整刀序前再检查排序种子", - "description": "", - "status": "completed", - "dev_type": null, - "scope": null, - "package": null, - "priority": "P2", - "creator": "Mr.Xia", - "assignee": "Mr.Xia", - "createdAt": "2026-05-22", - "completedAt": "2026-05-22", - "branch": null, - "base_branch": "master", - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": null, - "relatedFiles": [], - "notes": "", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/workflow.md b/.trellis/workflow.md deleted file mode 100644 index e273e2b..0000000 --- a/.trellis/workflow.md +++ /dev/null @@ -1,690 +0,0 @@ -# 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 -python ./.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 -python ./.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 `prd.md`, `implement.jsonl`, `check.jsonl`, `task.json`, optional `research/`, `info.md`. - -```bash -# Task lifecycle -python ./.trellis/scripts/task.py create "<title>" [--slug <name>] [--parent <dir>] -python ./.trellis/scripts/task.py start <name> # set active task (session-scoped when available) -python ./.trellis/scripts/task.py current --source # show active task and source -python ./.trellis/scripts/task.py finish # clear active task (triggers after_finish hooks) -python ./.trellis/scripts/task.py archive <name> # move to archive/{year-month}/ -python ./.trellis/scripts/task.py list [--mine] [--status <s>] -python ./.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 Phase 1.3. -python ./.trellis/scripts/task.py add-context <name> <action> <file> <reason> -python ./.trellis/scripts/task.py list-context <name> [action] -python ./.trellis/scripts/task.py validate <name> - -# Task metadata -python ./.trellis/scripts/task.py set-branch <name> <branch> -python ./.trellis/scripts/task.py set-base-branch <name> <branch> # PR target -python ./.trellis/scripts/task.py set-scope <name> <scope> - -# Hierarchy (parent/child) -python ./.trellis/scripts/task.py add-subtask <parent> <child> -python ./.trellis/scripts/task.py remove-subtask <parent> <child> - -# PR creation -python ./.trellis/scripts/task.py create-pr [name] [--dry-run] -``` - -> Run `python ./.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 -python ./.trellis/scripts/add_session.py --title "Title" --commit "hash" --summary "Summary" -``` - -### Context Script - -```bash -python ./.trellis/scripts/get_context.py # full session runtime -python ./.trellis/scripts/get_context.py --mode packages # available packages + spec layers -python ./.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 4 [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.3 jsonl curation - 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:in_progress] → Phase 2 + Phase 3.1-3.4 - (status stays 'in_progress' from - task.py start until task.py archive) - [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 → figure out what to do (brainstorm + research → prd.md) -Phase 2: Execute → write code and pass quality checks -Phase 3: Finish → distill lessons + wrap-up -``` - -<!-- Per-turn breadcrumb: shown when there is no active task (before Phase 1) --> - -[workflow-state:no_task] -No active task. **A Direct answer** — pure Q&A / explanation / lookup / chat; no file writes + one-line answer + repo reads ≤ 2 files → AI judges, no override needed. -**B Create a task** — any implementation / code change / build / refactor work. Entry sequence: (1) `python ./.trellis/scripts/task.py create "<title>"` to create the task (status=planning, breadcrumb switches to [workflow-state:planning] for brainstorm + jsonl phase guidance) → (2) load `trellis-brainstorm` skill to discuss requirements with the user and iterate on prd.md → (3) once prd is done and jsonl is curated, run `task.py start <task-dir>` to enter [workflow-state:in_progress] for the implementation skeleton. **"It looks small" is NOT grounds for downgrading B to A or C**. -**C Inline change** (per-turn only, escape hatch for B) — the user's CURRENT message MUST contain one of: "skip trellis" / "no task" / "just do it" / "don't create a task" / "跳过 trellis" / "别走流程" / "小修一下" / "直接改" / "先别建任务" → briefly acknowledge ("ok, skipping trellis flow this turn"), then inline. **Without seeing one of these phrases you must NOT inline on your own**; do not invent an override the user never said. -[/workflow-state:no_task] - -### Phase 1: Plan -- 1.0 Create task `[required · once]` (just `task.py create`; status enters planning) -- 1.1 Requirement exploration `[required · repeatable]` -- 1.2 Research `[optional · repeatable]` -- 1.3 Configure context `[required · once]` — Claude Code, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi -- 1.4 Activate task `[required · once]` (run `task.py start`; status → in_progress) -- 1.5 Completion criteria - -<!-- Per-turn breadcrumb: shown throughout Phase 1 (status='planning') --> - -[workflow-state:planning] -Load the `trellis-brainstorm` skill and iterate on prd.md with the user. -Phase 1.3 (required, once): before `task.py start`, you MUST curate `implement.jsonl` and `check.jsonl` — list the spec / research files sub-agents need so they get the right context injected. You may skip only if the jsonl already has agent-curated entries (the seed `_example` row alone doesn't count). -Then run `task.py start <task-dir>` to flip status to in_progress. -[/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 Phase 1.3 jsonl curation is skipped — - the inline workflow loads `trellis-before-dev` instead of injecting JSONL - into a sub-agent. --> - -[workflow-state:planning-inline] -Load the `trellis-brainstorm` skill and iterate on prd.md with the user. -Phase 1.3 jsonl curation is **skipped** in inline dispatch mode — the main session loads `trellis-before-dev` directly in Phase 2 and reads spec context itself, so there is no sub-agent to inject jsonl into. -Then run `task.py start <task-dir>` to flip status to in_progress. -[/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.1-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. --> - -[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 (no override)**: dispatch the `trellis-implement` / `trellis-check` sub-agents — the main agent does NOT edit code by default. Phase 3.4 commit (required, once): after trellis-update-spec, or whenever implementation is verifiably complete, the main agent **drives the commit** — state the commit plan in user-facing text, then run `git commit` — BEFORE suggesting `/trellis:finish-work`. `/finish-work` refuses to run on a dirty working tree (paths outside `.trellis/workspace/` and `.trellis/tasks/`). -**Sub-agent self-exemption**: if you are already running as `trellis-implement`, implement directly from the loaded task context and do NOT spawn another `trellis-implement`; if you are already running as `trellis-check`, review/fix directly and do NOT spawn another `trellis-check`. The default dispatch rule applies to the main session only. -**Sub-agent dispatch protocol (all platforms, all sub-agents)**: When you spawn `trellis-implement` / `trellis-check` / `trellis-research`, your dispatch prompt **MUST** start with one line: `Active task: <task path from \`task.py current\`>`. No exceptions. On class-2 platforms (codex / copilot / gemini / qoder) the sub-agent depends on this line because there is no hook to inject task context. On class-1 platforms (claude / cursor / opencode / kiro / codebuddy / droid) the line is normally redundant — the hook injects context directly — but it serves as a critical fallback when the hook fails (Windows + Claude Code PreToolUse silent skip, `--continue` resume, fork distribution, hooks disabled, etc.). For `trellis-research`, the line tells the sub-agent which `{task_dir}/research/` to write into. -**Inline override** (per-turn only, escape hatch for sub-agent dispatch): the user's CURRENT message MUST explicitly contain one of: "do it inline" / "no sub-agent" / "你直接改" / "别派 sub-agent" / "main session 写就行" / "不用 sub-agent". **Without seeing one of these phrases you must NOT inline on your own**; do not invent an override the user never said. -[/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** (inline mode): main session loads `trellis-before-dev` → main session edits code → main session loads `trellis-check` → run lint / type-check / tests → fix → `trellis-update-spec` → commit (Phase 3.4) → `/trellis:finish-work`. -**Main-session default (inline dispatch_mode)**: the main agent edits code directly. Do NOT dispatch `trellis-implement` / `trellis-check` sub-agents. Load the `trellis-before-dev` skill before writing code; load the `trellis-check` skill before reporting completion. -Phase 3.4 commit (required, once): after `trellis-update-spec`, or whenever implementation is verifiably complete, the main agent **drives the commit** — state the commit plan in user-facing text, then run `git commit` — BEFORE suggesting `/trellis:finish-work`. `/finish-work` refuses to run on a dirty working tree (paths outside `.trellis/workspace/` and `.trellis/tasks/`). -[/workflow-state:in_progress-inline] - -### Phase 3: Finish -- 3.1 Quality verification `[required · repeatable]` -- 3.2 Debug retrospective `[on demand]` -- 3.3 Spec update `[required · once]` -- 3.4 Commit changes `[required · once]` -- 3.5 Wrap-up reminder - -<!-- 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 via Phase 3.4; run `/trellis:finish-work` to wrap up (archive the task + record session). -If you reach this state with uncommitted code, return to Phase 3.4 first — `/finish-work` refuses to run on a dirty working tree. -`task.py archive` deletes any runtime session files that still point at the archived task. -[/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 - -### Skill Routing - -When a user request matches one of these intents, load the corresponding skill (or dispatch the corresponding sub-agent) first — do not skip skills. - -[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] - -| User intent | Route | -|---|---| -| Wants a new feature / requirement unclear | `trellis-brainstorm` | -| About to write code / start implementing | Dispatch the `trellis-implement` sub-agent per Phase 2.1 | -| Finished writing / want to verify | Dispatch the `trellis-check` sub-agent per Phase 2.2 | -| Stuck / fixed same bug several times | `trellis-break-loop` | -| Spec needs update | `trellis-update-spec` | - -**Why `trellis-before-dev` is NOT in this table:** you are not the one writing code — the `trellis-implement` sub-agent is. Sub-agent platforms get spec context via `implement.jsonl` injection / prelude, not via the main thread loading `trellis-before-dev`. - -[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] - -[codex-inline, Kilo, Antigravity, Windsurf] - -| User intent | Skill | -|---|---| -| Wants a new feature / requirement unclear | `trellis-brainstorm` | -| About to write code / start implementing | `trellis-before-dev` (then implement directly in the main session) | -| Finished writing / want to verify | `trellis-check` | -| Stuck / fixed same bug several times | `trellis-break-loop` | -| Spec needs update | `trellis-update-spec` | - -[/codex-inline, Kilo, Antigravity, Windsurf] - -### DO NOT skip skills - -[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] - -| What you're thinking | Why it's wrong | -|---|---| -| "This is simple, I'll just code it in the main thread" | Dispatching `trellis-implement` is the cheap path; skipping it tempts you to write code in the main thread and lose spec context — sub-agents get `implement.jsonl` injected, you don't | -| "I already thought it through in plan mode" | Plan-mode output lives in memory — sub-agents can't see it; must be persisted to prd.md | -| "I already know the spec" | The spec may have been updated since you last read it; the sub-agent gets the fresh copy, you may not | -| "Code first, check later" | `trellis-check` surfaces issues you won't notice yourself; earlier is cheaper | - -[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] - -[codex-inline, Kilo, Antigravity, Windsurf] - -| What you're thinking | Why it's wrong | -|---|---| -| "This is simple, just code it" | Simple tasks often grow complex; `trellis-before-dev` takes under a minute and loads the spec context you'll need | -| "I already thought it through in plan mode" | Plan-mode output lives in memory — must be persisted to prd.md before code | -| "I already know the spec" | The spec may have been updated since you last read it; read again | -| "Code first, check later" | `trellis-check` surfaces issues you won't notice yourself; earlier is cheaper | - -[/codex-inline, Kilo, Antigravity, Windsurf] - -### Loading Step Detail - -At each step, run this to fetch detailed guidance: - -```bash -python ./.trellis/scripts/get_context.py --mode phase --step <step> -# e.g. python ./.trellis/scripts/get_context.py --mode phase --step 1.1 -``` - ---- - -## Phase 1: Plan - -Goal: figure out what to build, produce a clear requirements doc and the context needed to implement it. - -#### 1.0 Create task `[required · once]` - -Create the task directory (status enters `planning`, the session active-task pointer auto-targets the new task when session identity is available): - -```bash -python ./.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. - -After this command succeeds, the per-turn breadcrumb auto-switches to `[workflow-state:planning]`, telling the AI to enter the brainstorm + jsonl curation phase. - -⚠️ **Run only `create` here — do not also run `start`**. `start` flips status to `in_progress`, which switches the breadcrumb to the implementation phase before brainstorm + jsonl are done — the AI will silently skip them. Save `start` for step 1.4, after jsonl curation is complete. - -Skip when `python ./.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 - -Return to this step whenever requirements change and revise `prd.md`. - -#### 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] - -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] - -[codex-inline, Kilo, Antigravity, Windsurf] - -Do the research in the main session directly and write findings into `{TASK_DIR}/research/`. (For `codex-inline` this avoids the `fork_turns="none"` isolation that prevents `trellis-research` sub-agents from resolving the active task path.) - -[/codex-inline, Kilo, Antigravity, Windsurf] - -**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] - -Curate `implement.jsonl` and `check.jsonl` so the Phase 2 sub-agents get the right spec 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) - -**How to discover relevant specs**: - -```bash -python ./.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 -python ./.trellis/scripts/task.py add-context "$TASK_DIR" implement "<path>" "<reason>" -python ./.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). - -Skip when: `implement.jsonl` has agent-curated entries (the seed row alone doesn't count). - -[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] - -[codex-inline, Kilo, Antigravity, Windsurf] - -Skip this step. Context is loaded directly by the `trellis-before-dev` skill in Phase 2. - -[/codex-inline, Kilo, Antigravity, Windsurf] - -#### 1.4 Activate task `[required · once]` - -Once prd.md is complete and 1.3 jsonl curation is done, flip the task status to `in_progress`: - -```bash -python ./.trellis/scripts/task.py start <task-dir> -``` - -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 requirements | ✅ | -| `task.py start` has been run (status = in_progress) | ✅ | -| `research/` has artifacts (complex tasks) | recommended | -| `info.md` technical design (complex tasks) | optional | - -[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] - -| `implement.jsonl` has agent-curated entries (not just the seed row) | ✅ | - -[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] - ---- - -## Phase 2: Execute - -Goal: turn the prd into code that passes quality checks. - -#### 2.1 Implement `[required · repeatable]` - -[Claude Code, Cursor, OpenCode, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] - -Spawn the implement sub-agent: - -- **Agent type**: `trellis-implement` -- **Task description**: Implement the requirements per prd.md, 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 hook/plugin auto-handles: -- Reads `implement.jsonl` and injects the referenced spec files into the agent prompt -- Injects prd.md content - -[/Claude Code, Cursor, OpenCode, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] - -[codex-sub-agent] - -Spawn the implement sub-agent: - -- **Agent type**: `trellis-implement` -- **Task description**: Implement the requirements per prd.md, 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 Codex sub-agent definition auto-handles the context load requirement: -- Resolves the active task with `task.py current --source`, then reads `prd.md` and `info.md` if present -- Reads `implement.jsonl` and requires the agent to load each referenced spec file before coding - -[/codex-sub-agent] - -[Kiro] - -Spawn the implement sub-agent: - -- **Agent type**: `trellis-implement` -- **Task description**: Implement the requirements per prd.md, 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 the referenced spec files into the agent prompt -- Injects prd.md content - -[/Kiro] - -[codex-inline, Kilo, Antigravity, Windsurf] - -1. Load the `trellis-before-dev` skill to read project guidelines -2. Read `{TASK_DIR}/prd.md` for requirements -3. Consult materials under `{TASK_DIR}/research/` -4. Implement the code per requirements -5. Run project lint and type-check - -[/codex-inline, Kilo, Antigravity, Windsurf] - -#### 2.2 Quality check `[required · repeatable]` - -[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] - -Spawn the check sub-agent: - -- **Agent type**: `trellis-check` -- **Task description**: Review all code changes against spec and prd; fix any findings directly; ensure lint and type-check pass -- **Dispatch prompt guard**: 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 -- 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] - -[codex-inline, Kilo, Antigravity, Windsurf] - -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, Windsurf] - -#### 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.1 Quality verification `[required · repeatable]` - -Load the `trellis-check` skill and do a final verification: -- Spec compliance -- lint / type-check / tests -- Cross-layer consistency (when changes span layers) - -If issues are found → fix → re-check, until green. - -#### 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]` - -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 constraint**: if you change a step's `[required · once]` marker or add a new `[required · once]` step, you MUST also add a matching enforcement line to that phase's `[workflow-state:STATUS]` tag block — otherwise the per-turn breadcrumb omits the reinforcement, and the AI silently skips the step. The regression tests assert this. - -All 4 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) | -| Phase 2 + Phase 3.1–3.4 (implementation + check + wrap-up) | `[workflow-state:in_progress]` (after Phase 2 summary) | -| 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) diff --git a/.trellis/workspace/Mr.Xia/index.md b/.trellis/workspace/Mr.Xia/index.md deleted file mode 100644 index 5267d61..0000000 --- a/.trellis/workspace/Mr.Xia/index.md +++ /dev/null @@ -1,41 +0,0 @@ -# Workspace Index - Mr.Xia - -> Journal tracking for AI development sessions. - ---- - -## Current Status - -<!-- @@@auto:current-status --> -- **Active File**: `journal-1.md` -- **Total Sessions**: 1 -- **Last Active**: 2026-05-22 -<!-- @@@/auto:current-status --> - ---- - -## Active Documents - -<!-- @@@auto:active-documents --> -| File | Lines | Status | -|------|-------|--------| -| `journal-1.md` | ~40 | Active | -<!-- @@@/auto:active-documents --> - ---- - -## Session History - -<!-- @@@auto:session-history --> -| # | Date | Title | Commits | Branch | -|---|------|-------|---------|--------| -| 1 | 2026-05-22 | 移除主窗口置顶功能 | `d1cdf08` | `master` | -<!-- @@@/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 \ No newline at end of file diff --git a/.trellis/workspace/Mr.Xia/journal-1.md b/.trellis/workspace/Mr.Xia/journal-1.md deleted file mode 100644 index d9f43c4..0000000 --- a/.trellis/workspace/Mr.Xia/journal-1.md +++ /dev/null @@ -1,40 +0,0 @@ -# Journal - Mr.Xia (Part 1) - -> AI development session journal -> Started: 2026-05-22 - ---- - - - -## Session 1: 移除主窗口置顶功能 - -**Date**: 2026-05-22 -**Task**: 移除主窗口置顶功能 -**Branch**: `master` - -### Summary - -移除主窗口工具栏中的置顶按钮,并清理对应的 Topmost 初始化、点击事件和 ViewModel 状态逻辑。 - -### Main Changes - -(Add details) - -### Git Commits - -| Hash | Message | -|------|---------| -| `d1cdf08` | (see git log) | - -### Testing - -- [OK] (Add test results) - -### Status - -[OK] **Completed** - -### Next Steps - -- None - task complete diff --git a/.trellis/workspace/index.md b/.trellis/workspace/index.md deleted file mode 100644 index cb8e1f3..0000000 --- a/.trellis/workspace/index.md +++ /dev/null @@ -1,125 +0,0 @@ -# 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 -python ./.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 - python ./.trellis/scripts/get_developer.py - ``` - -2. Read your personal index: - ```bash - cat .trellis/workspace/$(python ./.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**.