chore: 将 .trellis 加入 .gitignore 并移除已跟踪文件
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -360,4 +360,7 @@ MigrationBackup/
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
FodyWeavers.xsd
|
||||
|
||||
# Trellis task management
|
||||
.trellis/
|
||||
32
.trellis/.gitignore
vendored
32
.trellis/.gitignore
vendored
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
0.5.19
|
||||
@@ -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
|
||||
@@ -1,5 +0,0 @@
|
||||
"""
|
||||
Trellis Python Scripts
|
||||
|
||||
This module provides Python implementations of Trellis workflow scripts.
|
||||
"""
|
||||
@@ -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"
|
||||
<session content here>
|
||||
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())
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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-<name>.md
|
||||
Antigravity uses workflow directory: .agent/workflows/<name>.md
|
||||
Windsurf uses workflow directory: .windsurf/workflows/trellis-<name>.md
|
||||
Copilot uses prompt files: .github/prompts/<name>.prompt.md
|
||||
Pi uses prompt templates: .pi/prompts/trellis-<name>.md
|
||||
Claude/OpenCode use subdirectory: .claude/commands/trellis/<name>.md
|
||||
"""
|
||||
if self.platform == "pi":
|
||||
prompts_dir = self.get_config_dir(project_root) / "prompts"
|
||||
if not parts:
|
||||
return prompts_dir
|
||||
if len(parts) >= 2 and parts[0] == "trellis":
|
||||
filename = parts[-1]
|
||||
if filename.endswith(".md"):
|
||||
filename = filename[:-3]
|
||||
return prompts_dir / f"trellis-{filename}.md"
|
||||
return prompts_dir / Path(*parts)
|
||||
|
||||
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/<name>.md to trellis-<name>.md
|
||||
filename = parts[-1]
|
||||
return (
|
||||
self.get_config_dir(project_root) / "commands" / f"trellis-{filename}"
|
||||
)
|
||||
|
||||
return self.get_config_dir(project_root) / "commands" / Path(*parts)
|
||||
|
||||
def get_trellis_command_path(self, name: str) -> str:
|
||||
"""Get relative path to a trellis command file.
|
||||
|
||||
Args:
|
||||
name: Command name without extension (e.g., 'finish-work', 'check')
|
||||
|
||||
Returns:
|
||||
Relative path string for use in JSONL entries
|
||||
|
||||
Note:
|
||||
Cursor: .cursor/commands/trellis-<name>.md
|
||||
Codex: .agents/skills/trellis-<name>/SKILL.md
|
||||
Kiro: .kiro/skills/trellis-<name>/SKILL.md
|
||||
Gemini: .gemini/commands/trellis/<name>.toml
|
||||
Antigravity: .agent/workflows/<name>.md
|
||||
Windsurf: .windsurf/workflows/trellis-<name>.md
|
||||
Pi: .pi/prompts/trellis-<name>.md
|
||||
Others: .{platform}/commands/trellis/<name>.md
|
||||
"""
|
||||
if self.platform == "cursor":
|
||||
return f".cursor/commands/trellis-{name}.md"
|
||||
elif self.platform == "codex":
|
||||
# 0.5.0-beta.0 renamed all skill dirs to add the `trellis-` prefix
|
||||
# (see that release's manifest for the 60+ rename entries).
|
||||
return f".agents/skills/trellis-{name}/SKILL.md"
|
||||
elif self.platform == "kiro":
|
||||
return f".kiro/skills/trellis-{name}/SKILL.md"
|
||||
elif self.platform == "gemini":
|
||||
return f".gemini/commands/trellis/{name}.toml"
|
||||
elif self.platform == "antigravity":
|
||||
return f".agent/workflows/{name}.md"
|
||||
elif self.platform == "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)
|
||||
@@ -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/<package>"
|
||||
Monorepo without package: returns "spec" (caller should specify package)
|
||||
"""
|
||||
if package and is_monorepo(repo_root):
|
||||
return f"spec/{package}"
|
||||
return "spec"
|
||||
|
||||
|
||||
def validate_package(package: str, repo_root: Path | None = None) -> bool:
|
||||
"""Check if a package name is valid in this project.
|
||||
|
||||
Single-repo (no packages configured): always returns True.
|
||||
Monorepo: returns True only if package exists in config.yaml packages.
|
||||
"""
|
||||
packages = get_packages(repo_root)
|
||||
if packages is None:
|
||||
return True # Single-repo, no validation needed
|
||||
return package in packages
|
||||
|
||||
|
||||
def resolve_package(
|
||||
task_package: str | None = None,
|
||||
repo_root: Path | None = None,
|
||||
) -> str | None:
|
||||
"""Resolve package from inferred sources with validation.
|
||||
|
||||
Checks in order: task_package → default_package.
|
||||
Invalid inferred values print a warning to stderr and are skipped.
|
||||
|
||||
Returns:
|
||||
Resolved package name, or None if no valid package found.
|
||||
|
||||
Note:
|
||||
CLI --package should be validated separately by the caller
|
||||
(fail-fast with available packages list on error).
|
||||
"""
|
||||
packages = get_packages(repo_root)
|
||||
if packages is None:
|
||||
return None # Single-repo, no package needed
|
||||
|
||||
# Try task_package (guard against non-string values from malformed JSON)
|
||||
if task_package and isinstance(task_package, str):
|
||||
if task_package in packages:
|
||||
return task_package
|
||||
print(
|
||||
f"Warning: task.json package '{task_package}' not found in config, skipping",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Try default_package
|
||||
default = get_default_package(repo_root)
|
||||
if default:
|
||||
if default in packages:
|
||||
return default
|
||||
print(
|
||||
f"Warning: default_package '{default}' not found in config, skipping",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_spec_scope(repo_root: Path | None = None) -> list[str] | str | None:
|
||||
"""Get session.spec_scope configuration.
|
||||
|
||||
Returns:
|
||||
list[str]: Package names to include in spec scanning.
|
||||
str: "active_task" to use current task's package.
|
||||
None: No scope configured (scan all packages).
|
||||
"""
|
||||
config = _load_config(repo_root)
|
||||
session = config.get("session")
|
||||
if not isinstance(session, dict):
|
||||
return None
|
||||
|
||||
scope = session.get("spec_scope")
|
||||
if scope is None:
|
||||
return None
|
||||
if isinstance(scope, str):
|
||||
return scope # e.g. "active_task"
|
||||
if isinstance(scope, list):
|
||||
return [str(s) for s in scope]
|
||||
return None
|
||||
@@ -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/<name>/ directory structure
|
||||
- Initial journal file and index.md
|
||||
|
||||
Args:
|
||||
name: Developer name.
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
True on success, False on error.
|
||||
"""
|
||||
if not name:
|
||||
print("Error: developer name is required", file=sys.stderr)
|
||||
return False
|
||||
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
|
||||
workspace_dir = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / name
|
||||
|
||||
# Create .developer file
|
||||
initialized_at = datetime.now().isoformat()
|
||||
try:
|
||||
dev_file.write_text(
|
||||
f"name={name}\ninitialized_at={initialized_at}\n",
|
||||
encoding="utf-8"
|
||||
)
|
||||
except (OSError, IOError) as e:
|
||||
print(f"Error: Failed to create .developer file: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Create workspace directory structure
|
||||
try:
|
||||
workspace_dir.mkdir(parents=True, exist_ok=True)
|
||||
except (OSError, IOError) as e:
|
||||
print(f"Error: Failed to create workspace directory: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Create initial journal file
|
||||
journal_file = workspace_dir / f"{FILE_JOURNAL_PREFIX}1.md"
|
||||
if not journal_file.exists():
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
journal_content = f"""# Journal - {name} (Part 1)
|
||||
|
||||
> AI development session journal
|
||||
> Started: {today}
|
||||
|
||||
---
|
||||
|
||||
"""
|
||||
try:
|
||||
journal_file.write_text(journal_content, encoding="utf-8")
|
||||
except (OSError, IOError) as e:
|
||||
print(f"Error: Failed to create journal file: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Create index.md with markers for auto-update
|
||||
index_file = workspace_dir / "index.md"
|
||||
if not index_file.exists():
|
||||
index_content = f"""# Workspace Index - {name}
|
||||
|
||||
> Journal tracking for AI development sessions.
|
||||
|
||||
---
|
||||
|
||||
## Current Status
|
||||
|
||||
<!-- @@@auto:current-status -->
|
||||
- **Active File**: `journal-1.md`
|
||||
- **Total Sessions**: 0
|
||||
- **Last Active**: -
|
||||
<!-- @@@/auto:current-status -->
|
||||
|
||||
---
|
||||
|
||||
## Active Documents
|
||||
|
||||
<!-- @@@auto:active-documents -->
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| `journal-1.md` | ~0 | Active |
|
||||
<!-- @@@/auto:active-documents -->
|
||||
|
||||
---
|
||||
|
||||
## Session History
|
||||
|
||||
<!-- @@@auto:session-history -->
|
||||
| # | Date | Title | Commits | Branch |
|
||||
|---|------|-------|---------|--------|
|
||||
<!-- @@@/auto:session-history -->
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Sessions are appended to journal files
|
||||
- New journal file created when current exceeds 2000 lines
|
||||
- Use `add_session.py` to record sessions
|
||||
"""
|
||||
try:
|
||||
index_file.write_text(index_content, encoding="utf-8")
|
||||
except (OSError, IOError) as e:
|
||||
print(f"Error: Failed to create index.md: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
print(f"Developer initialized: {name}")
|
||||
print(f" .developer file: {dev_file}")
|
||||
print(f" Workspace dir: {workspace_dir}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def ensure_developer(repo_root: Path | None = None) -> None:
|
||||
"""Ensure developer is initialized, exit if not.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
if not check_developer(repo_root):
|
||||
print("Error: Developer not initialized.", file=sys.stderr)
|
||||
print(f"Run: python ./{DIR_WORKFLOW}/scripts/init_developer.py <your-name>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def show_developer_info(repo_root: Path | None = None) -> None:
|
||||
"""Show developer information.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
|
||||
if not developer:
|
||||
print("Developer: (not initialized)")
|
||||
else:
|
||||
print(f"Developer: {developer}")
|
||||
print(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/")
|
||||
print(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Entry (for testing)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
show_developer_info()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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/<package>/
|
||||
For single-repo: scans spec/
|
||||
"""
|
||||
target = spec_dir / package if package else spec_dir
|
||||
if not target.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
d.name for d in target.iterdir() if d.is_dir() and d.name != "guides"
|
||||
)
|
||||
|
||||
|
||||
def _get_active_task_package(repo_root: Path) -> str | None:
|
||||
"""Get the package field from the active task's task.json."""
|
||||
current = get_current_task(repo_root)
|
||||
if not current:
|
||||
return None
|
||||
ct = load_task(repo_root / current)
|
||||
return ct.package if ct and ct.package else None
|
||||
|
||||
|
||||
def _resolve_scope_set(
|
||||
packages: dict,
|
||||
spec_scope,
|
||||
task_pkg: str | None,
|
||||
default_pkg: str | None,
|
||||
) -> set | None:
|
||||
"""Resolve spec_scope to a set of allowed package names, or None for full scan."""
|
||||
if not packages:
|
||||
return None
|
||||
|
||||
if spec_scope is None:
|
||||
return None
|
||||
|
||||
if isinstance(spec_scope, str) and spec_scope == "active_task":
|
||||
if task_pkg and task_pkg in packages:
|
||||
return {task_pkg}
|
||||
if default_pkg and default_pkg in packages:
|
||||
return {default_pkg}
|
||||
return None
|
||||
|
||||
if isinstance(spec_scope, list):
|
||||
valid = {e for e in spec_scope if e in packages}
|
||||
if valid:
|
||||
return valid
|
||||
# All invalid: fallback
|
||||
if task_pkg and task_pkg in packages:
|
||||
return {task_pkg}
|
||||
if default_pkg and default_pkg in packages:
|
||||
return {default_pkg}
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Public Functions
|
||||
# =============================================================================
|
||||
|
||||
def get_packages_info(repo_root: Path) -> list[dict]:
|
||||
"""Get structured package info for monorepo projects.
|
||||
|
||||
Returns list of dicts with keys: name, path, type, default, specLayers,
|
||||
isSubmodule, isGitRepo.
|
||||
Returns empty list for single-repo projects.
|
||||
"""
|
||||
packages = get_packages(repo_root)
|
||||
if not packages:
|
||||
return []
|
||||
|
||||
default_pkg = get_default_package(repo_root)
|
||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||
result = []
|
||||
|
||||
for pkg_name, pkg_config in packages.items():
|
||||
pkg_path = pkg_config.get("path", pkg_name) if isinstance(pkg_config, dict) else str(pkg_config)
|
||||
pkg_type = pkg_config.get("type", "local") if isinstance(pkg_config, dict) else "local"
|
||||
pkg_git = pkg_config.get("git", False) if isinstance(pkg_config, dict) else False
|
||||
layers = _scan_spec_layers(spec_dir, pkg_name)
|
||||
|
||||
result.append({
|
||||
"name": pkg_name,
|
||||
"path": pkg_path,
|
||||
"type": pkg_type,
|
||||
"default": pkg_name == default_pkg,
|
||||
"specLayers": layers,
|
||||
"isSubmodule": pkg_type == "submodule",
|
||||
"isGitRepo": _is_true_config_value(pkg_git),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_packages_section(repo_root: Path) -> str:
|
||||
"""Build the PACKAGES section for text output."""
|
||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||
pkg_info = get_packages_info(repo_root)
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("## PACKAGES")
|
||||
|
||||
if not pkg_info:
|
||||
lines.append("(single-repo mode)")
|
||||
layers = _scan_spec_layers(spec_dir)
|
||||
if layers:
|
||||
lines.append(f"Spec layers: {', '.join(layers)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
default_pkg = get_default_package(repo_root)
|
||||
|
||||
for pkg in pkg_info:
|
||||
layers_str = f" [{', '.join(pkg['specLayers'])}]" if pkg["specLayers"] else ""
|
||||
submodule_tag = " (submodule)" if pkg["isSubmodule"] else ""
|
||||
git_repo_tag = " (git repo)" if pkg["isGitRepo"] else ""
|
||||
default_tag = " *" if pkg["default"] else ""
|
||||
lines.append(
|
||||
f"- {pkg['name']:<16} {pkg['path']:<20}{layers_str}{submodule_tag}{git_repo_tag}{default_tag}"
|
||||
)
|
||||
|
||||
if default_pkg:
|
||||
lines.append(f"Default package: {default_pkg}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_context_packages_text(repo_root: Path | None = None) -> str:
|
||||
"""Get packages context as formatted text (for --mode packages)."""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
pkg_info = get_packages_info(repo_root)
|
||||
lines: list[str] = []
|
||||
|
||||
if not pkg_info:
|
||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||
lines.append("Single-repo project (no packages configured)")
|
||||
lines.append("")
|
||||
layers = _scan_spec_layers(spec_dir)
|
||||
if layers:
|
||||
lines.append(f"Spec layers: {', '.join(layers)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
# Resolve scope for annotations
|
||||
packages_dict = get_packages(repo_root) or {}
|
||||
default_pkg = get_default_package(repo_root)
|
||||
spec_scope = get_spec_scope(repo_root)
|
||||
task_pkg = _get_active_task_package(repo_root)
|
||||
scope_set = _resolve_scope_set(packages_dict, spec_scope, task_pkg, default_pkg)
|
||||
|
||||
lines.append("## PACKAGES")
|
||||
lines.append("")
|
||||
for pkg in pkg_info:
|
||||
default_tag = " (default)" if pkg["default"] else ""
|
||||
type_tag = f" [{pkg['type']}]" if pkg["type"] != "local" else ""
|
||||
git_tag = " [git repo]" if pkg["isGitRepo"] else ""
|
||||
|
||||
# Scope annotation
|
||||
scope_tag = ""
|
||||
if scope_set is not None and pkg["name"] not in scope_set:
|
||||
scope_tag = " (out of scope)"
|
||||
|
||||
lines.append(f"### {pkg['name']}{default_tag}{type_tag}{git_tag}{scope_tag}")
|
||||
lines.append(f"Path: {pkg['path']}")
|
||||
if pkg["specLayers"]:
|
||||
lines.append(f"Spec layers: {', '.join(pkg['specLayers'])}")
|
||||
for layer in pkg["specLayers"]:
|
||||
lines.append(f" - .trellis/spec/{pkg['name']}/{layer}/index.md")
|
||||
else:
|
||||
lines.append("Spec: not configured")
|
||||
lines.append("")
|
||||
|
||||
# Also show shared guides
|
||||
guides_dir = repo_root / DIR_WORKFLOW / DIR_SPEC / "guides"
|
||||
if guides_dir.is_dir():
|
||||
lines.append("### Shared Guides (always included)")
|
||||
lines.append("Path: .trellis/spec/guides/index.md")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_context_packages_json(repo_root: Path | None = None) -> dict:
|
||||
"""Get packages context as a dictionary (for --mode packages --json)."""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
pkg_info = get_packages_info(repo_root)
|
||||
|
||||
if not pkg_info:
|
||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||
layers = _scan_spec_layers(spec_dir)
|
||||
return {
|
||||
"mode": "single-repo",
|
||||
"specLayers": layers,
|
||||
}
|
||||
|
||||
default_pkg = get_default_package(repo_root)
|
||||
spec_scope = get_spec_scope(repo_root)
|
||||
task_pkg = _get_active_task_package(repo_root)
|
||||
|
||||
return {
|
||||
"mode": "monorepo",
|
||||
"packages": pkg_info,
|
||||
"defaultPackage": default_pkg,
|
||||
"specScope": spec_scope,
|
||||
"activeTaskPackage": task_pkg,
|
||||
}
|
||||
@@ -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/<package>
|
||||
|
||||
Uses lazy import to avoid circular dependency with config.py.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
from .config import get_spec_base
|
||||
|
||||
base = get_spec_base(package, repo_root)
|
||||
return repo_root / DIR_WORKFLOW / base
|
||||
|
||||
|
||||
def get_package_path(package: str, repo_root: Path | None = None) -> Path | None:
|
||||
"""Get a package's source directory absolute path from config.
|
||||
|
||||
Returns:
|
||||
Absolute path to the package directory, or None if not found.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
from .config import get_packages
|
||||
|
||||
packages = get_packages(repo_root)
|
||||
if not packages or package not in packages:
|
||||
return None
|
||||
|
||||
info = packages[package]
|
||||
if isinstance(info, dict):
|
||||
rel_path = info.get("path", package)
|
||||
else:
|
||||
rel_path = str(info)
|
||||
|
||||
return repo_root / rel_path
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Entry (for testing)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
repo = get_repo_root()
|
||||
print(f"Repository root: {repo}")
|
||||
print(f"Developer: {get_developer(repo)}")
|
||||
print(f"Tasks dir: {get_tasks_dir(repo)}")
|
||||
print(f"Workspace dir: {get_workspace_dir(repo)}")
|
||||
print(f"Journal file: {get_active_journal_file(repo)}")
|
||||
print(f"Current task: {get_current_task(repo)}")
|
||||
@@ -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 <specific>` fails with "ignored by", DO NOT retry with
|
||||
``-f``. The presence of `.trellis/` in `.gitignore` is treated as user
|
||||
intent ("keep .trellis/ local-only"). The script warns and skips the
|
||||
auto-commit; users who want auto-staging can either fix their `.gitignore`
|
||||
or set ``session_auto_commit: false`` and manage git themselves.
|
||||
- The warning includes a negative example: ``Do NOT use `git add -f .trellis/` ...``
|
||||
so any AI rereading the log doesn't reinvent the bug.
|
||||
|
||||
History note: 0.5.10 introduced an automatic ``git add -f`` retry on the
|
||||
specific paths. That was reverted in 0.5.11 — auto-forcing into a tree the
|
||||
user had gitignored violates user intent even when the path list is narrow.
|
||||
The wider-grain forbidden command stays forbidden, and the narrow-grain auto
|
||||
``-f`` is gone too.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .git import run_git
|
||||
from .paths import (
|
||||
DIR_ARCHIVE,
|
||||
DIR_TASKS,
|
||||
DIR_WORKFLOW,
|
||||
DIR_WORKSPACE,
|
||||
FILE_JOURNAL_PREFIX,
|
||||
get_developer,
|
||||
)
|
||||
|
||||
|
||||
# Paths under .trellis/ that must NEVER be auto-staged. Listed here so the
|
||||
# warning to the user can show concrete subpaths to ignore individually
|
||||
# instead of ignoring the whole `.trellis/` tree.
|
||||
TRELLIS_IGNORED_SUBPATHS = (
|
||||
".trellis/.backup-*",
|
||||
".trellis/worktrees/",
|
||||
".trellis/.template-hashes.json",
|
||||
".trellis/.runtime/",
|
||||
".trellis/.cache/",
|
||||
)
|
||||
|
||||
|
||||
def safe_trellis_paths_to_add(repo_root: Path) -> list[str]:
|
||||
"""Return the list of repo-relative paths the auto-commit should stage.
|
||||
|
||||
Only includes paths that exist on disk so callers don't pass non-existent
|
||||
arguments to git. The caller is responsible for `git diff --cached`
|
||||
checking afterwards.
|
||||
|
||||
Included:
|
||||
- .trellis/workspace/<developer>/journal-*.md
|
||||
- .trellis/workspace/<developer>/index.md
|
||||
- .trellis/tasks/<task-dir>/ (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 -- <paths>`` succeeds → return success.
|
||||
- Plain fails (any reason — ignored or otherwise) → return failure with
|
||||
the stderr. Callers should inspect the stderr (see
|
||||
:func:`print_gitignore_warning`) and skip the auto-commit.
|
||||
"""
|
||||
if not paths:
|
||||
return True, False, ""
|
||||
|
||||
rc, _, err = run_git(["add", "--", *paths], cwd=repo_root)
|
||||
if rc == 0:
|
||||
return True, False, ""
|
||||
return False, False, err
|
||||
|
||||
|
||||
def print_gitignore_warning(paths: list[str]) -> None:
|
||||
"""Explain to the user (and any AI reading the log) what to do.
|
||||
|
||||
CRITICAL: includes the negative example
|
||||
``Do NOT use `git add -f .trellis/``` — agents reading the warning are
|
||||
known to invent that command, which fans out to ignored caches/backups.
|
||||
"""
|
||||
print(
|
||||
"[WARN] git add failed because .trellis/ paths are ignored by your .gitignore.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] Skipping auto-commit. The journal/task files were still written to disk;",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] git was not touched.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("[WARN]", file=sys.stderr)
|
||||
print(
|
||||
"[WARN] Trellis manages these specific paths and they should be tracked:",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if paths:
|
||||
for p in paths:
|
||||
print(f"[WARN] {p}", file=sys.stderr)
|
||||
else:
|
||||
print(
|
||||
"[WARN] .trellis/workspace/<developer>/{journal-*.md,index.md}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] .trellis/tasks/<task-dir>/",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] .trellis/tasks/archive/",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("[WARN]", file=sys.stderr)
|
||||
print(
|
||||
"[WARN] Recommended: change your .gitignore from `.trellis/` to specific",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] subpaths that should remain ignored, e.g.:",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for sub in TRELLIS_IGNORED_SUBPATHS:
|
||||
print(f"[WARN] {sub}", file=sys.stderr)
|
||||
print("[WARN]", file=sys.stderr)
|
||||
print(
|
||||
"[WARN] Or, if you intentionally keep .trellis/ local-only, set in",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] .trellis/config.yaml:",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] session_auto_commit: false",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] so the scripts skip git entirely and you can review / commit",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] manually with `git status` / `git add` / `git commit`.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("[WARN]", file=sys.stderr)
|
||||
print(
|
||||
"[WARN] Do NOT use `git add -f .trellis/` — it pulls in backups, worktrees,",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] and runtime caches that should never be committed.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
@@ -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<current>\S+)\s*(?:→|->)\s*(?P<latest>\S+)",
|
||||
output,
|
||||
)
|
||||
if update_match:
|
||||
return update_match.group("latest").strip()
|
||||
candidates = _VERSION_TOKEN_RE.findall(output)
|
||||
return candidates[-1] if candidates else None
|
||||
|
||||
|
||||
def _resolve_available_update_version() -> str | None:
|
||||
output = _fetch_trellis_version_output()
|
||||
if not output:
|
||||
return None
|
||||
return _extract_available_update_version(output)
|
||||
|
||||
|
||||
def _parse_version(version: str) -> tuple[tuple[int, int, int], tuple[str, ...] | None] | None:
|
||||
match = _VERSION_RE.match(version)
|
||||
if not match:
|
||||
return None
|
||||
major, minor, patch, prerelease = match.groups()
|
||||
numbers = (int(major), int(minor or "0"), int(patch or "0"))
|
||||
prerelease_parts = tuple(prerelease.split(".")) if prerelease else None
|
||||
return numbers, prerelease_parts
|
||||
|
||||
|
||||
def _compare_prerelease(
|
||||
left: tuple[str, ...] | None,
|
||||
right: tuple[str, ...] | None,
|
||||
) -> int:
|
||||
if left is None and right is None:
|
||||
return 0
|
||||
if left is None:
|
||||
return 1
|
||||
if right is None:
|
||||
return -1
|
||||
|
||||
for left_part, right_part in zip(left, right):
|
||||
if left_part == right_part:
|
||||
continue
|
||||
left_numeric = left_part.isdigit()
|
||||
right_numeric = right_part.isdigit()
|
||||
if left_numeric and right_numeric:
|
||||
left_int = int(left_part)
|
||||
right_int = int(right_part)
|
||||
return (left_int > right_int) - (left_int < right_int)
|
||||
if left_numeric:
|
||||
return -1
|
||||
if right_numeric:
|
||||
return 1
|
||||
return (left_part > right_part) - (left_part < right_part)
|
||||
|
||||
return (len(left) > len(right)) - (len(left) < len(right))
|
||||
|
||||
|
||||
def _compare_versions(left: str, right: str) -> int | None:
|
||||
parsed_left = _parse_version(left)
|
||||
parsed_right = _parse_version(right)
|
||||
if parsed_left is None or parsed_right is None:
|
||||
return None
|
||||
|
||||
left_numbers, left_prerelease = parsed_left
|
||||
right_numbers, right_prerelease = parsed_right
|
||||
if left_numbers != right_numbers:
|
||||
return (left_numbers > right_numbers) - (left_numbers < right_numbers)
|
||||
return _compare_prerelease(left_prerelease, right_prerelease)
|
||||
|
||||
|
||||
def _update_marker_path(repo_root: Path) -> Path:
|
||||
context_key = resolve_context_key()
|
||||
if not context_key:
|
||||
terminal_key = os.environ.get("TERM_SESSION_ID", "").strip()
|
||||
context_key = terminal_key or f"ppid-{os.getppid()}"
|
||||
safe_key = re.sub(r"[^A-Za-z0-9._-]+", "_", context_key).strip("._-")
|
||||
if not safe_key:
|
||||
safe_key = "session"
|
||||
return (
|
||||
repo_root
|
||||
/ DIR_WORKFLOW
|
||||
/ ".runtime"
|
||||
/ f"update-check-{safe_key[:160]}.marker"
|
||||
)
|
||||
|
||||
|
||||
def _mark_update_check_attempted(repo_root: Path) -> bool:
|
||||
marker_path = _update_marker_path(repo_root)
|
||||
if marker_path.exists():
|
||||
return False
|
||||
try:
|
||||
marker_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
marker_path.write_text("checked\n", encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def _get_update_hint(repo_root: Path) -> str | None:
|
||||
marker_path = _update_marker_path(repo_root)
|
||||
if marker_path.exists():
|
||||
return None
|
||||
|
||||
current_version = _read_project_version(repo_root)
|
||||
if not current_version:
|
||||
return None
|
||||
|
||||
latest_version = _resolve_available_update_version()
|
||||
if not latest_version:
|
||||
return None
|
||||
|
||||
_mark_update_check_attempted(repo_root)
|
||||
comparison = _compare_versions(current_version, latest_version)
|
||||
if comparison is None or comparison >= 0:
|
||||
return None
|
||||
|
||||
return (
|
||||
f"Trellis update available: {current_version} -> {latest_version}, "
|
||||
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 <name>"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
lines.append(f"Name: {developer}")
|
||||
lines.append("")
|
||||
|
||||
root_git_info = _collect_root_git_info(repo_root)
|
||||
_append_root_git_context(lines, root_git_info)
|
||||
|
||||
# Package git repos — independent sub-repositories
|
||||
_append_package_git_context(
|
||||
lines,
|
||||
_collect_package_git_info(
|
||||
repo_root,
|
||||
discover_unconfigured=not root_git_info["isRepo"],
|
||||
),
|
||||
)
|
||||
|
||||
# Current task
|
||||
lines.append("## CURRENT TASK")
|
||||
current_task = get_current_task(repo_root)
|
||||
if current_task:
|
||||
current_task_dir = repo_root / current_task
|
||||
source_type, context_key, _ = get_current_task_source(repo_root)
|
||||
lines.append(f"Path: {current_task}")
|
||||
lines.append(
|
||||
f"Source: {source_type}" + (f":{context_key}" if context_key else "")
|
||||
)
|
||||
|
||||
ct = load_task(current_task_dir)
|
||||
if ct:
|
||||
lines.append(f"Name: {ct.name}")
|
||||
lines.append(f"Status: {ct.status}")
|
||||
lines.append(f"Created: {ct.raw.get('createdAt', 'unknown')}")
|
||||
if ct.description:
|
||||
lines.append(f"Description: {ct.description}")
|
||||
|
||||
# Check for prd.md
|
||||
prd_file = current_task_dir / "prd.md"
|
||||
if prd_file.is_file():
|
||||
lines.append("")
|
||||
lines.append("[!] This task has prd.md - read it for task details")
|
||||
else:
|
||||
lines.append("(none)")
|
||||
lines.append("")
|
||||
|
||||
# Active tasks
|
||||
lines.append("## ACTIVE TASKS")
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
task_count = 0
|
||||
|
||||
# Collect all task data for hierarchy display
|
||||
all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)}
|
||||
all_statuses = {name: t.status for name, t in all_tasks.items()}
|
||||
|
||||
def _print_task_tree(name: str, indent: int = 0) -> None:
|
||||
nonlocal task_count
|
||||
t = all_tasks[name]
|
||||
progress = children_progress(t.children, all_statuses)
|
||||
prefix = " " * indent
|
||||
lines.append(f"{prefix}- {name}/ ({t.status}){progress} @{t.assignee or '-'}")
|
||||
task_count += 1
|
||||
for child in t.children:
|
||||
if child in all_tasks:
|
||||
_print_task_tree(child, indent + 1)
|
||||
|
||||
for dir_name in sorted(all_tasks.keys()):
|
||||
if not all_tasks[dir_name].parent:
|
||||
_print_task_tree(dir_name)
|
||||
|
||||
if task_count == 0:
|
||||
lines.append("(no active tasks)")
|
||||
lines.append(f"Total: {task_count} active task(s)")
|
||||
lines.append("")
|
||||
|
||||
# My tasks
|
||||
lines.append("## MY TASKS (Assigned to me)")
|
||||
my_task_count = 0
|
||||
|
||||
for t in all_tasks.values():
|
||||
if t.assignee == developer and t.status != "done":
|
||||
progress = children_progress(t.children, all_statuses)
|
||||
lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress}")
|
||||
my_task_count += 1
|
||||
|
||||
if my_task_count == 0:
|
||||
lines.append("(no tasks assigned to you)")
|
||||
lines.append("")
|
||||
|
||||
# Journal file
|
||||
lines.append("## JOURNAL FILE")
|
||||
journal_file = get_active_journal_file(repo_root)
|
||||
if journal_file:
|
||||
journal_lines = count_lines(journal_file)
|
||||
relative = f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}"
|
||||
lines.append(f"Active file: {relative}")
|
||||
lines.append(f"Line count: {journal_lines} / 2000")
|
||||
if journal_lines > 1800:
|
||||
lines.append("[!] WARNING: Approaching 2000 line limit!")
|
||||
else:
|
||||
lines.append("No journal file found")
|
||||
lines.append("")
|
||||
|
||||
# Packages
|
||||
packages_text = get_packages_section(repo_root)
|
||||
if packages_text:
|
||||
lines.append(packages_text)
|
||||
lines.append("")
|
||||
|
||||
# Paths
|
||||
lines.append("## PATHS")
|
||||
lines.append(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/")
|
||||
lines.append(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/")
|
||||
lines.append(f"Spec: {DIR_WORKFLOW}/{DIR_SPEC}/")
|
||||
lines.append("")
|
||||
|
||||
lines.append("========================================")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Record Mode
|
||||
# =============================================================================
|
||||
|
||||
def get_context_record_json(repo_root: Path | None = None) -> dict:
|
||||
"""Get record-mode context as a dictionary.
|
||||
|
||||
Focused on: my active tasks, git status, current task.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
|
||||
root_git_info = _collect_root_git_info(repo_root)
|
||||
|
||||
# My tasks (single pass — collect statuses and filter by assignee)
|
||||
all_tasks_list = list(iter_active_tasks(tasks_dir))
|
||||
all_statuses = {t.dir_name: t.status for t in all_tasks_list}
|
||||
|
||||
my_tasks = []
|
||||
for t in all_tasks_list:
|
||||
if t.assignee == developer:
|
||||
done = sum(
|
||||
1 for c in t.children
|
||||
if all_statuses.get(c) in ("completed", "done")
|
||||
)
|
||||
my_tasks.append({
|
||||
"dir": t.dir_name,
|
||||
"title": t.title,
|
||||
"status": t.status,
|
||||
"priority": t.priority,
|
||||
"children": list(t.children),
|
||||
"childrenDone": done,
|
||||
"parent": t.parent,
|
||||
"meta": t.meta,
|
||||
})
|
||||
|
||||
# Current task
|
||||
current_task_info = None
|
||||
current_task = get_current_task(repo_root)
|
||||
if current_task:
|
||||
source_type, context_key, _ = get_current_task_source(repo_root)
|
||||
ct = load_task(repo_root / current_task)
|
||||
if ct:
|
||||
current_task_info = {
|
||||
"path": current_task,
|
||||
"name": ct.name,
|
||||
"status": ct.status,
|
||||
"source": source_type,
|
||||
"contextKey": context_key,
|
||||
}
|
||||
|
||||
# Package git repos
|
||||
pkg_git_info = _collect_package_git_info(
|
||||
repo_root,
|
||||
discover_unconfigured=not root_git_info["isRepo"],
|
||||
)
|
||||
|
||||
result = {
|
||||
"developer": developer or "",
|
||||
"git": {
|
||||
"isRepo": root_git_info["isRepo"],
|
||||
"branch": root_git_info["branch"],
|
||||
"isClean": root_git_info["isClean"],
|
||||
"uncommittedChanges": root_git_info["uncommittedChanges"],
|
||||
"recentCommits": root_git_info["recentCommits"],
|
||||
},
|
||||
"myTasks": my_tasks,
|
||||
"currentTask": current_task_info,
|
||||
}
|
||||
|
||||
if pkg_git_info:
|
||||
result["packageGit"] = pkg_git_info
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_context_text_record(repo_root: Path | None = None) -> str:
|
||||
"""Get context as formatted text for record-session mode.
|
||||
|
||||
Focused output: MY ACTIVE TASKS first (with [!!!] emphasis),
|
||||
then GIT STATUS, RECENT COMMITS, CURRENT TASK.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("========================================")
|
||||
lines.append("SESSION CONTEXT (RECORD MODE)")
|
||||
lines.append("========================================")
|
||||
lines.append("")
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
if not developer:
|
||||
lines.append(
|
||||
f"ERROR: Not initialized. Run: python ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
# MY ACTIVE TASKS — first and prominent
|
||||
lines.append(f"## [!!!] MY ACTIVE TASKS (Assigned to {developer})")
|
||||
lines.append("[!] Review whether any should be archived before recording this session.")
|
||||
lines.append("")
|
||||
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
my_task_count = 0
|
||||
|
||||
# Single pass — collect all tasks and filter by assignee
|
||||
all_statuses = get_all_statuses(tasks_dir)
|
||||
|
||||
for t in iter_active_tasks(tasks_dir):
|
||||
if t.assignee == developer:
|
||||
progress = children_progress(t.children, all_statuses)
|
||||
lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress} — {t.dir_name}")
|
||||
my_task_count += 1
|
||||
|
||||
if my_task_count == 0:
|
||||
lines.append("(no active tasks assigned to you)")
|
||||
lines.append("")
|
||||
|
||||
root_git_info = _collect_root_git_info(repo_root)
|
||||
_append_root_git_context(lines, root_git_info)
|
||||
|
||||
# Package git repos — independent sub-repositories
|
||||
_append_package_git_context(
|
||||
lines,
|
||||
_collect_package_git_info(
|
||||
repo_root,
|
||||
discover_unconfigured=not root_git_info["isRepo"],
|
||||
),
|
||||
)
|
||||
|
||||
# CURRENT TASK
|
||||
lines.append("## CURRENT TASK")
|
||||
current_task = get_current_task(repo_root)
|
||||
if current_task:
|
||||
source_type, context_key, _ = get_current_task_source(repo_root)
|
||||
lines.append(f"Path: {current_task}")
|
||||
lines.append(
|
||||
f"Source: {source_type}" + (f":{context_key}" if context_key else "")
|
||||
)
|
||||
ct = load_task(repo_root / current_task)
|
||||
if ct:
|
||||
lines.append(f"Name: {ct.name}")
|
||||
lines.append(f"Status: {ct.status}")
|
||||
else:
|
||||
lines.append("(none)")
|
||||
lines.append("")
|
||||
|
||||
lines.append("========================================")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def output_text(repo_root: Path | None = None) -> None:
|
||||
"""Output context in text format.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
update_hint = _get_update_hint(repo_root)
|
||||
if update_hint:
|
||||
print(update_hint)
|
||||
print("")
|
||||
print(get_context_text(repo_root))
|
||||
@@ -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
|
||||
@@ -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']}")
|
||||
@@ -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\": \"<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."
|
||||
)
|
||||
|
||||
|
||||
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 <dir>", file=sys.stderr)
|
||||
else:
|
||||
print(" 2. Run: python task.py start <dir>", 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 <task-dir> <branch-name>")
|
||||
return 1
|
||||
|
||||
task_json = target_dir / FILE_TASK_JSON
|
||||
if not task_json.is_file():
|
||||
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
|
||||
return 1
|
||||
|
||||
data = read_json(task_json)
|
||||
if not data:
|
||||
return 1
|
||||
|
||||
data["branch"] = branch
|
||||
write_json(task_json, data)
|
||||
|
||||
print(colored(f"✓ Branch set to: {branch}", Colors.GREEN))
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: set-base-branch
|
||||
# =============================================================================
|
||||
|
||||
def cmd_set_base_branch(args: argparse.Namespace) -> int:
|
||||
"""Set the base branch (PR target) for task."""
|
||||
repo_root = get_repo_root()
|
||||
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||
base_branch = args.base_branch
|
||||
|
||||
if not base_branch:
|
||||
print(colored("Error: Missing arguments", Colors.RED))
|
||||
print("Usage: python task.py set-base-branch <task-dir> <base-branch>")
|
||||
print("Example: python task.py set-base-branch <dir> develop")
|
||||
print()
|
||||
print("This sets the target branch for PR (the branch your feature will merge into).")
|
||||
return 1
|
||||
|
||||
task_json = target_dir / FILE_TASK_JSON
|
||||
if not task_json.is_file():
|
||||
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
|
||||
return 1
|
||||
|
||||
data = read_json(task_json)
|
||||
if not data:
|
||||
return 1
|
||||
|
||||
data["base_branch"] = base_branch
|
||||
write_json(task_json, data)
|
||||
|
||||
print(colored(f"✓ Base branch set to: {base_branch}", Colors.GREEN))
|
||||
print(f" PR will target: {base_branch}")
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: set-scope
|
||||
# =============================================================================
|
||||
|
||||
def cmd_set_scope(args: argparse.Namespace) -> int:
|
||||
"""Set scope for PR title."""
|
||||
repo_root = get_repo_root()
|
||||
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||
scope = args.scope
|
||||
|
||||
if not scope:
|
||||
print(colored("Error: Missing arguments", Colors.RED))
|
||||
print("Usage: python task.py set-scope <task-dir> <scope>")
|
||||
return 1
|
||||
|
||||
task_json = target_dir / FILE_TASK_JSON
|
||||
if not task_json.is_file():
|
||||
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
|
||||
return 1
|
||||
|
||||
data = read_json(task_json)
|
||||
if not data:
|
||||
return 1
|
||||
|
||||
data["scope"] = scope
|
||||
write_json(task_json, data)
|
||||
|
||||
print(colored(f"✓ Scope set to: {scope}", Colors.GREEN))
|
||||
return 0
|
||||
@@ -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)}")
|
||||
@@ -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]"
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
@@ -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 `<workflow>` 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"
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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/<name>/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)
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Initialize developer for workflow.
|
||||
|
||||
Usage:
|
||||
python init_developer.py <developer-name>
|
||||
|
||||
This creates:
|
||||
- .trellis/.developer file with developer info
|
||||
- .trellis/workspace/<name>/ directory structure
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from common.paths import (
|
||||
DIR_WORKFLOW,
|
||||
FILE_DEVELOPER,
|
||||
get_developer,
|
||||
)
|
||||
from common.developer import init_developer
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point."""
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <developer-name>")
|
||||
print()
|
||||
print("Example:")
|
||||
print(f" {sys.argv[0]} john")
|
||||
sys.exit(1)
|
||||
|
||||
name = sys.argv[1]
|
||||
|
||||
# Check if already initialized
|
||||
existing = get_developer()
|
||||
if existing:
|
||||
print(f"Developer already initialized: {existing}")
|
||||
print()
|
||||
print(f"To reinitialize, remove {DIR_WORKFLOW}/{FILE_DEVELOPER} first")
|
||||
sys.exit(0)
|
||||
|
||||
if init_developer(name):
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,500 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Task Management Script.
|
||||
|
||||
Usage:
|
||||
python task.py create "<title>" [--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())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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**.
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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**.
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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?"
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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."}
|
||||
@@ -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."}
|
||||
@@ -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` 明确说明不写单元测试。
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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**.
|
||||
Reference in New Issue
Block a user