Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3327b9ef6 | |||
| d751055452 | |||
| b0ce83a937 | |||
| 3bb1272c6d | |||
| 5501558354 | |||
| f0a76fd82c | |||
| 2506713fad | |||
| a060666f6f | |||
| 4192798ceb | |||
| 4a12610662 | |||
| 4d093548de | |||
| aaa593ac31 | |||
| 24a8dec151 |
@@ -22,3 +22,12 @@
|
|||||||
# Default to all CPUs for parallel codegen. Override with --jobs N if
|
# Default to all CPUs for parallel codegen. Override with --jobs N if
|
||||||
# you need to leave headroom for other work.
|
# you need to leave headroom for other work.
|
||||||
# jobs = 8
|
# jobs = 8
|
||||||
|
|
||||||
|
# sccache compilation cache: caches compiled crates keyed by source+flags,
|
||||||
|
# so clean builds and branch switches reuse prior results. sccache transparently
|
||||||
|
# skips crates built with incremental compilation (dev profile), so it mainly
|
||||||
|
# accelerates release/fast-release and dependency rebuilds. Harmless if absent
|
||||||
|
# — cargo only applies this when the binary exists on PATH via build-and-run.sh,
|
||||||
|
# which sets RUSTC_WRAPPER conditionally. Uncomment to force it for direct cargo:
|
||||||
|
# rustc-wrapper = "sccache"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# CodeGraph data files
|
||||||
|
# These are local to each machine and should not be committed
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
cache/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Hook markers
|
||||||
|
.dirty
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Developer identity (local only)
|
||||||
|
.developer
|
||||||
|
|
||||||
|
# Current task pointer (each dev works on different task)
|
||||||
|
.current-task
|
||||||
|
|
||||||
|
# Session/window scoped runtime state
|
||||||
|
.runtime/
|
||||||
|
|
||||||
|
# Ralph Loop state file
|
||||||
|
.ralph-state.json
|
||||||
|
|
||||||
|
# Agent runtime files
|
||||||
|
.agents/
|
||||||
|
.agent-log
|
||||||
|
.session-id
|
||||||
|
|
||||||
|
# Task directory runtime files
|
||||||
|
.plan-log
|
||||||
|
|
||||||
|
# Atomic update temp files
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# Update backup directories
|
||||||
|
.backup-*
|
||||||
|
|
||||||
|
# Conflict resolution temp files
|
||||||
|
*.new
|
||||||
|
|
||||||
|
# Python cache
|
||||||
|
**/__pycache__/
|
||||||
|
**/*.pyc
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
{
|
||||||
|
"__version": 2,
|
||||||
|
"hashes": {
|
||||||
|
".claude/agents/trellis-check.md": "9e48342243f311d55386f8fb42933945e87aba73d5ade547133aa98345a06128",
|
||||||
|
".claude/agents/trellis-implement.md": "73b56b3047c0e852382c4630aa181fb847d1e5ea1f63459dac4bf728f43fd097",
|
||||||
|
".claude/agents/trellis-research.md": "add4aa4259ded425b04ec992802c646490352bb4eb3730a7ab450beea87d4faa",
|
||||||
|
".claude/hooks/inject-subagent-context.py": "c8ea04062990530dffb26c4d1efa3e6887e042d3086da2f2325b491c9d544931",
|
||||||
|
".claude/hooks/inject-workflow-state.py": "f7fa9389ed7aa264597fff5de6277bec186e89a3ef539192997c6d026d88d5ec",
|
||||||
|
".claude/hooks/session-start.py": "2cd87a08dec8cecc5f1acb665d6bd7241bc580f6ae39d571b99a0118be89c46b",
|
||||||
|
".claude/commands/trellis/continue.md": "6c34c41824f8eff4b2df792e032e0c36787b59f8a8879b0338347073f76ad52c",
|
||||||
|
".claude/commands/trellis/finish-work.md": "d6aa570ab684f57e4845de2d84a1ff6d9f0908e04c5a56e14fd70ae739c369fc",
|
||||||
|
".claude/skills/trellis-before-dev/SKILL.md": "859894f2e8258cbfa142d710363433761c54984c69f7ed7bd44512fb4eb165cd",
|
||||||
|
".claude/skills/trellis-brainstorm/SKILL.md": "219f99c61e59aa9af4642c0ec96ceec72c915f6757572760ba69dee951b05eb1",
|
||||||
|
".claude/skills/trellis-break-loop/SKILL.md": "f5a93699832f29dee443b53c135a7459519b371f689af191d15c29e8ee5c7bde",
|
||||||
|
".claude/skills/trellis-check/SKILL.md": "b21ff04b7680ebacb8c5ecbc48a22d627eb13e2b47fceb78c8ced0b43b60b282",
|
||||||
|
".claude/skills/trellis-update-spec/SKILL.md": "d975db7af166578488958751ae2c56edb827a68bddb569aa27acc3453f64e610",
|
||||||
|
".claude/skills/trellis-channel/references/command-reference.md": "c4df3d940d89814310bfdaded20e84dd1d6b96786f6772688de7a123338d3c8e",
|
||||||
|
".claude/skills/trellis-channel/references/forum.md": "407db39e8ebde47cea8637e3069bdb3d76ba42b43ed1d08ba97a56f26dbcfcb4",
|
||||||
|
".claude/skills/trellis-channel/references/progress-debugging.md": "93b021fd8d5d3d4ee54952c40424ab91ca57c2ab8032bfbfc3b5c5e22f1464cd",
|
||||||
|
".claude/skills/trellis-channel/references/workers.md": "d4481b2858bca82050cb8d131916da8a2ccc3857f199cdb9ced8bb41d592e2a7",
|
||||||
|
".claude/skills/trellis-channel/references/workflows.md": "0782d602efde6e94a7d7d7950fd605eedc9a1f5a523908284e76b628fd618fd4",
|
||||||
|
".claude/skills/trellis-channel/SKILL.md": "ff84801c511a736da018b4b1149348ebc9a16b7fc18d905ec10cfa7acbf269a3",
|
||||||
|
".claude/skills/trellis-meta/references/customize-local/add-project-local-conventions.md": "86009ccb5d0373f399582da0bc570c4e5c6053c3c764857424ff93384f0e04e5",
|
||||||
|
".claude/skills/trellis-meta/references/customize-local/change-agents.md": "3eef0d9b9cf875f121c1a38afb8eee6d3cd3894127db601ae5a7760fa5b61575",
|
||||||
|
".claude/skills/trellis-meta/references/customize-local/change-context-loading.md": "350d319dc1ab99609ddbf52cf8c06c71bd97ba1a29ce2eac8b97d0bb192938bf",
|
||||||
|
".claude/skills/trellis-meta/references/customize-local/change-hooks.md": "91892f2cff53ae003736007e95172945acabf48b2fc889bd627cd2406ce449c4",
|
||||||
|
".claude/skills/trellis-meta/references/customize-local/change-skills-or-commands.md": "a6994e2418cdf5bcad10b4236b02741179ae794bc4fdd811a64f443293b69268",
|
||||||
|
".claude/skills/trellis-meta/references/customize-local/change-spec-structure.md": "31eccaad7097d96e66a45c1b4caea1ba4f2e54b7814184c3ebf82c87dafc4841",
|
||||||
|
".claude/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "60ff9efb93604b87a461a4af30322d76750402a51e40f31531a7ff88d309996d",
|
||||||
|
".claude/skills/trellis-meta/references/customize-local/change-workflow.md": "43fa780a2ca580de121b10893d49b99f978873deebbf45008c466e5ac6651519",
|
||||||
|
".claude/skills/trellis-meta/references/customize-local/overview.md": "ce8f09e9f93ce9a48500763fb3a4db2b3908a5fbf4f985ab71dacebb404cf8f4",
|
||||||
|
".claude/skills/trellis-meta/references/local-architecture/bundled-skills.md": "bac739b042d7a12dae5d14d85cbc312e56ca03f571d2ba7a25b20641ddaefc3f",
|
||||||
|
".claude/skills/trellis-meta/references/local-architecture/context-injection.md": "8497289bf333b3aa456f317039d1239b7ece79254aa0eb62cfc647714c866084",
|
||||||
|
".claude/skills/trellis-meta/references/local-architecture/generated-files.md": "ca4211b43aff79cf263c83255f557994386723d10da344c0c4e1ec211c7fcd37",
|
||||||
|
".claude/skills/trellis-meta/references/local-architecture/multi-agent-channel.md": "56e5070474aeca872e2d70c46feea5aaafecd3d3ec052c3f7b1877358dca62e9",
|
||||||
|
".claude/skills/trellis-meta/references/local-architecture/overview.md": "50638fd9eaaba2e0edf2f2a84d920578d5b2fb7934031b174e417d3dc510b2a6",
|
||||||
|
".claude/skills/trellis-meta/references/local-architecture/spec-system.md": "b8d8a6a0888b44a232c8f50161b9e20e903cf621ad7be4021715ab6fab226f47",
|
||||||
|
".claude/skills/trellis-meta/references/local-architecture/task-system.md": "2b561d49c390f7d0db5391912946133be4bf73189231e2b8cc9afa1c5ac6165a",
|
||||||
|
".claude/skills/trellis-meta/references/local-architecture/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6",
|
||||||
|
".claude/skills/trellis-meta/references/local-architecture/workspace-memory.md": "e6427b46aba744563c2444b30df4043cd856561b7709ec2dece26095416421fd",
|
||||||
|
".claude/skills/trellis-meta/references/platform-files/agents.md": "ce1a22c2b3ff0bff68755b5663e1f3b63ef2f1658cff43e511cee9013537c698",
|
||||||
|
".claude/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "b70f983d0b3fea45e4ad7abef8ab8981473ff016d6d454d4d407865c13019d31",
|
||||||
|
".claude/skills/trellis-meta/references/platform-files/overview.md": "7bc24f5ffefe082f373562bbba715cce9e761a76f6b59ce341d4dc679971a587",
|
||||||
|
".claude/skills/trellis-meta/references/platform-files/platform-map.md": "a3cb8b4b4e42a3b380485aca673750ba3b2b28c16a04339b1db7afbd479509ab",
|
||||||
|
".claude/skills/trellis-meta/references/platform-files/skills-and-commands.md": "5732bb0a7d384a91abd0c103372f3865e0b186e75185f35e0e91b00a6b6bf5a3",
|
||||||
|
".claude/skills/trellis-meta/SKILL.md": "a190ecbde70a658a5b5c3e2558783e99c9afc6f2e5cdecdac8f11c06dd42065e",
|
||||||
|
".claude/skills/trellis-session-insight/references/cli-quick-reference.md": "c520353fe3fc00b9702ed4f780647c8fbd6d342e66527a03647f533a9fe09779",
|
||||||
|
".claude/skills/trellis-session-insight/references/triggering-patterns.md": "121ecd23be83d1567e8ce15c366a81073d7a2b1d3ad616fce235c07ca1f1cc20",
|
||||||
|
".claude/skills/trellis-session-insight/SKILL.md": "f6ad80eaca21b8fa63d31ae26b612bb2bb69dd04469603828509a8c5a0463ff1",
|
||||||
|
".claude/skills/trellis-spec-bootstrap/references/mcp-setup.md": "df542fc8f279edd38046d26a7c8151804b708f57b24d4aa2733cea587a88c65e",
|
||||||
|
".claude/skills/trellis-spec-bootstrap/references/repository-analysis.md": "0dae98d774f6e34559b9f3442888ac43e3a8af110c37cbefc49ce256986858b6",
|
||||||
|
".claude/skills/trellis-spec-bootstrap/references/spec-task-planning.md": "ef493d028c3b0807a8a534bb71fb92a68129f273db763ad27ceb464a522e799d",
|
||||||
|
".claude/skills/trellis-spec-bootstrap/references/spec-writing.md": "e9800fe9ed4a4cd87062ea1829cf2caa8d170ec15e141678a6a30e74c497f47d",
|
||||||
|
".claude/skills/trellis-spec-bootstrap/SKILL.md": "97bfa68c06cebb558eb4464bc1b81f7d2d56040d75baa8de1ee5ad90cca0196a",
|
||||||
|
".agents/skills/trellis-continue/SKILL.md": "7723ccf49fbf19d8f086cacc7a080bd8be8db6fc70a32908b80f68efa318d7bf",
|
||||||
|
".agents/skills/trellis-finish-work/SKILL.md": "161060fbcd44f787440d3a5c297a9f5223ea7774bb3021a50e376875a9ac5b2d",
|
||||||
|
".agents/skills/trellis-before-dev/SKILL.md": "859894f2e8258cbfa142d710363433761c54984c69f7ed7bd44512fb4eb165cd",
|
||||||
|
".agents/skills/trellis-brainstorm/SKILL.md": "219f99c61e59aa9af4642c0ec96ceec72c915f6757572760ba69dee951b05eb1",
|
||||||
|
".agents/skills/trellis-break-loop/SKILL.md": "f5a93699832f29dee443b53c135a7459519b371f689af191d15c29e8ee5c7bde",
|
||||||
|
".agents/skills/trellis-check/SKILL.md": "b21ff04b7680ebacb8c5ecbc48a22d627eb13e2b47fceb78c8ced0b43b60b282",
|
||||||
|
".agents/skills/trellis-update-spec/SKILL.md": "003ce08a3404aeb50998029392c4d4e57b626edf526d3ebd585032bb92dcbb96",
|
||||||
|
".agents/skills/trellis-channel/references/command-reference.md": "c4df3d940d89814310bfdaded20e84dd1d6b96786f6772688de7a123338d3c8e",
|
||||||
|
".agents/skills/trellis-channel/references/forum.md": "407db39e8ebde47cea8637e3069bdb3d76ba42b43ed1d08ba97a56f26dbcfcb4",
|
||||||
|
".agents/skills/trellis-channel/references/progress-debugging.md": "93b021fd8d5d3d4ee54952c40424ab91ca57c2ab8032bfbfc3b5c5e22f1464cd",
|
||||||
|
".agents/skills/trellis-channel/references/workers.md": "d4481b2858bca82050cb8d131916da8a2ccc3857f199cdb9ced8bb41d592e2a7",
|
||||||
|
".agents/skills/trellis-channel/references/workflows.md": "0782d602efde6e94a7d7d7950fd605eedc9a1f5a523908284e76b628fd618fd4",
|
||||||
|
".agents/skills/trellis-channel/SKILL.md": "ff84801c511a736da018b4b1149348ebc9a16b7fc18d905ec10cfa7acbf269a3",
|
||||||
|
".agents/skills/trellis-meta/references/customize-local/add-project-local-conventions.md": "86009ccb5d0373f399582da0bc570c4e5c6053c3c764857424ff93384f0e04e5",
|
||||||
|
".agents/skills/trellis-meta/references/customize-local/change-agents.md": "3eef0d9b9cf875f121c1a38afb8eee6d3cd3894127db601ae5a7760fa5b61575",
|
||||||
|
".agents/skills/trellis-meta/references/customize-local/change-context-loading.md": "350d319dc1ab99609ddbf52cf8c06c71bd97ba1a29ce2eac8b97d0bb192938bf",
|
||||||
|
".agents/skills/trellis-meta/references/customize-local/change-hooks.md": "91892f2cff53ae003736007e95172945acabf48b2fc889bd627cd2406ce449c4",
|
||||||
|
".agents/skills/trellis-meta/references/customize-local/change-skills-or-commands.md": "a6994e2418cdf5bcad10b4236b02741179ae794bc4fdd811a64f443293b69268",
|
||||||
|
".agents/skills/trellis-meta/references/customize-local/change-spec-structure.md": "31eccaad7097d96e66a45c1b4caea1ba4f2e54b7814184c3ebf82c87dafc4841",
|
||||||
|
".agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "60ff9efb93604b87a461a4af30322d76750402a51e40f31531a7ff88d309996d",
|
||||||
|
".agents/skills/trellis-meta/references/customize-local/change-workflow.md": "43fa780a2ca580de121b10893d49b99f978873deebbf45008c466e5ac6651519",
|
||||||
|
".agents/skills/trellis-meta/references/customize-local/overview.md": "ce8f09e9f93ce9a48500763fb3a4db2b3908a5fbf4f985ab71dacebb404cf8f4",
|
||||||
|
".agents/skills/trellis-meta/references/local-architecture/bundled-skills.md": "bac739b042d7a12dae5d14d85cbc312e56ca03f571d2ba7a25b20641ddaefc3f",
|
||||||
|
".agents/skills/trellis-meta/references/local-architecture/context-injection.md": "8497289bf333b3aa456f317039d1239b7ece79254aa0eb62cfc647714c866084",
|
||||||
|
".agents/skills/trellis-meta/references/local-architecture/generated-files.md": "ca4211b43aff79cf263c83255f557994386723d10da344c0c4e1ec211c7fcd37",
|
||||||
|
".agents/skills/trellis-meta/references/local-architecture/multi-agent-channel.md": "56e5070474aeca872e2d70c46feea5aaafecd3d3ec052c3f7b1877358dca62e9",
|
||||||
|
".agents/skills/trellis-meta/references/local-architecture/overview.md": "50638fd9eaaba2e0edf2f2a84d920578d5b2fb7934031b174e417d3dc510b2a6",
|
||||||
|
".agents/skills/trellis-meta/references/local-architecture/spec-system.md": "b8d8a6a0888b44a232c8f50161b9e20e903cf621ad7be4021715ab6fab226f47",
|
||||||
|
".agents/skills/trellis-meta/references/local-architecture/task-system.md": "2b561d49c390f7d0db5391912946133be4bf73189231e2b8cc9afa1c5ac6165a",
|
||||||
|
".agents/skills/trellis-meta/references/local-architecture/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6",
|
||||||
|
".agents/skills/trellis-meta/references/local-architecture/workspace-memory.md": "e6427b46aba744563c2444b30df4043cd856561b7709ec2dece26095416421fd",
|
||||||
|
".agents/skills/trellis-meta/references/platform-files/agents.md": "ce1a22c2b3ff0bff68755b5663e1f3b63ef2f1658cff43e511cee9013537c698",
|
||||||
|
".agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "b70f983d0b3fea45e4ad7abef8ab8981473ff016d6d454d4d407865c13019d31",
|
||||||
|
".agents/skills/trellis-meta/references/platform-files/overview.md": "7bc24f5ffefe082f373562bbba715cce9e761a76f6b59ce341d4dc679971a587",
|
||||||
|
".agents/skills/trellis-meta/references/platform-files/platform-map.md": "a3cb8b4b4e42a3b380485aca673750ba3b2b28c16a04339b1db7afbd479509ab",
|
||||||
|
".agents/skills/trellis-meta/references/platform-files/skills-and-commands.md": "5732bb0a7d384a91abd0c103372f3865e0b186e75185f35e0e91b00a6b6bf5a3",
|
||||||
|
".agents/skills/trellis-meta/SKILL.md": "a190ecbde70a658a5b5c3e2558783e99c9afc6f2e5cdecdac8f11c06dd42065e",
|
||||||
|
".agents/skills/trellis-session-insight/references/cli-quick-reference.md": "c520353fe3fc00b9702ed4f780647c8fbd6d342e66527a03647f533a9fe09779",
|
||||||
|
".agents/skills/trellis-session-insight/references/triggering-patterns.md": "121ecd23be83d1567e8ce15c366a81073d7a2b1d3ad616fce235c07ca1f1cc20",
|
||||||
|
".agents/skills/trellis-session-insight/SKILL.md": "f6ad80eaca21b8fa63d31ae26b612bb2bb69dd04469603828509a8c5a0463ff1",
|
||||||
|
".agents/skills/trellis-spec-bootstrap/references/mcp-setup.md": "df542fc8f279edd38046d26a7c8151804b708f57b24d4aa2733cea587a88c65e",
|
||||||
|
".agents/skills/trellis-spec-bootstrap/references/repository-analysis.md": "0dae98d774f6e34559b9f3442888ac43e3a8af110c37cbefc49ce256986858b6",
|
||||||
|
".agents/skills/trellis-spec-bootstrap/references/spec-task-planning.md": "ef493d028c3b0807a8a534bb71fb92a68129f273db763ad27ceb464a522e799d",
|
||||||
|
".agents/skills/trellis-spec-bootstrap/references/spec-writing.md": "e9800fe9ed4a4cd87062ea1829cf2caa8d170ec15e141678a6a30e74c497f47d",
|
||||||
|
".agents/skills/trellis-spec-bootstrap/SKILL.md": "97bfa68c06cebb558eb4464bc1b81f7d2d56040d75baa8de1ee5ad90cca0196a",
|
||||||
|
".agents/skills/trellis-start/SKILL.md": "79a5ba7a2aff3c72e06d7f4cd6942dc4f4f4092dd40f9c8e94f1838024a81e4d",
|
||||||
|
".codex/agents/trellis-check.toml": "372dbd32a68f156727fd9f5d755a184543db96013abfeaffb09974c78fd5b873",
|
||||||
|
".codex/agents/trellis-implement.toml": "87dc719802b355c61607c85ae0ab3e42b2a374a578a9fde349c3ae412638f8b4",
|
||||||
|
".codex/agents/trellis-research.toml": "5492f7f6ab8bdea975b0e853bf171b050f7ddf6c2079ac770ed912c48d815eae",
|
||||||
|
".codex/hooks/session-start.py": "1c951ff35f490c5fbf576b4764ec190895df7c2a48e279fb20625209f51c321a",
|
||||||
|
".codex/hooks/inject-workflow-state.py": "f7fa9389ed7aa264597fff5de6277bec186e89a3ef539192997c6d026d88d5ec",
|
||||||
|
".codex/hooks.json": "522ba3c488c100027783e52ecff84c0bd799852dd77ad3f1936e86db105f01d6",
|
||||||
|
".trellis/agents/check.md": "edb4f57361407249a53bf5998ebf91c40d2b969e826a2c5e1b4e813a08bcb175",
|
||||||
|
".trellis/agents/implement.md": "66e25ad046c94869442834bc3cdfbd5a9a7412d3ff54561d64d2886552c27e87",
|
||||||
|
".trellis/config.yaml": "2e6489de5ab99c501f24edd3f08df977801bf49c33b04a2de6afafe35a3a8332",
|
||||||
|
".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c",
|
||||||
|
".trellis/scripts/add_session.py": "7fcacb29ffd13599949a517aa967d868040eb4c9b40356b0c460a32361237eb6",
|
||||||
|
".trellis/scripts/common/__init__.py": "3d5e9347141f0296319a5beb29d69ae714c5a474b9078caeb3edd7c5f6562e22",
|
||||||
|
".trellis/scripts/common/active_task.py": "6c88ed40ef7289bca0f6d2ecba0f8b8aef46cd58788080fbeeea88de138a431f",
|
||||||
|
".trellis/scripts/common/cli_adapter.py": "bda2eb3a76b3990ef979846a5eff3d8aedde9c70e0e9d128266034aeee186020",
|
||||||
|
".trellis/scripts/common/config.py": "25c5a53ad20d6909be5209222e4208a84528805316a4d78350529459a364edb1",
|
||||||
|
".trellis/scripts/common/developer.py": "f5f833123abe68890171b4da825a324216d24913f6b5ad9245afc556424ffd7b",
|
||||||
|
".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": "baa5c82324eb62154374ec63394ecdc8609bb37d93892e3bcb88f452bb7d6446",
|
||||||
|
".trellis/scripts/common/session_context.py": "df79c44efe3432811c32d145d57a66343a70e221ec087ed2bd28b76677bb4076",
|
||||||
|
".trellis/scripts/common/task_context.py": "d174684d417bbe2fafc26b6afcddb264c7dc519527bb24d2055cd27daaad9b55",
|
||||||
|
".trellis/scripts/common/task_queue.py": "0be61f713462b1fe4574927c82fc4704e678afe72dcb9813543aedf2f9e9e0c5",
|
||||||
|
".trellis/scripts/common/task_store.py": "789c090c2d5103bf790e8bfb0989a8ff486c7d370cbe03d3eefb1c239d02624a",
|
||||||
|
".trellis/scripts/common/task_utils.py": "f5ef4af87ba3e11d8b19630c0c96d009de1811fc9be56c2027a9c96e21ed103e",
|
||||||
|
".trellis/scripts/common/tasks.py": "4436a8b0b53c270a35989e26d9dbd92669408c6562d88c02083a404562da85fe",
|
||||||
|
".trellis/scripts/common/trellis_config.py": "0839dcf90ebbd77712c276930a89335b3313927051650c91d220fb51ca2a6a3c",
|
||||||
|
".trellis/scripts/common/types.py": "9962081cc2608fb9d1deb32c6880e336f62cdca6b338e7ae813304701e155ee9",
|
||||||
|
".trellis/scripts/common/workflow_phase.py": "f2b5fcf0c40cedcf3d7d0ad8023d141fa4095caf0302d73f2a73e1bc04b5692b",
|
||||||
|
".trellis/scripts/get_context.py": "ca5bf9e90bdb1d75d3de182b95f820f9d108ab28793d29097b24fd71315adcf5",
|
||||||
|
".trellis/scripts/get_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f",
|
||||||
|
".trellis/scripts/hooks/linear_sync.py": "e09cc4ce4699aada908808718698f33f705a3edf55c4dcf8f777ad892f80ca79",
|
||||||
|
".trellis/scripts/init_developer.py": "f9e6c0d882406e81c8cd6b1c5abb204b0befc0069ff89cf650cd536a80f8c60e",
|
||||||
|
".trellis/scripts/task.py": "6c65801a1f56648fd4765a1d216493d3094827c1db4761e55fdaa548c1801798",
|
||||||
|
".trellis/workflow.md": "f44acd1c08d1601fdc4377671ef932eb3e09f0e87b7872a6d0ee4baeada29fcb"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0.6.3
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
name: check
|
||||||
|
description: |
|
||||||
|
Code quality auditor for the Trellis channel runtime. Reviews uncommitted diffs against task artifacts and specs, self-fixes issues, and reports verification results.
|
||||||
|
provider: claude
|
||||||
|
labels: [trellis, check]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Check Agent (channel runtime)
|
||||||
|
|
||||||
|
You are the Check Agent spawned by `trellis channel spawn --agent check` inside the Trellis channel runtime. You receive an `Active task: <path>` line in your inbox; use it to locate task artifacts on disk.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Before reviewing, read in this order:
|
||||||
|
|
||||||
|
1. `<task-path>/check.jsonl` if present — spec manifest curated for this turn; read every listed file
|
||||||
|
2. `<task-path>/prd.md` — requirements
|
||||||
|
3. `<task-path>/design.md` if present — technical design
|
||||||
|
4. `<task-path>/implement.md` if present — execution plan
|
||||||
|
5. `.trellis/spec/` — project-wide guidelines (load only what is relevant to the diff under review)
|
||||||
|
|
||||||
|
## Core Responsibilities
|
||||||
|
|
||||||
|
1. **Get the diff** — `git diff` / `git diff --staged` for uncommitted changes
|
||||||
|
2. **Review against task artifacts** — does the diff satisfy `prd.md` (and `design.md` / `implement.md` if present)?
|
||||||
|
3. **Review against specs** — naming, structure, type safety, error handling, conventions in `.trellis/spec/`
|
||||||
|
4. **Self-fix** — when an issue is mechanical and small, fix it directly with the editing tools you have
|
||||||
|
5. **Run verification** — project lint and typecheck on the changed scope
|
||||||
|
6. **Report** — concrete findings with `file:line` citations and what was fixed vs. what is open
|
||||||
|
|
||||||
|
## Forbidden Operations
|
||||||
|
|
||||||
|
- `git commit`
|
||||||
|
- `git push`
|
||||||
|
- `git merge`
|
||||||
|
|
||||||
|
The supervising main session owns commits. Report the post-fix state; do not commit on its behalf.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Run `git diff --name-only` and `git diff` to scope the changes
|
||||||
|
2. Read the task artifacts and relevant spec files
|
||||||
|
3. For each issue:
|
||||||
|
- If mechanical (lint nit, missing type, wrong import, dead branch) → fix in-place
|
||||||
|
- If a design/judgment issue → record and report, do not silently rewrite
|
||||||
|
4. Run the project's lint and typecheck on the changed scope after self-fixes
|
||||||
|
5. Report
|
||||||
|
|
||||||
|
## Report Format
|
||||||
|
|
||||||
|
```
|
||||||
|
## Self-Check Complete
|
||||||
|
|
||||||
|
### Files Checked
|
||||||
|
- <path>
|
||||||
|
|
||||||
|
### Issues Found and Fixed
|
||||||
|
1. `<file>:<line>` — <what was wrong> → <what you changed>
|
||||||
|
|
||||||
|
### Issues Not Fixed
|
||||||
|
- `<file>:<line>` — <issue> — <why deferred to the main session>
|
||||||
|
|
||||||
|
### Verification Results
|
||||||
|
- TypeCheck: <pass|fail|skipped + reason>
|
||||||
|
- Lint: <pass|fail|skipped + reason>
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
Checked <N> files, found <X> issues, fixed <Y>, <X-Y> open.
|
||||||
|
```
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
name: implement
|
||||||
|
description: |
|
||||||
|
Code implementation expert for the Trellis channel runtime. Understands specs and task artifacts, then implements features. No git commit allowed.
|
||||||
|
provider: claude
|
||||||
|
labels: [trellis, implement]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Implement Agent (channel runtime)
|
||||||
|
|
||||||
|
You are the Implement Agent spawned by `trellis channel spawn --agent implement` inside the Trellis channel runtime. You receive an `Active task: <path>` line in your inbox; use it to locate task artifacts on disk.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Before implementing, read in this order:
|
||||||
|
|
||||||
|
1. `<task-path>/implement.jsonl` if present — spec manifest curated for this turn; read every listed file
|
||||||
|
2. `<task-path>/prd.md` — requirements
|
||||||
|
3. `<task-path>/design.md` if present — technical design
|
||||||
|
4. `<task-path>/implement.md` if present — execution plan
|
||||||
|
5. `.trellis/spec/` — project-wide guidelines (load only what is relevant to the diff you are about to write)
|
||||||
|
|
||||||
|
## Core Responsibilities
|
||||||
|
|
||||||
|
1. **Understand specs** — read relevant spec files in `.trellis/spec/`
|
||||||
|
2. **Understand task artifacts** — read the artifacts listed above
|
||||||
|
3. **Implement features** — write code that follows specs and existing patterns
|
||||||
|
4. **Self-check** — run lint and typecheck on the changed scope before reporting
|
||||||
|
|
||||||
|
## Forbidden Operations
|
||||||
|
|
||||||
|
- `git commit`
|
||||||
|
- `git push`
|
||||||
|
- `git merge`
|
||||||
|
|
||||||
|
The supervising main session owns commits. Report what changed; do not commit on its behalf.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Read relevant specs based on task type and the files in `implement.jsonl` if present
|
||||||
|
2. Read the task's `prd.md`, `design.md` if present, and `implement.md` if present
|
||||||
|
3. Implement features following specs and existing patterns
|
||||||
|
4. Run the project's lint and typecheck commands on the changed scope
|
||||||
|
5. Report files touched, key decisions, and verification results back to the channel
|
||||||
|
|
||||||
|
## Code Standards
|
||||||
|
|
||||||
|
- Follow existing code patterns
|
||||||
|
- Don't add unnecessary abstractions
|
||||||
|
- Only do what the PRD asks for; no speculative scope expansion
|
||||||
|
- Surface uncertainty back to the channel rather than guessing
|
||||||
|
|
||||||
|
## Report Format
|
||||||
|
|
||||||
|
```
|
||||||
|
## Implementation Complete
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
- <path> — <one-line description>
|
||||||
|
|
||||||
|
### Implementation Summary
|
||||||
|
1. <step>
|
||||||
|
2. <step>
|
||||||
|
|
||||||
|
### Verification Results
|
||||||
|
- Lint: <pass|fail|skipped + reason>
|
||||||
|
- TypeCheck: <pass|fail|skipped + reason>
|
||||||
|
|
||||||
|
### Open Questions
|
||||||
|
- <if any, otherwise omit>
|
||||||
|
```
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Trellis Configuration
|
||||||
|
# Project-level settings for the Trellis workflow system
|
||||||
|
#
|
||||||
|
# All values have sensible defaults. Only override what you need.
|
||||||
|
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
# Session Recording
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Commit message used when auto-committing journal/index changes
|
||||||
|
# after running add_session.py
|
||||||
|
session_commit_message: "chore: record journal"
|
||||||
|
|
||||||
|
# Maximum lines per journal file before rotating to a new one
|
||||||
|
max_journal_lines: 2000
|
||||||
|
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
# Session Auto-Commit
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Auto-commit behavior for session journal + task archive operations.
|
||||||
|
# - true (default): scripts auto-stage and auto-commit journal / task changes
|
||||||
|
# after add_session.py / task.py archive runs.
|
||||||
|
# - false: scripts do not touch git. Files (journal-*.md, task archive moves)
|
||||||
|
# are still written to disk; you decide whether to git add / commit.
|
||||||
|
#
|
||||||
|
# Use `false` if your project's .gitignore intentionally excludes `.trellis/`
|
||||||
|
# and you want session data kept local-only, or if you prefer to review
|
||||||
|
# staged changes manually before each commit.
|
||||||
|
#
|
||||||
|
# Accepts: true / false / yes / no / 1 / 0 / on / off (case-insensitive).
|
||||||
|
#
|
||||||
|
# session_auto_commit: true
|
||||||
|
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
# Task Lifecycle Hooks
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Shell commands to run after task lifecycle events.
|
||||||
|
# Each hook receives TASK_JSON_PATH environment variable pointing to task.json.
|
||||||
|
# Hook failures print a warning but do not block the main operation.
|
||||||
|
#
|
||||||
|
# hooks:
|
||||||
|
# after_create:
|
||||||
|
# - "echo 'Task created'"
|
||||||
|
# after_start:
|
||||||
|
# - "echo 'Task started'"
|
||||||
|
# after_finish:
|
||||||
|
# - "echo 'Task finished'"
|
||||||
|
# after_archive:
|
||||||
|
# - "echo 'Task archived'"
|
||||||
|
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
# Monorepo / Packages
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Declare packages for monorepo projects.
|
||||||
|
# Trellis auto-detects workspaces during `trellis init`, but you can also
|
||||||
|
# configure them manually here.
|
||||||
|
#
|
||||||
|
# packages:
|
||||||
|
# frontend:
|
||||||
|
# path: packages/frontend
|
||||||
|
# backend:
|
||||||
|
# path: packages/backend
|
||||||
|
# docs:
|
||||||
|
# path: docs-site
|
||||||
|
# type: submodule
|
||||||
|
# # For polyrepo / meta-repo layouts (independent .git in each subdir),
|
||||||
|
# # mark the package with `git: true`. The runtime treats it as an
|
||||||
|
# # independent repository for things like git-context display.
|
||||||
|
# webapp:
|
||||||
|
# path: ./webapp
|
||||||
|
# git: true
|
||||||
|
|
||||||
|
# Default package used when --package is not specified.
|
||||||
|
# default_package: frontend
|
||||||
|
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
# Channel worker OOM guard
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
# Default safeguards for `trellis channel spawn` workers. The guard runs
|
||||||
|
# at spawn time (cleans expired idle workers, then enforces the live-worker
|
||||||
|
# budget) and inside each supervisor (self-terminates a worker that stays
|
||||||
|
# continuously idle past `idle_timeout`).
|
||||||
|
#
|
||||||
|
# Precedence: CLI flag > env var (TRELLIS_CHANNEL_WORKER_IDLE_TIMEOUT /
|
||||||
|
# TRELLIS_CHANNEL_MAX_LIVE_WORKERS) > this config > built-in default.
|
||||||
|
#
|
||||||
|
# `idle_timeout: 0` disables idle cleanup (workers can sit idle forever
|
||||||
|
# unless explicitly killed or given `--timeout`).
|
||||||
|
# `max_live_workers: 0` disables the spawn-time budget check.
|
||||||
|
#
|
||||||
|
channel:
|
||||||
|
worker_guard:
|
||||||
|
idle_timeout: 5m
|
||||||
|
max_live_workers: 6
|
||||||
|
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Auto-detected monorepo packages
|
||||||
|
packages:
|
||||||
|
vendor/ComfyUI_frontend:
|
||||||
|
path: vendor/ComfyUI_frontend
|
||||||
|
type: submodule
|
||||||
|
default_package: vendor/ComfyUI_frontend
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
Trellis Python Scripts
|
||||||
|
|
||||||
|
This module provides Python implementations of Trellis workflow scripts.
|
||||||
|
"""
|
||||||
Executable
+567
@@ -0,0 +1,567 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Add a new session to journal file and update index.md.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 add_session.py --title "Title" --commit "hash" --summary "Summary" [--package cli]
|
||||||
|
python3 add_session.py --title "Title" --branch "feat/my-branch"
|
||||||
|
|
||||||
|
# Pipe detailed content via stdin (use --stdin to opt in):
|
||||||
|
cat << 'EOF' | python3 add_session.py --stdin --title "Title" --summary "Summary"
|
||||||
|
<session content here>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
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 (
|
||||||
|
DIR_TASKS,
|
||||||
|
DIR_WORKFLOW,
|
||||||
|
FILE_JOURNAL_PREFIX,
|
||||||
|
get_repo_root,
|
||||||
|
get_current_task,
|
||||||
|
get_developer,
|
||||||
|
get_workspace_dir,
|
||||||
|
)
|
||||||
|
from common.developer import ensure_developer
|
||||||
|
from common.git import run_git
|
||||||
|
from common.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 + current-task paths and commit.
|
||||||
|
|
||||||
|
Path scope is restricted to specific products: the current developer's
|
||||||
|
journal files + index.md, and ONLY the current task directory (resolved
|
||||||
|
via ``get_current_task``). We never `git add` the whole `.trellis/` tree
|
||||||
|
or iterate over all active task dirs (#303: parallel-window dirty task
|
||||||
|
dirs must not be bundled into the session auto-commit). If `.gitignore`
|
||||||
|
blocks the specific paths we warn + skip — never retry with ``-f``.
|
||||||
|
|
||||||
|
Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when set to
|
||||||
|
``false``, this function returns immediately without touching git
|
||||||
|
(journal/index files are still written to disk by the caller).
|
||||||
|
"""
|
||||||
|
if not get_session_auto_commit(repo_root):
|
||||||
|
print(
|
||||||
|
"[OK] session_auto_commit: false — skipping git stage/commit.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
commit_msg = get_session_commit_message(repo_root)
|
||||||
|
# Resolve the current task so staging is scoped to its dir only. The ref
|
||||||
|
# is ``.trellis/tasks/<name>`` (or under archive/) — pass the bare name.
|
||||||
|
current = get_current_task(repo_root)
|
||||||
|
if current:
|
||||||
|
task_name = Path(current).name
|
||||||
|
paths = safe_trellis_paths_to_add(repo_root, task_name=task_name)
|
||||||
|
else:
|
||||||
|
# Current task unknown (0 or >=2 parallel sessions — exactly the
|
||||||
|
# parallel-window case #303 is about). Do NOT fall back to the wide
|
||||||
|
# `tasks_dir.iterdir()` scan; that would re-leak other tasks' dirty
|
||||||
|
# dirs into the session commit. Stage only the developer's journal/
|
||||||
|
# index and skip every task dir.
|
||||||
|
paths = [
|
||||||
|
p
|
||||||
|
for p in safe_trellis_paths_to_add(repo_root, task_name=None)
|
||||||
|
if not p.startswith(f"{DIR_WORKFLOW}/{DIR_TASKS}/")
|
||||||
|
]
|
||||||
|
if not paths:
|
||||||
|
print("[OK] No workspace changes to commit.", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
success, _, err = safe_git_add(paths, repo_root)
|
||||||
|
if not success:
|
||||||
|
if err and "ignored by" in err.lower():
|
||||||
|
print_gitignore_warning(paths)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"[WARN] git add failed: {err.strip() if err else 'unknown error'}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if there are staged changes for the paths we just staged.
|
||||||
|
rc, _, _ = run_git(
|
||||||
|
["diff", "--cached", "--quiet", "--", *paths], cwd=repo_root
|
||||||
|
)
|
||||||
|
if rc == 0:
|
||||||
|
print("[OK] No workspace changes to commit.", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
rc, _, commit_err = run_git(["commit", "-m", commit_msg], cwd=repo_root)
|
||||||
|
if rc == 0:
|
||||||
|
print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"[WARN] Auto-commit failed: {commit_err.strip()}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def add_session(
|
||||||
|
title: str,
|
||||||
|
commit: str = "-",
|
||||||
|
summary: str = "(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())
|
||||||
Executable
+92
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
Common utilities for Trellis workflow scripts.
|
||||||
|
|
||||||
|
This module provides shared functionality used by other Trellis scripts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Windows Encoding Fix (MUST be at top, before any other output)
|
||||||
|
# =============================================================================
|
||||||
|
# On Windows, stdout defaults to the system code page (often GBK/CP936).
|
||||||
|
# This causes UnicodeEncodeError when printing non-ASCII characters.
|
||||||
|
#
|
||||||
|
# Any script that imports from common will automatically get this fix.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_stream(stream: object) -> object:
|
||||||
|
"""Configure a stream for UTF-8 encoding on Windows."""
|
||||||
|
# Try reconfigure() first (Python 3.7+, more reliable)
|
||||||
|
if hasattr(stream, "reconfigure"):
|
||||||
|
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
||||||
|
return stream
|
||||||
|
# Fallback: detach and rewrap with TextIOWrapper
|
||||||
|
elif hasattr(stream, "detach"):
|
||||||
|
return io.TextIOWrapper(
|
||||||
|
stream.detach(), # type: ignore[union-attr]
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
return stream
|
||||||
|
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment]
|
||||||
|
sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment]
|
||||||
|
sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
|
def configure_encoding() -> None:
|
||||||
|
"""
|
||||||
|
Configure stdout/stderr/stdin for UTF-8 encoding on Windows.
|
||||||
|
|
||||||
|
This is automatically called when importing from common,
|
||||||
|
but can be called manually for scripts that don't import common.
|
||||||
|
|
||||||
|
Safe to call multiple times.
|
||||||
|
"""
|
||||||
|
global sys
|
||||||
|
if sys.platform == "win32":
|
||||||
|
sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment]
|
||||||
|
sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment]
|
||||||
|
sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
|
from .paths import (
|
||||||
|
DIR_WORKFLOW,
|
||||||
|
DIR_WORKSPACE,
|
||||||
|
DIR_TASKS,
|
||||||
|
DIR_ARCHIVE,
|
||||||
|
DIR_SPEC,
|
||||||
|
DIR_SCRIPTS,
|
||||||
|
FILE_DEVELOPER,
|
||||||
|
FILE_CURRENT_TASK,
|
||||||
|
FILE_TASK_JSON,
|
||||||
|
FILE_JOURNAL_PREFIX,
|
||||||
|
get_repo_root,
|
||||||
|
get_developer,
|
||||||
|
check_developer,
|
||||||
|
get_tasks_dir,
|
||||||
|
get_workspace_dir,
|
||||||
|
get_active_journal_file,
|
||||||
|
count_lines,
|
||||||
|
get_current_task,
|
||||||
|
get_current_task_abs,
|
||||||
|
normalize_task_ref,
|
||||||
|
resolve_task_ref,
|
||||||
|
set_current_task,
|
||||||
|
clear_current_task,
|
||||||
|
has_current_task,
|
||||||
|
generate_task_date_prefix,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .active_task import (
|
||||||
|
ActiveTask,
|
||||||
|
clear_active_task,
|
||||||
|
resolve_active_task,
|
||||||
|
resolve_context_key,
|
||||||
|
set_active_task,
|
||||||
|
)
|
||||||
Executable
+626
@@ -0,0 +1,626 @@
|
|||||||
|
#!/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
|
||||||
Executable
+825
@@ -0,0 +1,825 @@
|
|||||||
|
"""
|
||||||
|
CLI Adapter for Multi-Platform Support.
|
||||||
|
|
||||||
|
Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, Devin, Qoder, CodeBuddy, GitHub Copilot, Factory Droid, and Pi Agent interfaces.
|
||||||
|
|
||||||
|
Supported platforms:
|
||||||
|
- claude: Claude Code (default)
|
||||||
|
- opencode: OpenCode
|
||||||
|
- cursor: Cursor IDE
|
||||||
|
- iflow: iFlow CLI
|
||||||
|
- codex: Codex CLI (skills-based)
|
||||||
|
- kilo: Kilo CLI
|
||||||
|
- kiro: Kiro Code (skills-based)
|
||||||
|
- gemini: Gemini CLI
|
||||||
|
- antigravity: Antigravity (workflow-based)
|
||||||
|
- devin: Devin (formerly Windsurf; workflow-based)
|
||||||
|
- qoder: Qoder
|
||||||
|
- codebuddy: CodeBuddy
|
||||||
|
- copilot: GitHub Copilot (VS Code)
|
||||||
|
- droid: Factory Droid (commands-based)
|
||||||
|
- pi: Pi Agent (extension-backed)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from common.cli_adapter import CLIAdapter
|
||||||
|
|
||||||
|
adapter = CLIAdapter("opencode")
|
||||||
|
cmd = adapter.build_run_command(
|
||||||
|
agent="dispatch",
|
||||||
|
session_id="abc123",
|
||||||
|
prompt="Start the pipeline"
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import ClassVar, Literal
|
||||||
|
|
||||||
|
Platform = Literal[
|
||||||
|
"claude",
|
||||||
|
"opencode",
|
||||||
|
"cursor",
|
||||||
|
"iflow",
|
||||||
|
"codex",
|
||||||
|
"kilo",
|
||||||
|
"kiro",
|
||||||
|
"gemini",
|
||||||
|
"antigravity",
|
||||||
|
"devin",
|
||||||
|
"qoder",
|
||||||
|
"codebuddy",
|
||||||
|
"copilot",
|
||||||
|
"droid",
|
||||||
|
"pi",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CLIAdapter:
|
||||||
|
"""Adapter for different AI coding CLI tools."""
|
||||||
|
|
||||||
|
platform: Platform
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Agent Name Mapping
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
# OpenCode has built-in agents that cannot be overridden
|
||||||
|
# See: https://github.com/sst/opencode/issues/4271
|
||||||
|
# Note: Class-level constant, not a dataclass field
|
||||||
|
_AGENT_NAME_MAP: ClassVar[dict[Platform, dict[str, str]]] = {
|
||||||
|
"claude": {}, # No mapping needed
|
||||||
|
"opencode": {
|
||||||
|
"plan": "trellis-plan", # 'plan' is built-in in OpenCode
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_agent_name(self, agent: str) -> str:
|
||||||
|
"""Get platform-specific agent name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent: Original agent name (e.g., 'plan', 'dispatch')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Platform-specific agent name (e.g., 'trellis-plan' for OpenCode)
|
||||||
|
"""
|
||||||
|
mapping = self._AGENT_NAME_MAP.get(self.platform, {})
|
||||||
|
return mapping.get(agent, agent)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Agent Path
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config_dir_name(self) -> str:
|
||||||
|
"""Get platform-specific config directory name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Directory name ('.claude', '.opencode', '.cursor', '.iflow', '.codex', '.kilocode', '.kiro', '.gemini', '.agent', '.devin', '.qoder', '.codebuddy', '.github/copilot', '.factory', 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 == "devin":
|
||||||
|
return ".devin"
|
||||||
|
elif self.platform == "qoder":
|
||||||
|
return ".qoder"
|
||||||
|
elif self.platform == "codebuddy":
|
||||||
|
return ".codebuddy"
|
||||||
|
elif self.platform == "copilot":
|
||||||
|
return ".github/copilot"
|
||||||
|
elif self.platform == "droid":
|
||||||
|
return ".factory"
|
||||||
|
elif self.platform == "pi":
|
||||||
|
return ".pi"
|
||||||
|
else:
|
||||||
|
return ".claude"
|
||||||
|
|
||||||
|
def get_config_dir(self, project_root: Path) -> Path:
|
||||||
|
"""Get platform-specific config directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_root: Project root directory
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to config directory (.claude, .opencode, .cursor, .iflow, .codex, .kilocode, .kiro, .gemini, .agent, .devin, .qoder, .codebuddy, .github/copilot, .factory, 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
|
||||||
|
Devin uses workflow directory: .devin/workflows/trellis-<name>.md
|
||||||
|
Copilot uses prompt files: .github/prompts/<name>.prompt.md
|
||||||
|
Pi uses prompt templates: .pi/prompts/trellis-<name>.md
|
||||||
|
Claude/OpenCode use subdirectory: .claude/commands/trellis/<name>.md
|
||||||
|
"""
|
||||||
|
if self.platform == "pi":
|
||||||
|
prompts_dir = self.get_config_dir(project_root) / "prompts"
|
||||||
|
if not parts:
|
||||||
|
return prompts_dir
|
||||||
|
if len(parts) >= 2 and parts[0] == "trellis":
|
||||||
|
filename = parts[-1]
|
||||||
|
if filename.endswith(".md"):
|
||||||
|
filename = filename[:-3]
|
||||||
|
return prompts_dir / f"trellis-{filename}.md"
|
||||||
|
return prompts_dir / Path(*parts)
|
||||||
|
|
||||||
|
if self.platform == "devin":
|
||||||
|
workflow_dir = self.get_config_dir(project_root) / "workflows"
|
||||||
|
if not parts:
|
||||||
|
return workflow_dir
|
||||||
|
if len(parts) >= 2 and parts[0] == "trellis":
|
||||||
|
filename = parts[-1]
|
||||||
|
return workflow_dir / f"trellis-{filename}"
|
||||||
|
return workflow_dir / Path(*parts)
|
||||||
|
|
||||||
|
if self.platform in ("antigravity", "kilo"):
|
||||||
|
workflow_dir = self.get_config_dir(project_root) / "workflows"
|
||||||
|
if not parts:
|
||||||
|
return workflow_dir
|
||||||
|
if len(parts) >= 2 and parts[0] == "trellis":
|
||||||
|
filename = parts[-1]
|
||||||
|
return workflow_dir / filename
|
||||||
|
return workflow_dir / Path(*parts)
|
||||||
|
|
||||||
|
if self.platform == "copilot":
|
||||||
|
prompts_dir = project_root / ".github" / "prompts"
|
||||||
|
if not parts:
|
||||||
|
return prompts_dir
|
||||||
|
if len(parts) >= 2 and parts[0] == "trellis":
|
||||||
|
filename = parts[-1]
|
||||||
|
if filename.endswith(".md"):
|
||||||
|
filename = filename[:-3]
|
||||||
|
return prompts_dir / f"{filename}.prompt.md"
|
||||||
|
return prompts_dir / Path(*parts)
|
||||||
|
|
||||||
|
if not parts:
|
||||||
|
return self.get_config_dir(project_root) / "commands"
|
||||||
|
|
||||||
|
# Cursor uses prefix naming instead of subdirectory
|
||||||
|
if self.platform == "cursor" and len(parts) >= 2 and parts[0] == "trellis":
|
||||||
|
# Convert trellis/<name>.md to trellis-<name>.md
|
||||||
|
filename = parts[-1]
|
||||||
|
return (
|
||||||
|
self.get_config_dir(project_root) / "commands" / f"trellis-{filename}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.get_config_dir(project_root) / "commands" / Path(*parts)
|
||||||
|
|
||||||
|
def get_trellis_command_path(self, name: str) -> str:
|
||||||
|
"""Get relative path to a trellis command file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Command name without extension (e.g., 'finish-work', 'check')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Relative path string for use in JSONL entries
|
||||||
|
|
||||||
|
Note:
|
||||||
|
Cursor: .cursor/commands/trellis-<name>.md
|
||||||
|
Codex: .agents/skills/trellis-<name>/SKILL.md
|
||||||
|
Kiro: .kiro/skills/trellis-<name>/SKILL.md
|
||||||
|
Gemini: .gemini/commands/trellis/<name>.toml
|
||||||
|
Antigravity: .agent/workflows/<name>.md
|
||||||
|
Devin: .devin/workflows/trellis-<name>.md
|
||||||
|
Pi: .pi/prompts/trellis-<name>.md
|
||||||
|
Others: .{platform}/commands/trellis/<name>.md
|
||||||
|
"""
|
||||||
|
if self.platform == "cursor":
|
||||||
|
return f".cursor/commands/trellis-{name}.md"
|
||||||
|
elif self.platform == "codex":
|
||||||
|
# 0.5.0-beta.0 renamed all skill dirs to add the `trellis-` prefix
|
||||||
|
# (see that release's manifest for the 60+ rename entries).
|
||||||
|
return f".agents/skills/trellis-{name}/SKILL.md"
|
||||||
|
elif self.platform == "kiro":
|
||||||
|
return f".kiro/skills/trellis-{name}/SKILL.md"
|
||||||
|
elif self.platform == "gemini":
|
||||||
|
return f".gemini/commands/trellis/{name}.toml"
|
||||||
|
elif self.platform == "antigravity":
|
||||||
|
return f".agent/workflows/{name}.md"
|
||||||
|
elif self.platform == "devin":
|
||||||
|
return f".devin/workflows/trellis-{name}.md"
|
||||||
|
elif self.platform == "kilo":
|
||||||
|
return f".kilocode/workflows/{name}.md"
|
||||||
|
elif self.platform == "copilot":
|
||||||
|
return f".github/prompts/{name}.prompt.md"
|
||||||
|
elif self.platform == "droid":
|
||||||
|
return f".factory/commands/trellis/{name}.md"
|
||||||
|
elif self.platform == "pi":
|
||||||
|
return f".pi/prompts/trellis-{name}.md"
|
||||||
|
else:
|
||||||
|
return f"{self.config_dir_name}/commands/trellis/{name}.md"
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Environment Variables
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def get_non_interactive_env(self) -> dict[str, str]:
|
||||||
|
"""Get environment variables for non-interactive mode.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict of environment variables to set
|
||||||
|
"""
|
||||||
|
if self.platform == "opencode":
|
||||||
|
return {"OPENCODE_NON_INTERACTIVE": "1"}
|
||||||
|
elif self.platform == "iflow":
|
||||||
|
return {"IFLOW_NON_INTERACTIVE": "1"}
|
||||||
|
elif self.platform == "codex":
|
||||||
|
return {"CODEX_NON_INTERACTIVE": "1"}
|
||||||
|
elif self.platform == "kiro":
|
||||||
|
return {"KIRO_NON_INTERACTIVE": "1"}
|
||||||
|
elif self.platform == "gemini":
|
||||||
|
return {} # Gemini CLI doesn't have a non-interactive env var
|
||||||
|
elif self.platform == "antigravity":
|
||||||
|
return {}
|
||||||
|
elif self.platform == "devin":
|
||||||
|
return {}
|
||||||
|
elif self.platform == "qoder":
|
||||||
|
return {}
|
||||||
|
elif self.platform == "codebuddy":
|
||||||
|
return {}
|
||||||
|
elif self.platform == "copilot":
|
||||||
|
return {}
|
||||||
|
elif self.platform == "droid":
|
||||||
|
return {}
|
||||||
|
elif self.platform == "pi":
|
||||||
|
return {}
|
||||||
|
else:
|
||||||
|
return {"CLAUDE_NON_INTERACTIVE": "1"}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# CLI Command Building
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def build_run_command(
|
||||||
|
self,
|
||||||
|
agent: str,
|
||||||
|
prompt: str,
|
||||||
|
session_id: str | None = None,
|
||||||
|
skip_permissions: bool = True,
|
||||||
|
verbose: bool = True,
|
||||||
|
json_output: bool = True,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build CLI command for running an agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent: Agent name (will be mapped if needed)
|
||||||
|
prompt: Prompt to send to the agent
|
||||||
|
session_id: Optional session ID (Claude Code only for creation)
|
||||||
|
skip_permissions: Whether to skip permission prompts
|
||||||
|
verbose: Whether to enable verbose output
|
||||||
|
json_output: Whether to use JSON output format
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of command arguments
|
||||||
|
"""
|
||||||
|
mapped_agent = self.get_agent_name(agent)
|
||||||
|
|
||||||
|
if self.platform == "opencode":
|
||||||
|
cmd = ["opencode", "run"]
|
||||||
|
cmd.extend(["--agent", mapped_agent])
|
||||||
|
|
||||||
|
# Note: OpenCode 'run' mode is non-interactive by default
|
||||||
|
# No equivalent to Claude Code's --dangerously-skip-permissions
|
||||||
|
# See: https://github.com/anomalyco/opencode/issues/9070
|
||||||
|
|
||||||
|
if json_output:
|
||||||
|
cmd.extend(["--format", "json"])
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
cmd.extend(["--log-level", "DEBUG", "--print-logs"])
|
||||||
|
|
||||||
|
# Note: OpenCode doesn't support --session-id on creation
|
||||||
|
# Session ID must be extracted from logs after startup
|
||||||
|
|
||||||
|
cmd.append(prompt)
|
||||||
|
|
||||||
|
elif self.platform == "iflow":
|
||||||
|
cmd = ["iflow", "-y", "-p"]
|
||||||
|
cmd.append(f"${mapped_agent} {prompt}")
|
||||||
|
elif self.platform == "codex":
|
||||||
|
cmd = ["codex", "exec"]
|
||||||
|
cmd.append(prompt)
|
||||||
|
elif self.platform == "kiro":
|
||||||
|
cmd = ["kiro", "run", prompt]
|
||||||
|
elif self.platform == "gemini":
|
||||||
|
cmd = ["gemini"]
|
||||||
|
cmd.append(prompt)
|
||||||
|
elif self.platform == "antigravity":
|
||||||
|
raise ValueError(
|
||||||
|
"Antigravity workflows are UI slash commands; CLI agent run is not supported."
|
||||||
|
)
|
||||||
|
elif self.platform == "devin":
|
||||||
|
raise ValueError(
|
||||||
|
"Devin workflows are UI slash commands; CLI agent run is not supported."
|
||||||
|
)
|
||||||
|
elif self.platform == "qoder":
|
||||||
|
cmd = ["qodercli", "-p", prompt]
|
||||||
|
elif self.platform == "codebuddy":
|
||||||
|
raise ValueError(
|
||||||
|
"CodeBuddy does not support non-interactive mode (no CLI agent)"
|
||||||
|
)
|
||||||
|
elif self.platform == "copilot":
|
||||||
|
raise ValueError(
|
||||||
|
"GitHub Copilot is IDE-only; CLI agent run is not supported."
|
||||||
|
)
|
||||||
|
elif self.platform == "droid":
|
||||||
|
raise ValueError(
|
||||||
|
"Factory Droid CLI agent run is not yet supported."
|
||||||
|
)
|
||||||
|
elif self.platform == "pi":
|
||||||
|
cmd = ["pi", "-p", prompt]
|
||||||
|
|
||||||
|
else: # claude
|
||||||
|
cmd = ["claude", "-p"]
|
||||||
|
cmd.extend(["--agent", mapped_agent])
|
||||||
|
|
||||||
|
if session_id:
|
||||||
|
cmd.extend(["--session-id", session_id])
|
||||||
|
|
||||||
|
if skip_permissions:
|
||||||
|
cmd.append("--dangerously-skip-permissions")
|
||||||
|
|
||||||
|
if json_output:
|
||||||
|
cmd.extend(["--output-format", "stream-json"])
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
cmd.append("--verbose")
|
||||||
|
|
||||||
|
cmd.append(prompt)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
def build_resume_command(self, session_id: str) -> list[str]:
|
||||||
|
"""Build CLI command for resuming a session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID to resume (ignored for iFlow)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of command arguments
|
||||||
|
"""
|
||||||
|
if self.platform == "opencode":
|
||||||
|
return ["opencode", "run", "--session", session_id]
|
||||||
|
elif self.platform == "iflow":
|
||||||
|
# iFlow uses -c to continue most recent conversation
|
||||||
|
# session_id is ignored as iFlow doesn't support session IDs
|
||||||
|
return ["iflow", "-c"]
|
||||||
|
elif self.platform == "codex":
|
||||||
|
return ["codex", "resume", session_id]
|
||||||
|
elif self.platform == "kiro":
|
||||||
|
return ["kiro", "resume", session_id]
|
||||||
|
elif self.platform == "gemini":
|
||||||
|
return ["gemini", "--resume", session_id]
|
||||||
|
elif self.platform == "antigravity":
|
||||||
|
raise ValueError(
|
||||||
|
"Antigravity workflows are UI slash commands; CLI resume is not supported."
|
||||||
|
)
|
||||||
|
elif self.platform == "devin":
|
||||||
|
raise ValueError(
|
||||||
|
"Devin workflows are UI slash commands; CLI resume is not supported."
|
||||||
|
)
|
||||||
|
elif self.platform == "qoder":
|
||||||
|
return ["qodercli", "--resume", session_id]
|
||||||
|
elif self.platform == "codebuddy":
|
||||||
|
raise ValueError(
|
||||||
|
"CodeBuddy does not support non-interactive mode (no CLI agent)"
|
||||||
|
)
|
||||||
|
elif self.platform == "copilot":
|
||||||
|
raise ValueError(
|
||||||
|
"GitHub Copilot is IDE-only; CLI resume is not supported."
|
||||||
|
)
|
||||||
|
elif self.platform == "droid":
|
||||||
|
raise ValueError(
|
||||||
|
"Factory Droid CLI resume is not yet supported."
|
||||||
|
)
|
||||||
|
elif self.platform == "pi":
|
||||||
|
return ["pi", "-c", session_id]
|
||||||
|
else:
|
||||||
|
return ["claude", "--resume", session_id]
|
||||||
|
|
||||||
|
def get_resume_command_str(self, session_id: str, cwd: str | None = None) -> str:
|
||||||
|
"""Get human-readable resume command string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID to resume
|
||||||
|
cwd: Optional working directory to cd into
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Command string for display
|
||||||
|
"""
|
||||||
|
cmd = self.build_resume_command(session_id)
|
||||||
|
cmd_str = " ".join(cmd)
|
||||||
|
|
||||||
|
if cwd:
|
||||||
|
return f"cd {cwd} && {cmd_str}"
|
||||||
|
return cmd_str
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Platform Detection Helpers
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_opencode(self) -> bool:
|
||||||
|
"""Check if platform is OpenCode."""
|
||||||
|
return self.platform == "opencode"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_claude(self) -> bool:
|
||||||
|
"""Check if platform is Claude Code."""
|
||||||
|
return self.platform == "claude"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_cursor(self) -> bool:
|
||||||
|
"""Check if platform is Cursor."""
|
||||||
|
return self.platform == "cursor"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_iflow(self) -> bool:
|
||||||
|
"""Check if platform is iFlow CLI."""
|
||||||
|
return self.platform == "iflow"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cli_name(self) -> str:
|
||||||
|
"""Get CLI executable name.
|
||||||
|
|
||||||
|
Note: Cursor doesn't have a CLI tool, returns None-like value.
|
||||||
|
"""
|
||||||
|
if self.is_opencode:
|
||||||
|
return "opencode"
|
||||||
|
elif self.is_cursor:
|
||||||
|
return "cursor" # Note: Cursor is IDE-only, no CLI
|
||||||
|
elif self.platform == "iflow":
|
||||||
|
return "iflow"
|
||||||
|
elif self.platform == "kiro":
|
||||||
|
return "kiro"
|
||||||
|
elif self.platform == "gemini":
|
||||||
|
return "gemini"
|
||||||
|
elif self.platform == "antigravity":
|
||||||
|
return "agy"
|
||||||
|
elif self.platform == "devin":
|
||||||
|
return "devin"
|
||||||
|
elif self.platform == "qoder":
|
||||||
|
return "qodercli"
|
||||||
|
elif self.platform == "codebuddy":
|
||||||
|
return "codebuddy"
|
||||||
|
elif self.platform == "copilot":
|
||||||
|
return "copilot"
|
||||||
|
elif self.platform == "droid":
|
||||||
|
return "droid"
|
||||||
|
elif self.platform == "pi":
|
||||||
|
return "pi"
|
||||||
|
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', 'devin', 'qoder', 'codebuddy', 'copilot', 'droid', or 'pi')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CLIAdapter instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If platform is not supported
|
||||||
|
|
||||||
|
Note:
|
||||||
|
'windsurf' is accepted as a deprecated alias for 'devin' (Windsurf was
|
||||||
|
renamed to Devin) and normalized before validation.
|
||||||
|
"""
|
||||||
|
# Deprecated alias: Windsurf was renamed to Devin.
|
||||||
|
if platform == "windsurf":
|
||||||
|
platform = "devin"
|
||||||
|
if platform not in (
|
||||||
|
"claude",
|
||||||
|
"opencode",
|
||||||
|
"cursor",
|
||||||
|
"iflow",
|
||||||
|
"codex",
|
||||||
|
"kilo",
|
||||||
|
"kiro",
|
||||||
|
"gemini",
|
||||||
|
"antigravity",
|
||||||
|
"devin",
|
||||||
|
"qoder",
|
||||||
|
"codebuddy",
|
||||||
|
"copilot",
|
||||||
|
"droid",
|
||||||
|
"pi",
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'devin', '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",
|
||||||
|
".devin",
|
||||||
|
".windsurf", # deprecated: pre-rename Devin config dir (still a platform signal)
|
||||||
|
".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, Devin, or other platforms."""
|
||||||
|
|
||||||
|
|
||||||
|
def _has_other_platform_dir(project_root: Path, exclude: set[str]) -> bool:
|
||||||
|
"""Check if any platform config dir exists besides those in *exclude*."""
|
||||||
|
return any(
|
||||||
|
(project_root / d).is_dir()
|
||||||
|
for d in _ALL_PLATFORM_CONFIG_DIRS
|
||||||
|
if d not in exclude
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_platform(project_root: Path) -> Platform:
|
||||||
|
"""Auto-detect platform based on existing config directories.
|
||||||
|
|
||||||
|
Detection order:
|
||||||
|
1. TRELLIS_PLATFORM environment variable (if set)
|
||||||
|
2. .opencode directory exists → opencode
|
||||||
|
3. .iflow directory exists → iflow
|
||||||
|
4. .cursor directory exists (without .claude) → cursor
|
||||||
|
5. .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. .devin/workflows (or legacy .windsurf/workflows) exists and no other platform dirs → devin
|
||||||
|
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', 'devin', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', or default 'claude')
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Check environment variable first
|
||||||
|
env_platform = os.environ.get("TRELLIS_PLATFORM", "").lower()
|
||||||
|
# Deprecated alias: Windsurf was renamed to Devin.
|
||||||
|
if env_platform == "windsurf":
|
||||||
|
env_platform = "devin"
|
||||||
|
if env_platform in (
|
||||||
|
"claude",
|
||||||
|
"opencode",
|
||||||
|
"cursor",
|
||||||
|
"iflow",
|
||||||
|
"codex",
|
||||||
|
"kilo",
|
||||||
|
"kiro",
|
||||||
|
"gemini",
|
||||||
|
"antigravity",
|
||||||
|
"devin",
|
||||||
|
"qoder",
|
||||||
|
"codebuddy",
|
||||||
|
"copilot",
|
||||||
|
"droid",
|
||||||
|
"pi",
|
||||||
|
):
|
||||||
|
return env_platform # type: ignore
|
||||||
|
|
||||||
|
# Check for .opencode directory (OpenCode-specific)
|
||||||
|
if (project_root / ".opencode").is_dir():
|
||||||
|
return "opencode"
|
||||||
|
|
||||||
|
# Check for .iflow directory (iFlow-specific)
|
||||||
|
if (project_root / ".iflow").is_dir():
|
||||||
|
return "iflow"
|
||||||
|
|
||||||
|
# Check for .cursor directory (Cursor-specific)
|
||||||
|
# Only detect as cursor if .claude doesn't exist (to avoid confusion)
|
||||||
|
if (project_root / ".cursor").is_dir() and not (project_root / ".claude").is_dir():
|
||||||
|
return "cursor"
|
||||||
|
|
||||||
|
# Check for .gemini directory (Gemini CLI-specific)
|
||||||
|
if (project_root / ".gemini").is_dir():
|
||||||
|
return "gemini"
|
||||||
|
|
||||||
|
# Check for .codex directory (Codex-specific)
|
||||||
|
# .agents/skills/ alone does NOT trigger codex detection (it's a shared standard)
|
||||||
|
if (project_root / ".codex").is_dir() and not _has_other_platform_dir(
|
||||||
|
project_root, {".codex", ".agents"}
|
||||||
|
):
|
||||||
|
return "codex"
|
||||||
|
|
||||||
|
# Check for .kilocode directory (Kilo-specific)
|
||||||
|
if (project_root / ".kilocode").is_dir():
|
||||||
|
return "kilo"
|
||||||
|
|
||||||
|
# Check for Kiro skills directory only when no other platform config exists
|
||||||
|
if (project_root / ".kiro" / "skills").is_dir() and not _has_other_platform_dir(
|
||||||
|
project_root, {".kiro"}
|
||||||
|
):
|
||||||
|
return "kiro"
|
||||||
|
|
||||||
|
# Check for Antigravity workflow directory only when no other platform config exists
|
||||||
|
if (
|
||||||
|
project_root / ".agent" / "workflows"
|
||||||
|
).is_dir() and not _has_other_platform_dir(
|
||||||
|
project_root, {".agent", ".gemini"}
|
||||||
|
):
|
||||||
|
return "antigravity"
|
||||||
|
|
||||||
|
# Check for Devin workflow directory only when no other platform config
|
||||||
|
# exists. `.windsurf/workflows` is the legacy pre-rename path (still detected
|
||||||
|
# as devin for back-compat until users migrate via `trellis update --migrate`).
|
||||||
|
if (
|
||||||
|
(project_root / ".devin" / "workflows").is_dir()
|
||||||
|
or (project_root / ".windsurf" / "workflows").is_dir()
|
||||||
|
) and not _has_other_platform_dir(
|
||||||
|
project_root, {".devin", ".windsurf"}
|
||||||
|
):
|
||||||
|
return "devin"
|
||||||
|
|
||||||
|
# Check for .codebuddy directory (CodeBuddy-specific)
|
||||||
|
if (project_root / ".codebuddy").is_dir():
|
||||||
|
return "codebuddy"
|
||||||
|
|
||||||
|
# Check for .qoder directory (Qoder-specific)
|
||||||
|
if (project_root / ".qoder").is_dir():
|
||||||
|
return "qoder"
|
||||||
|
|
||||||
|
# Check for .github/copilot directory (GitHub Copilot-specific)
|
||||||
|
if (project_root / ".github" / "copilot").is_dir():
|
||||||
|
return "copilot"
|
||||||
|
|
||||||
|
# Check for .factory directory (Factory Droid-specific)
|
||||||
|
if (project_root / ".factory").is_dir():
|
||||||
|
return "droid"
|
||||||
|
|
||||||
|
# Check for .pi directory (Pi Agent-specific)
|
||||||
|
if (project_root / ".pi").is_dir():
|
||||||
|
return "pi"
|
||||||
|
|
||||||
|
# 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)
|
||||||
Executable
+445
@@ -0,0 +1,445 @@
|
|||||||
|
#!/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
|
||||||
Executable
+190
@@ -0,0 +1,190 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Developer management utilities.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
init_developer - Initialize developer
|
||||||
|
ensure_developer - Ensure developer is initialized (exit if not)
|
||||||
|
show_developer_info - Show developer information
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .paths import (
|
||||||
|
DIR_WORKFLOW,
|
||||||
|
DIR_WORKSPACE,
|
||||||
|
DIR_TASKS,
|
||||||
|
FILE_DEVELOPER,
|
||||||
|
FILE_JOURNAL_PREFIX,
|
||||||
|
get_repo_root,
|
||||||
|
get_developer,
|
||||||
|
check_developer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Developer Initialization
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def init_developer(name: str, repo_root: Path | None = None) -> bool:
|
||||||
|
"""Initialize developer.
|
||||||
|
|
||||||
|
Creates:
|
||||||
|
- .trellis/.developer file with developer info
|
||||||
|
- .trellis/workspace/<name>/ directory structure
|
||||||
|
- Initial journal file and index.md
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Developer name.
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True on success, False on error.
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
print("Error: developer name is required", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
|
||||||
|
workspace_dir = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / name
|
||||||
|
|
||||||
|
# Create .developer file
|
||||||
|
initialized_at = datetime.now().isoformat()
|
||||||
|
try:
|
||||||
|
dev_file.write_text(
|
||||||
|
f"name={name}\ninitialized_at={initialized_at}\n",
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
except (OSError, IOError) as e:
|
||||||
|
print(f"Error: Failed to create .developer file: {e}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Create workspace directory structure
|
||||||
|
try:
|
||||||
|
workspace_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
except (OSError, IOError) as e:
|
||||||
|
print(f"Error: Failed to create workspace directory: {e}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Create initial journal file
|
||||||
|
journal_file = workspace_dir / f"{FILE_JOURNAL_PREFIX}1.md"
|
||||||
|
if not journal_file.exists():
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
journal_content = f"""# Journal - {name} (Part 1)
|
||||||
|
|
||||||
|
> AI development session journal
|
||||||
|
> Started: {today}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
journal_file.write_text(journal_content, encoding="utf-8")
|
||||||
|
except (OSError, IOError) as e:
|
||||||
|
print(f"Error: Failed to create journal file: {e}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Create index.md with markers for auto-update
|
||||||
|
index_file = workspace_dir / "index.md"
|
||||||
|
if not index_file.exists():
|
||||||
|
index_content = f"""# Workspace Index - {name}
|
||||||
|
|
||||||
|
> Journal tracking for AI development sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
<!-- @@@auto:current-status -->
|
||||||
|
- **Active File**: `journal-1.md`
|
||||||
|
- **Total Sessions**: 0
|
||||||
|
- **Last Active**: -
|
||||||
|
<!-- @@@/auto:current-status -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Active Documents
|
||||||
|
|
||||||
|
<!-- @@@auto:active-documents -->
|
||||||
|
| File | Lines | Status |
|
||||||
|
|------|-------|--------|
|
||||||
|
| `journal-1.md` | ~0 | Active |
|
||||||
|
<!-- @@@/auto:active-documents -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session History
|
||||||
|
|
||||||
|
<!-- @@@auto:session-history -->
|
||||||
|
| # | Date | Title | Commits | Branch |
|
||||||
|
|---|------|-------|---------|--------|
|
||||||
|
<!-- @@@/auto:session-history -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Sessions are appended to journal files
|
||||||
|
- New journal file created when current exceeds 2000 lines
|
||||||
|
- Use `add_session.py` to record sessions
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
index_file.write_text(index_content, encoding="utf-8")
|
||||||
|
except (OSError, IOError) as e:
|
||||||
|
print(f"Error: Failed to create index.md: {e}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"Developer initialized: {name}")
|
||||||
|
print(f" .developer file: {dev_file}")
|
||||||
|
print(f" Workspace dir: {workspace_dir}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_developer(repo_root: Path | None = None) -> None:
|
||||||
|
"""Ensure developer is initialized, exit if not.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
if not check_developer(repo_root):
|
||||||
|
print("Error: Developer not initialized.", file=sys.stderr)
|
||||||
|
print(f"Run: python3 ./{DIR_WORKFLOW}/scripts/init_developer.py <your-name>", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def show_developer_info(repo_root: Path | None = None) -> None:
|
||||||
|
"""Show developer information.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
developer = get_developer(repo_root)
|
||||||
|
|
||||||
|
if not developer:
|
||||||
|
print("Developer: (not initialized)")
|
||||||
|
else:
|
||||||
|
print(f"Developer: {developer}")
|
||||||
|
print(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/")
|
||||||
|
print(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Main Entry (for testing)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
show_developer_info()
|
||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
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)
|
||||||
Executable
+106
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Git and Session Context utilities.
|
||||||
|
|
||||||
|
Entry shim — delegates to session_context and packages_context.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
output_json - Output context in JSON format
|
||||||
|
output_text - Output context in text format
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from .git import run_git
|
||||||
|
from .session_context import (
|
||||||
|
get_context_json,
|
||||||
|
get_context_text,
|
||||||
|
get_context_record_json,
|
||||||
|
get_context_text_record,
|
||||||
|
output_json,
|
||||||
|
output_text,
|
||||||
|
)
|
||||||
|
from .packages_context import (
|
||||||
|
get_context_packages_text,
|
||||||
|
get_context_packages_json,
|
||||||
|
)
|
||||||
|
from .trellis_config import read_trellis_config
|
||||||
|
from .workflow_phase import (
|
||||||
|
filter_platform,
|
||||||
|
get_phase_index,
|
||||||
|
get_step,
|
||||||
|
resolve_effective_platform,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backward-compatible alias — external modules import this name
|
||||||
|
_run_git_command = run_git
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Main Entry
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""CLI entry point."""
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Get Session Context for AI Agent")
|
||||||
|
parser.add_argument(
|
||||||
|
"--json",
|
||||||
|
"-j",
|
||||||
|
action="store_true",
|
||||||
|
help="Output in JSON format (works with any --mode)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mode",
|
||||||
|
"-m",
|
||||||
|
choices=["default", "record", "packages", "phase"],
|
||||||
|
default="default",
|
||||||
|
help="Output mode: default (full context), record (for record-session), packages (package info only), phase (workflow step extraction)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--step",
|
||||||
|
help="Step id for --mode phase, e.g. 1.1, 2.2. Omit to get the Phase Index.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--platform",
|
||||||
|
help="Platform name for --mode phase, e.g. cursor, claude-code. Filters platform-tagged blocks.",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.mode == "record":
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(get_context_record_json(), indent=2, ensure_ascii=False))
|
||||||
|
else:
|
||||||
|
print(get_context_text_record())
|
||||||
|
elif args.mode == "packages":
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(get_context_packages_json(), indent=2, ensure_ascii=False))
|
||||||
|
else:
|
||||||
|
print(get_context_packages_text())
|
||||||
|
elif args.mode == "phase":
|
||||||
|
content = get_step(args.step) if args.step else get_phase_index()
|
||||||
|
if not content.strip():
|
||||||
|
if args.step:
|
||||||
|
parser.exit(2, f"Step not found: {args.step}\n")
|
||||||
|
else:
|
||||||
|
parser.exit(2, "Phase Index section not found in workflow.md\n")
|
||||||
|
if args.platform:
|
||||||
|
effective = resolve_effective_platform(
|
||||||
|
args.platform, read_trellis_config()
|
||||||
|
)
|
||||||
|
content = filter_platform(content, effective)
|
||||||
|
print(content, end="")
|
||||||
|
else:
|
||||||
|
if args.json:
|
||||||
|
output_json()
|
||||||
|
else:
|
||||||
|
output_text()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+37
@@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
Executable
+45
@@ -0,0 +1,45 @@
|
|||||||
|
"""
|
||||||
|
Terminal output utilities: colors and structured logging.
|
||||||
|
|
||||||
|
Single source of truth for Colors and log_* functions
|
||||||
|
used across all Trellis scripts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class Colors:
|
||||||
|
"""ANSI color codes for terminal output."""
|
||||||
|
|
||||||
|
RED = "\033[0;31m"
|
||||||
|
GREEN = "\033[0;32m"
|
||||||
|
YELLOW = "\033[1;33m"
|
||||||
|
BLUE = "\033[0;34m"
|
||||||
|
CYAN = "\033[0;36m"
|
||||||
|
DIM = "\033[2m"
|
||||||
|
NC = "\033[0m" # No Color / Reset
|
||||||
|
|
||||||
|
|
||||||
|
def colored(text: str, color: str) -> str:
|
||||||
|
"""Apply ANSI color to text."""
|
||||||
|
return f"{color}{text}{Colors.NC}"
|
||||||
|
|
||||||
|
|
||||||
|
def log_info(msg: str) -> None:
|
||||||
|
"""Print info-level message with [INFO] prefix."""
|
||||||
|
print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def log_success(msg: str) -> None:
|
||||||
|
"""Print success message with [SUCCESS] prefix."""
|
||||||
|
print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def log_warn(msg: str) -> None:
|
||||||
|
"""Print warning message with [WARN] prefix."""
|
||||||
|
print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def log_error(msg: str) -> None:
|
||||||
|
"""Print error message with [ERROR] prefix."""
|
||||||
|
print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}")
|
||||||
Executable
+238
@@ -0,0 +1,238 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Package discovery and context output.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
get_packages_info - Get structured package info
|
||||||
|
get_packages_section - Build PACKAGES text section
|
||||||
|
get_context_packages_text - Full packages text output (--mode packages)
|
||||||
|
get_context_packages_json - Full packages JSON output (--mode packages --json)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .config import _is_true_config_value, get_default_package, get_packages, get_spec_scope
|
||||||
|
from .paths import (
|
||||||
|
DIR_SPEC,
|
||||||
|
DIR_WORKFLOW,
|
||||||
|
get_current_task,
|
||||||
|
get_repo_root,
|
||||||
|
)
|
||||||
|
from .tasks import load_task
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Internal Helpers
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _scan_spec_layers(spec_dir: Path, package: str | None = None) -> list[str]:
|
||||||
|
"""Scan spec directory for available layers (subdirectories).
|
||||||
|
|
||||||
|
For monorepo: scans spec/<package>/
|
||||||
|
For single-repo: scans spec/
|
||||||
|
"""
|
||||||
|
target = spec_dir / package if package else spec_dir
|
||||||
|
if not target.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(
|
||||||
|
d.name for d in target.iterdir() if d.is_dir() and d.name != "guides"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_active_task_package(repo_root: Path) -> str | None:
|
||||||
|
"""Get the package field from the active task's task.json."""
|
||||||
|
current = get_current_task(repo_root)
|
||||||
|
if not current:
|
||||||
|
return None
|
||||||
|
ct = load_task(repo_root / current)
|
||||||
|
return ct.package if ct and ct.package else None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_scope_set(
|
||||||
|
packages: dict,
|
||||||
|
spec_scope,
|
||||||
|
task_pkg: str | None,
|
||||||
|
default_pkg: str | None,
|
||||||
|
) -> set | None:
|
||||||
|
"""Resolve spec_scope to a set of allowed package names, or None for full scan."""
|
||||||
|
if not packages:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if spec_scope is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(spec_scope, str) and spec_scope == "active_task":
|
||||||
|
if task_pkg and task_pkg in packages:
|
||||||
|
return {task_pkg}
|
||||||
|
if default_pkg and default_pkg in packages:
|
||||||
|
return {default_pkg}
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(spec_scope, list):
|
||||||
|
valid = {e for e in spec_scope if e in packages}
|
||||||
|
if valid:
|
||||||
|
return valid
|
||||||
|
# All invalid: fallback
|
||||||
|
if task_pkg and task_pkg in packages:
|
||||||
|
return {task_pkg}
|
||||||
|
if default_pkg and default_pkg in packages:
|
||||||
|
return {default_pkg}
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Public Functions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_packages_info(repo_root: Path) -> list[dict]:
|
||||||
|
"""Get structured package info for monorepo projects.
|
||||||
|
|
||||||
|
Returns list of dicts with keys: name, path, type, default, specLayers,
|
||||||
|
isSubmodule, isGitRepo.
|
||||||
|
Returns empty list for single-repo projects.
|
||||||
|
"""
|
||||||
|
packages = get_packages(repo_root)
|
||||||
|
if not packages:
|
||||||
|
return []
|
||||||
|
|
||||||
|
default_pkg = get_default_package(repo_root)
|
||||||
|
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for pkg_name, pkg_config in packages.items():
|
||||||
|
pkg_path = pkg_config.get("path", pkg_name) if isinstance(pkg_config, dict) else str(pkg_config)
|
||||||
|
pkg_type = pkg_config.get("type", "local") if isinstance(pkg_config, dict) else "local"
|
||||||
|
pkg_git = pkg_config.get("git", False) if isinstance(pkg_config, dict) else False
|
||||||
|
layers = _scan_spec_layers(spec_dir, pkg_name)
|
||||||
|
|
||||||
|
result.append({
|
||||||
|
"name": pkg_name,
|
||||||
|
"path": pkg_path,
|
||||||
|
"type": pkg_type,
|
||||||
|
"default": pkg_name == default_pkg,
|
||||||
|
"specLayers": layers,
|
||||||
|
"isSubmodule": pkg_type == "submodule",
|
||||||
|
"isGitRepo": _is_true_config_value(pkg_git),
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_packages_section(repo_root: Path) -> str:
|
||||||
|
"""Build the PACKAGES section for text output."""
|
||||||
|
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||||
|
pkg_info = get_packages_info(repo_root)
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append("## PACKAGES")
|
||||||
|
|
||||||
|
if not pkg_info:
|
||||||
|
lines.append("(single-repo mode)")
|
||||||
|
layers = _scan_spec_layers(spec_dir)
|
||||||
|
if layers:
|
||||||
|
lines.append(f"Spec layers: {', '.join(layers)}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
default_pkg = get_default_package(repo_root)
|
||||||
|
|
||||||
|
for pkg in pkg_info:
|
||||||
|
layers_str = f" [{', '.join(pkg['specLayers'])}]" if pkg["specLayers"] else ""
|
||||||
|
submodule_tag = " (submodule)" if pkg["isSubmodule"] else ""
|
||||||
|
git_repo_tag = " (git repo)" if pkg["isGitRepo"] else ""
|
||||||
|
default_tag = " *" if pkg["default"] else ""
|
||||||
|
lines.append(
|
||||||
|
f"- {pkg['name']:<16} {pkg['path']:<20}{layers_str}{submodule_tag}{git_repo_tag}{default_tag}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if default_pkg:
|
||||||
|
lines.append(f"Default package: {default_pkg}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def get_context_packages_text(repo_root: Path | None = None) -> str:
|
||||||
|
"""Get packages context as formatted text (for --mode packages)."""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
pkg_info = get_packages_info(repo_root)
|
||||||
|
lines: list[str] = []
|
||||||
|
|
||||||
|
if not pkg_info:
|
||||||
|
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||||
|
lines.append("Single-repo project (no packages configured)")
|
||||||
|
lines.append("")
|
||||||
|
layers = _scan_spec_layers(spec_dir)
|
||||||
|
if layers:
|
||||||
|
lines.append(f"Spec layers: {', '.join(layers)}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
# Resolve scope for annotations
|
||||||
|
packages_dict = get_packages(repo_root) or {}
|
||||||
|
default_pkg = get_default_package(repo_root)
|
||||||
|
spec_scope = get_spec_scope(repo_root)
|
||||||
|
task_pkg = _get_active_task_package(repo_root)
|
||||||
|
scope_set = _resolve_scope_set(packages_dict, spec_scope, task_pkg, default_pkg)
|
||||||
|
|
||||||
|
lines.append("## PACKAGES")
|
||||||
|
lines.append("")
|
||||||
|
for pkg in pkg_info:
|
||||||
|
default_tag = " (default)" if pkg["default"] else ""
|
||||||
|
type_tag = f" [{pkg['type']}]" if pkg["type"] != "local" else ""
|
||||||
|
git_tag = " [git repo]" if pkg["isGitRepo"] else ""
|
||||||
|
|
||||||
|
# Scope annotation
|
||||||
|
scope_tag = ""
|
||||||
|
if scope_set is not None and pkg["name"] not in scope_set:
|
||||||
|
scope_tag = " (out of scope)"
|
||||||
|
|
||||||
|
lines.append(f"### {pkg['name']}{default_tag}{type_tag}{git_tag}{scope_tag}")
|
||||||
|
lines.append(f"Path: {pkg['path']}")
|
||||||
|
if pkg["specLayers"]:
|
||||||
|
lines.append(f"Spec layers: {', '.join(pkg['specLayers'])}")
|
||||||
|
for layer in pkg["specLayers"]:
|
||||||
|
lines.append(f" - .trellis/spec/{pkg['name']}/{layer}/index.md")
|
||||||
|
else:
|
||||||
|
lines.append("Spec: not configured")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Also show shared guides
|
||||||
|
guides_dir = repo_root / DIR_WORKFLOW / DIR_SPEC / "guides"
|
||||||
|
if guides_dir.is_dir():
|
||||||
|
lines.append("### Shared Guides (always included)")
|
||||||
|
lines.append("Path: .trellis/spec/guides/index.md")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def get_context_packages_json(repo_root: Path | None = None) -> dict:
|
||||||
|
"""Get packages context as a dictionary (for --mode packages --json)."""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
pkg_info = get_packages_info(repo_root)
|
||||||
|
|
||||||
|
if not pkg_info:
|
||||||
|
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||||
|
layers = _scan_spec_layers(spec_dir)
|
||||||
|
return {
|
||||||
|
"mode": "single-repo",
|
||||||
|
"specLayers": layers,
|
||||||
|
}
|
||||||
|
|
||||||
|
default_pkg = get_default_package(repo_root)
|
||||||
|
spec_scope = get_spec_scope(repo_root)
|
||||||
|
task_pkg = _get_active_task_package(repo_root)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mode": "monorepo",
|
||||||
|
"packages": pkg_info,
|
||||||
|
"defaultPackage": default_pkg,
|
||||||
|
"specScope": spec_scope,
|
||||||
|
"activeTaskPackage": task_pkg,
|
||||||
|
}
|
||||||
Executable
+447
@@ -0,0 +1,447 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Common path utilities for Trellis workflow.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
get_repo_root - Get repository root directory
|
||||||
|
get_developer - Get developer name
|
||||||
|
get_workspace_dir - Get developer workspace directory
|
||||||
|
get_tasks_dir - Get tasks directory
|
||||||
|
get_active_journal_file - Get current journal file
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Path Constants (change here to rename directories)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Directory names
|
||||||
|
DIR_WORKFLOW = ".trellis"
|
||||||
|
DIR_WORKSPACE = "workspace"
|
||||||
|
DIR_TASKS = "tasks"
|
||||||
|
DIR_ARCHIVE = "archive"
|
||||||
|
DIR_SPEC = "spec"
|
||||||
|
DIR_SCRIPTS = "scripts"
|
||||||
|
|
||||||
|
# File names
|
||||||
|
FILE_DEVELOPER = ".developer"
|
||||||
|
FILE_CURRENT_TASK = ".current-task"
|
||||||
|
FILE_TASK_JSON = "task.json"
|
||||||
|
FILE_JOURNAL_PREFIX = "journal-"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Repository Root
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_repo_root(start_path: Path | None = None) -> Path:
|
||||||
|
"""Find the nearest directory containing .trellis/ folder.
|
||||||
|
|
||||||
|
This handles nested git repos correctly (e.g., test project inside another repo).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_path: Starting directory to search from. Defaults to current directory.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to repository root, or current directory if no .trellis/ found.
|
||||||
|
"""
|
||||||
|
current = (start_path or Path.cwd()).resolve()
|
||||||
|
|
||||||
|
while current != current.parent:
|
||||||
|
if (current / DIR_WORKFLOW).is_dir():
|
||||||
|
return current
|
||||||
|
current = current.parent
|
||||||
|
|
||||||
|
# Fallback to current directory if no .trellis/ found
|
||||||
|
return Path.cwd().resolve()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Developer
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_developer(repo_root: Path | None = None) -> str | None:
|
||||||
|
"""Get developer name from .developer file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Developer name or None if not initialized.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
|
||||||
|
|
||||||
|
if not dev_file.is_file():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = dev_file.read_text(encoding="utf-8")
|
||||||
|
for line in content.splitlines():
|
||||||
|
if line.startswith("name="):
|
||||||
|
return line.split("=", 1)[1].strip()
|
||||||
|
except (OSError, IOError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def check_developer(repo_root: Path | None = None) -> bool:
|
||||||
|
"""Check if developer is initialized.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if developer is initialized.
|
||||||
|
"""
|
||||||
|
return get_developer(repo_root) is not None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tasks Directory
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_tasks_dir(repo_root: Path | None = None) -> Path:
|
||||||
|
"""Get tasks directory path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to tasks directory.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
return repo_root / DIR_WORKFLOW / DIR_TASKS
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Workspace Directory
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_workspace_dir(repo_root: Path | None = None) -> Path | None:
|
||||||
|
"""Get developer workspace directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to workspace directory or None if developer not set.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
developer = get_developer(repo_root)
|
||||||
|
if developer:
|
||||||
|
return repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Journal File
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_active_journal_file(repo_root: Path | None = None) -> Path | None:
|
||||||
|
"""Get the current active journal file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to active journal file or None if not found.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
workspace_dir = get_workspace_dir(repo_root)
|
||||||
|
if workspace_dir is None or not workspace_dir.is_dir():
|
||||||
|
return None
|
||||||
|
|
||||||
|
latest: Path | None = None
|
||||||
|
highest = 0
|
||||||
|
|
||||||
|
for f in workspace_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"):
|
||||||
|
if not f.is_file():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract number from filename
|
||||||
|
name = f.stem # e.g., "journal-1"
|
||||||
|
match = re.search(r"(\d+)$", name)
|
||||||
|
if match:
|
||||||
|
num = int(match.group(1))
|
||||||
|
if num > highest:
|
||||||
|
highest = num
|
||||||
|
latest = f
|
||||||
|
|
||||||
|
return latest
|
||||||
|
|
||||||
|
|
||||||
|
def count_lines(file_path: Path) -> int:
|
||||||
|
"""Count lines in a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of lines, or 0 if file doesn't exist.
|
||||||
|
"""
|
||||||
|
if not file_path.is_file():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
return len(file_path.read_text(encoding="utf-8").splitlines())
|
||||||
|
except (OSError, IOError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Current Task Management
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def normalize_task_ref(task_ref: str) -> str:
|
||||||
|
"""Normalize a task ref for stable runtime storage.
|
||||||
|
|
||||||
|
Stored refs should prefer repo-relative POSIX paths like
|
||||||
|
`.trellis/tasks/03-27-my-task`, even on Windows. Absolute paths are preserved
|
||||||
|
unless they can later be converted back to repo-relative form by callers.
|
||||||
|
"""
|
||||||
|
normalized = task_ref.strip()
|
||||||
|
if not normalized:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
path_obj = Path(normalized)
|
||||||
|
if path_obj.is_absolute():
|
||||||
|
return str(path_obj)
|
||||||
|
|
||||||
|
normalized = normalized.replace("\\", "/")
|
||||||
|
while normalized.startswith("./"):
|
||||||
|
normalized = normalized[2:]
|
||||||
|
|
||||||
|
if normalized.startswith(f"{DIR_TASKS}/"):
|
||||||
|
return f"{DIR_WORKFLOW}/{normalized}"
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_task_ref(task_ref: str, repo_root: Path | None = None) -> Path | None:
|
||||||
|
"""Resolve a task ref to an absolute task directory path."""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
normalized = normalize_task_ref(task_ref)
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
|
||||||
|
path_obj = Path(normalized)
|
||||||
|
if path_obj.is_absolute():
|
||||||
|
return path_obj
|
||||||
|
|
||||||
|
if normalized.startswith(f"{DIR_WORKFLOW}/"):
|
||||||
|
return repo_root / path_obj
|
||||||
|
|
||||||
|
return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_task(
|
||||||
|
repo_root: Path | None = None,
|
||||||
|
platform_input: dict | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Get current task directory path (relative to repo_root).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Relative path to current task directory or None.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
from .active_task import resolve_active_task
|
||||||
|
|
||||||
|
return resolve_active_task(repo_root, platform_input, platform).task_path
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_task_abs(
|
||||||
|
repo_root: Path | None = None,
|
||||||
|
platform_input: dict | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
) -> Path | None:
|
||||||
|
"""Get current task directory absolute path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Absolute path to current task directory or None.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
relative = get_current_task(repo_root, platform_input, platform)
|
||||||
|
if relative:
|
||||||
|
return resolve_task_ref(relative, repo_root)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_task_source(
|
||||||
|
repo_root: Path | None = None,
|
||||||
|
platform_input: dict | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
) -> tuple[str, str | None, str | None]:
|
||||||
|
"""Get active task source as (`source`, `context_key`, `task_path`)."""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
from .active_task import get_current_task_source as _get_source
|
||||||
|
|
||||||
|
return _get_source(repo_root, platform_input, platform)
|
||||||
|
|
||||||
|
|
||||||
|
def set_current_task(
|
||||||
|
task_path: str,
|
||||||
|
repo_root: Path | None = None,
|
||||||
|
platform_input: dict | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Set current task in session scope.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_path: Task directory path (relative to repo_root).
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True on success, False on error.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
from .active_task import set_active_task
|
||||||
|
|
||||||
|
return set_active_task(
|
||||||
|
task_path,
|
||||||
|
repo_root,
|
||||||
|
platform_input=platform_input,
|
||||||
|
platform=platform,
|
||||||
|
) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def clear_current_task(
|
||||||
|
repo_root: Path | None = None,
|
||||||
|
platform_input: dict | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Clear current task in session scope.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True on success.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
from .active_task import clear_active_task
|
||||||
|
|
||||||
|
clear_active_task(
|
||||||
|
repo_root,
|
||||||
|
platform_input=platform_input,
|
||||||
|
platform=platform,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def has_current_task(repo_root: Path | None = None) -> bool:
|
||||||
|
"""Check if has current task.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if current task is set.
|
||||||
|
"""
|
||||||
|
return get_current_task(repo_root) is not None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Task ID Generation
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def generate_task_date_prefix() -> str:
|
||||||
|
"""Generate task ID based on date (MM-DD format).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Date prefix string (e.g., "01-21").
|
||||||
|
"""
|
||||||
|
return datetime.now().strftime("%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Monorepo / Package Paths
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def get_spec_dir(package: str | None = None, repo_root: Path | None = None) -> Path:
|
||||||
|
"""Get the spec directory path.
|
||||||
|
|
||||||
|
Single-repo: .trellis/spec
|
||||||
|
Monorepo with package: .trellis/spec/<package>
|
||||||
|
|
||||||
|
Uses lazy import to avoid circular dependency with config.py.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
from .config import get_spec_base
|
||||||
|
|
||||||
|
base = get_spec_base(package, repo_root)
|
||||||
|
return repo_root / DIR_WORKFLOW / base
|
||||||
|
|
||||||
|
|
||||||
|
def get_package_path(package: str, repo_root: Path | None = None) -> Path | None:
|
||||||
|
"""Get a package's source directory absolute path from config.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Absolute path to the package directory, or None if not found.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
from .config import get_packages
|
||||||
|
|
||||||
|
packages = get_packages(repo_root)
|
||||||
|
if not packages or package not in packages:
|
||||||
|
return None
|
||||||
|
|
||||||
|
info = packages[package]
|
||||||
|
if isinstance(info, dict):
|
||||||
|
rel_path = info.get("path", package)
|
||||||
|
else:
|
||||||
|
rel_path = str(info)
|
||||||
|
|
||||||
|
return repo_root / rel_path
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Main Entry (for testing)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
repo = get_repo_root()
|
||||||
|
print(f"Repository root: {repo}")
|
||||||
|
print(f"Developer: {get_developer(repo)}")
|
||||||
|
print(f"Tasks dir: {get_tasks_dir(repo)}")
|
||||||
|
print(f"Workspace dir: {get_workspace_dir(repo)}")
|
||||||
|
print(f"Journal file: {get_active_journal_file(repo)}")
|
||||||
|
print(f"Current task: {get_current_task(repo)}")
|
||||||
Executable
+315
@@ -0,0 +1,315 @@
|
|||||||
|
"""
|
||||||
|
Safe git-add helpers for Trellis-owned paths.
|
||||||
|
|
||||||
|
Why this module exists
|
||||||
|
----------------------
|
||||||
|
A real user incident: a project's `.gitignore` listed `.trellis/` (company-wide
|
||||||
|
template / personal habit). When `add_session.py` and `task.py archive` ran
|
||||||
|
their auto-commit and `git add` failed with `ignored by .gitignore`, the AI
|
||||||
|
agent driving the workflow "fixed" it by retrying with
|
||||||
|
`git add -f .trellis/` — which fan-out-included every ignored subtree
|
||||||
|
(`.trellis/.backup-*/`, `.trellis/worktrees/`, `.trellis/.template-hashes.json`,
|
||||||
|
`.trellis/.runtime/`), committing 548 files / 83474 lines of caches/backups.
|
||||||
|
|
||||||
|
Design
|
||||||
|
------
|
||||||
|
- Scripts only stage SPECIFIC product paths (journal files, index.md, the
|
||||||
|
current task dir, the archive dir). Never the whole `.trellis/` tree.
|
||||||
|
- If plain `git add <specific>` fails with "ignored by", DO NOT retry with
|
||||||
|
``-f``. The presence of `.trellis/` in `.gitignore` is treated as user
|
||||||
|
intent ("keep .trellis/ local-only"). The script warns and skips the
|
||||||
|
auto-commit; users who want auto-staging can either fix their `.gitignore`
|
||||||
|
or set ``session_auto_commit: false`` and manage git themselves.
|
||||||
|
- The warning includes a negative example: ``Do NOT use `git add -f .trellis/` ...``
|
||||||
|
so any AI rereading the log doesn't reinvent the bug.
|
||||||
|
|
||||||
|
History note: 0.5.10 introduced an automatic ``git add -f`` retry on the
|
||||||
|
specific paths. That was reverted in 0.5.11 — auto-forcing into a tree the
|
||||||
|
user had gitignored violates user intent even when the path list is narrow.
|
||||||
|
The wider-grain forbidden command stays forbidden, and the narrow-grain auto
|
||||||
|
``-f`` is gone too.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .git import run_git
|
||||||
|
from .paths import (
|
||||||
|
DIR_ARCHIVE,
|
||||||
|
DIR_TASKS,
|
||||||
|
DIR_WORKFLOW,
|
||||||
|
DIR_WORKSPACE,
|
||||||
|
FILE_JOURNAL_PREFIX,
|
||||||
|
get_developer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Paths under .trellis/ that must NEVER be auto-staged. Listed here so the
|
||||||
|
# warning to the user can show concrete subpaths to ignore individually
|
||||||
|
# instead of ignoring the whole `.trellis/` tree.
|
||||||
|
TRELLIS_IGNORED_SUBPATHS = (
|
||||||
|
".trellis/.backup-*",
|
||||||
|
".trellis/worktrees/",
|
||||||
|
".trellis/.template-hashes.json",
|
||||||
|
".trellis/.runtime/",
|
||||||
|
".trellis/.cache/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_trellis_paths_to_add(
|
||||||
|
repo_root: Path,
|
||||||
|
task_name: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return the list of repo-relative paths the auto-commit should stage.
|
||||||
|
|
||||||
|
Only includes paths that exist on disk so callers don't pass non-existent
|
||||||
|
arguments to git. The caller is responsible for `git diff --cached`
|
||||||
|
checking afterwards.
|
||||||
|
|
||||||
|
Included:
|
||||||
|
- .trellis/workspace/<developer>/journal-*.md
|
||||||
|
- .trellis/workspace/<developer>/index.md
|
||||||
|
- .trellis/tasks/<task_name>/ (ONLY the current task dir when
|
||||||
|
``task_name`` is passed; plus its archive location if the task
|
||||||
|
already lives under archive/)
|
||||||
|
|
||||||
|
Excluded (intentionally — these must not be staged):
|
||||||
|
- .trellis/.backup-*, .trellis/worktrees/,
|
||||||
|
.trellis/.template-hashes.json, .trellis/.runtime/, .trellis/.cache/
|
||||||
|
|
||||||
|
Scope contract (see #303 / break-loop analysis): when ``task_name`` is
|
||||||
|
passed, the task segment stages ONLY that task directory — it never walks
|
||||||
|
``tasks_dir.iterdir()`` over all active tasks. This mirrors
|
||||||
|
:func:`safe_archive_paths_to_add` and prevents dirty changes in OTHER
|
||||||
|
parallel-window task dirs from being bundled into the session auto-commit.
|
||||||
|
|
||||||
|
Backwards-compat: with no ``task_name``, the function walks every active
|
||||||
|
task directory (+ the archive subtree) the old wide way. New callers
|
||||||
|
should always pass ``task_name``.
|
||||||
|
"""
|
||||||
|
paths: list[str] = []
|
||||||
|
|
||||||
|
# Workspace journal files + index.md
|
||||||
|
developer = get_developer(repo_root)
|
||||||
|
if developer:
|
||||||
|
ws = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer
|
||||||
|
if ws.is_dir():
|
||||||
|
for f in sorted(ws.glob(f"{FILE_JOURNAL_PREFIX}*.md")):
|
||||||
|
if f.is_file():
|
||||||
|
paths.append(
|
||||||
|
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{f.name}"
|
||||||
|
)
|
||||||
|
index_md = ws / "index.md"
|
||||||
|
if index_md.is_file():
|
||||||
|
paths.append(
|
||||||
|
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/index.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS
|
||||||
|
if not tasks_dir.is_dir():
|
||||||
|
return paths
|
||||||
|
|
||||||
|
if task_name is not None:
|
||||||
|
# Narrow scope — ONLY the current task directory (active or archived).
|
||||||
|
# Never iterdir() all tasks: parallel-window dirty task dirs must not
|
||||||
|
# leak into the session auto-commit.
|
||||||
|
active_task = tasks_dir / task_name
|
||||||
|
if active_task.is_dir():
|
||||||
|
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}")
|
||||||
|
archived_task = tasks_dir / DIR_ARCHIVE / task_name
|
||||||
|
if archived_task.is_dir():
|
||||||
|
paths.append(
|
||||||
|
f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{task_name}"
|
||||||
|
)
|
||||||
|
return paths
|
||||||
|
|
||||||
|
# Legacy wide scope (no task_name): each direct child of tasks/ that is a
|
||||||
|
# directory and not the archive root, plus the whole archive subtree.
|
||||||
|
for child in sorted(tasks_dir.iterdir()):
|
||||||
|
if not child.is_dir():
|
||||||
|
continue
|
||||||
|
if child.name == DIR_ARCHIVE:
|
||||||
|
continue
|
||||||
|
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child.name}")
|
||||||
|
|
||||||
|
archive_dir = tasks_dir / DIR_ARCHIVE
|
||||||
|
if archive_dir.is_dir():
|
||||||
|
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}")
|
||||||
|
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def safe_archive_paths_to_add(
|
||||||
|
repo_root: Path,
|
||||||
|
task_name: str | None = None,
|
||||||
|
modified_children: list[str] | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return paths to stage after `task.py archive`.
|
||||||
|
|
||||||
|
Scoped to ONLY the paths the archive operation actually touched:
|
||||||
|
|
||||||
|
- the archive subtree (where the freshly-moved task lives)
|
||||||
|
- the source task directory (for source-side deletes; caller pairs
|
||||||
|
this with `git rm --cached` since `git add` won't stage deletes
|
||||||
|
for a path that no longer exists in the working tree)
|
||||||
|
- any child task directories whose `task.json` was edited to drop
|
||||||
|
the archived parent (parent-children relationship update)
|
||||||
|
|
||||||
|
This narrow scope avoids "scope creep" — dirty changes in OTHER
|
||||||
|
active task dirs (parallel-window edits) are NOT bundled into the
|
||||||
|
archive commit. Callers handle each kind of change in its own
|
||||||
|
commit boundary.
|
||||||
|
|
||||||
|
Backwards-compat: with no arguments, the function walks the whole
|
||||||
|
`.trellis/tasks/` subtree the old way (active tasks + archive). New
|
||||||
|
callers should always pass `task_name`.
|
||||||
|
"""
|
||||||
|
paths: list[str] = []
|
||||||
|
tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS
|
||||||
|
if not tasks_dir.is_dir():
|
||||||
|
return paths
|
||||||
|
|
||||||
|
archive_dir = tasks_dir / DIR_ARCHIVE
|
||||||
|
|
||||||
|
if task_name is not None:
|
||||||
|
# Narrow scope — only paths that still exist on disk (so
|
||||||
|
# `git add` doesn't choke on the moved-away source). The caller
|
||||||
|
# handles the source-side deletes via `git rm --cached`
|
||||||
|
# explicitly.
|
||||||
|
if archive_dir.is_dir():
|
||||||
|
paths.append(
|
||||||
|
f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}"
|
||||||
|
)
|
||||||
|
for child_name in modified_children or []:
|
||||||
|
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child_name}")
|
||||||
|
return paths
|
||||||
|
|
||||||
|
# Legacy wide scope (no task_name): preserve old behavior so callers
|
||||||
|
# that have not been updated keep working.
|
||||||
|
if archive_dir.is_dir():
|
||||||
|
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}")
|
||||||
|
for child in sorted(tasks_dir.iterdir()):
|
||||||
|
if not child.is_dir():
|
||||||
|
continue
|
||||||
|
if child.name == DIR_ARCHIVE:
|
||||||
|
continue
|
||||||
|
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child.name}")
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def _stderr_indicates_ignored(stderr: str) -> bool:
|
||||||
|
"""git add error indicates the path is excluded by .gitignore."""
|
||||||
|
if not stderr:
|
||||||
|
return False
|
||||||
|
lowered = stderr.lower()
|
||||||
|
return "ignored by" in lowered
|
||||||
|
|
||||||
|
|
||||||
|
def safe_git_add(
|
||||||
|
paths: list[str], repo_root: Path
|
||||||
|
) -> tuple[bool, bool, str]:
|
||||||
|
"""Run `git add` on specific paths; never retry with -f.
|
||||||
|
|
||||||
|
Returns ``(success, used_force, stderr)``. The ``used_force`` field is
|
||||||
|
kept for signature compatibility with the 0.5.10 implementation but is
|
||||||
|
always ``False`` — we never auto-force.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- No paths passed → success, no force, empty stderr.
|
||||||
|
- Plain ``git add -- <paths>`` succeeds → return success.
|
||||||
|
- Plain fails (any reason — ignored or otherwise) → return failure with
|
||||||
|
the stderr. Callers should inspect the stderr (see
|
||||||
|
:func:`print_gitignore_warning`) and skip the auto-commit.
|
||||||
|
"""
|
||||||
|
if not paths:
|
||||||
|
return True, False, ""
|
||||||
|
|
||||||
|
rc, _, err = run_git(["add", "--", *paths], cwd=repo_root)
|
||||||
|
if rc == 0:
|
||||||
|
return True, False, ""
|
||||||
|
return False, False, err
|
||||||
|
|
||||||
|
|
||||||
|
def print_gitignore_warning(paths: list[str]) -> None:
|
||||||
|
"""Explain to the user (and any AI reading the log) what to do.
|
||||||
|
|
||||||
|
CRITICAL: includes the negative example
|
||||||
|
``Do NOT use `git add -f .trellis/``` — agents reading the warning are
|
||||||
|
known to invent that command, which fans out to ignored caches/backups.
|
||||||
|
"""
|
||||||
|
print(
|
||||||
|
"[WARN] git add failed because .trellis/ paths are ignored by your .gitignore.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"[WARN] Skipping auto-commit. The journal/task files were still written to disk;",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"[WARN] git was not touched.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print("[WARN]", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
"[WARN] Trellis manages these specific paths and they should be tracked:",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
if paths:
|
||||||
|
for p in paths:
|
||||||
|
print(f"[WARN] {p}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
"[WARN] .trellis/workspace/<developer>/{journal-*.md,index.md}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"[WARN] .trellis/tasks/<task-dir>/",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"[WARN] .trellis/tasks/archive/",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print("[WARN]", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
"[WARN] Recommended: change your .gitignore from `.trellis/` to specific",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"[WARN] subpaths that should remain ignored, e.g.:",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
for sub in TRELLIS_IGNORED_SUBPATHS:
|
||||||
|
print(f"[WARN] {sub}", file=sys.stderr)
|
||||||
|
print("[WARN]", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
"[WARN] Or, if you intentionally keep .trellis/ local-only, set in",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"[WARN] .trellis/config.yaml:",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"[WARN] session_auto_commit: false",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"[WARN] so the scripts skip git entirely and you can review / commit",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"[WARN] manually with `git status` / `git add` / `git commit`.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print("[WARN]", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
"[WARN] Do NOT use `git add -f .trellis/` — it pulls in backups, worktrees,",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"[WARN] and runtime caches that should never be committed.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
Executable
+821
@@ -0,0 +1,821 @@
|
|||||||
|
#!/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}, "
|
||||||
|
"run trellis upgrade"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# JSON Output
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_context_json(repo_root: Path | None = None) -> dict:
|
||||||
|
"""Get context as a dictionary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Context dictionary.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
developer = get_developer(repo_root)
|
||||||
|
tasks_dir = get_tasks_dir(repo_root)
|
||||||
|
journal_file = get_active_journal_file(repo_root)
|
||||||
|
|
||||||
|
journal_lines = 0
|
||||||
|
journal_relative = ""
|
||||||
|
if journal_file and developer:
|
||||||
|
journal_lines = count_lines(journal_file)
|
||||||
|
journal_relative = (
|
||||||
|
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
root_git_info = _collect_root_git_info(repo_root)
|
||||||
|
|
||||||
|
# Tasks
|
||||||
|
tasks = [
|
||||||
|
{
|
||||||
|
"dir": t.dir_name,
|
||||||
|
"name": t.name,
|
||||||
|
"status": t.status,
|
||||||
|
"children": list(t.children),
|
||||||
|
"parent": t.parent,
|
||||||
|
}
|
||||||
|
for t in iter_active_tasks(tasks_dir)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Package git repos (independent sub-repositories)
|
||||||
|
pkg_git_info = _collect_package_git_info(
|
||||||
|
repo_root,
|
||||||
|
discover_unconfigured=not root_git_info["isRepo"],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"developer": developer or "",
|
||||||
|
"git": {
|
||||||
|
"isRepo": root_git_info["isRepo"],
|
||||||
|
"branch": root_git_info["branch"],
|
||||||
|
"isClean": root_git_info["isClean"],
|
||||||
|
"uncommittedChanges": root_git_info["uncommittedChanges"],
|
||||||
|
"recentCommits": root_git_info["recentCommits"],
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"active": tasks,
|
||||||
|
"directory": f"{DIR_WORKFLOW}/{DIR_TASKS}",
|
||||||
|
},
|
||||||
|
"journal": {
|
||||||
|
"file": journal_relative,
|
||||||
|
"lines": journal_lines,
|
||||||
|
"nearLimit": journal_lines > 1800,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if pkg_git_info:
|
||||||
|
result["packageGit"] = pkg_git_info
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def output_json(repo_root: Path | None = None) -> None:
|
||||||
|
"""Output context in JSON format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
"""
|
||||||
|
context = get_context_json(repo_root)
|
||||||
|
print(json.dumps(context, indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Text Output
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_context_text(repo_root: Path | None = None) -> str:
|
||||||
|
"""Get context as formatted text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted text output.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
lines.append("========================================")
|
||||||
|
lines.append("SESSION CONTEXT")
|
||||||
|
lines.append("========================================")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
developer = get_developer(repo_root)
|
||||||
|
|
||||||
|
# Developer section
|
||||||
|
lines.append("## DEVELOPER")
|
||||||
|
if not developer:
|
||||||
|
lines.append(
|
||||||
|
f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
lines.append(f"Name: {developer}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
root_git_info = _collect_root_git_info(repo_root)
|
||||||
|
_append_root_git_context(lines, root_git_info)
|
||||||
|
|
||||||
|
# Package git repos — independent sub-repositories
|
||||||
|
_append_package_git_context(
|
||||||
|
lines,
|
||||||
|
_collect_package_git_info(
|
||||||
|
repo_root,
|
||||||
|
discover_unconfigured=not root_git_info["isRepo"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Current task
|
||||||
|
lines.append("## CURRENT TASK")
|
||||||
|
current_task = get_current_task(repo_root)
|
||||||
|
if current_task:
|
||||||
|
current_task_dir = repo_root / current_task
|
||||||
|
source_type, context_key, _ = get_current_task_source(repo_root)
|
||||||
|
lines.append(f"Path: {current_task}")
|
||||||
|
lines.append(
|
||||||
|
f"Source: {source_type}" + (f":{context_key}" if context_key else "")
|
||||||
|
)
|
||||||
|
|
||||||
|
ct = load_task(current_task_dir)
|
||||||
|
if ct:
|
||||||
|
lines.append(f"Name: {ct.name}")
|
||||||
|
lines.append(f"Status: {ct.status}")
|
||||||
|
lines.append(f"Created: {ct.raw.get('createdAt', 'unknown')}")
|
||||||
|
if ct.description:
|
||||||
|
lines.append(f"Description: {ct.description}")
|
||||||
|
|
||||||
|
# Check for prd.md
|
||||||
|
prd_file = current_task_dir / "prd.md"
|
||||||
|
if prd_file.is_file():
|
||||||
|
lines.append("")
|
||||||
|
lines.append("[!] This task has prd.md - read it for task details")
|
||||||
|
else:
|
||||||
|
lines.append("(none)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Active tasks
|
||||||
|
lines.append("## ACTIVE TASKS")
|
||||||
|
tasks_dir = get_tasks_dir(repo_root)
|
||||||
|
task_count = 0
|
||||||
|
|
||||||
|
# Collect all task data for hierarchy display
|
||||||
|
all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)}
|
||||||
|
all_statuses = {name: t.status for name, t in all_tasks.items()}
|
||||||
|
|
||||||
|
def _print_task_tree(name: str, indent: int = 0) -> None:
|
||||||
|
nonlocal task_count
|
||||||
|
t = all_tasks[name]
|
||||||
|
progress = children_progress(t.children, all_statuses)
|
||||||
|
prefix = " " * indent
|
||||||
|
lines.append(f"{prefix}- {name}/ ({t.status}){progress} @{t.assignee or '-'}")
|
||||||
|
task_count += 1
|
||||||
|
for child in t.children:
|
||||||
|
if child in all_tasks:
|
||||||
|
_print_task_tree(child, indent + 1)
|
||||||
|
|
||||||
|
for dir_name in sorted(all_tasks.keys()):
|
||||||
|
if not all_tasks[dir_name].parent:
|
||||||
|
_print_task_tree(dir_name)
|
||||||
|
|
||||||
|
if task_count == 0:
|
||||||
|
lines.append("(no active tasks)")
|
||||||
|
lines.append(f"Total: {task_count} active task(s)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# My tasks
|
||||||
|
lines.append("## MY TASKS (Assigned to me)")
|
||||||
|
my_task_count = 0
|
||||||
|
|
||||||
|
for t in all_tasks.values():
|
||||||
|
if t.assignee == developer and t.status != "done":
|
||||||
|
progress = children_progress(t.children, all_statuses)
|
||||||
|
lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress}")
|
||||||
|
my_task_count += 1
|
||||||
|
|
||||||
|
if my_task_count == 0:
|
||||||
|
lines.append("(no tasks assigned to you)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Journal file
|
||||||
|
lines.append("## JOURNAL FILE")
|
||||||
|
journal_file = get_active_journal_file(repo_root)
|
||||||
|
if journal_file:
|
||||||
|
journal_lines = count_lines(journal_file)
|
||||||
|
relative = f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}"
|
||||||
|
lines.append(f"Active file: {relative}")
|
||||||
|
lines.append(f"Line count: {journal_lines} / 2000")
|
||||||
|
if journal_lines > 1800:
|
||||||
|
lines.append("[!] WARNING: Approaching 2000 line limit!")
|
||||||
|
else:
|
||||||
|
lines.append("No journal file found")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Packages
|
||||||
|
packages_text = get_packages_section(repo_root)
|
||||||
|
if packages_text:
|
||||||
|
lines.append(packages_text)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Paths
|
||||||
|
lines.append("## PATHS")
|
||||||
|
lines.append(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/")
|
||||||
|
lines.append(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/")
|
||||||
|
lines.append(f"Spec: {DIR_WORKFLOW}/{DIR_SPEC}/")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("========================================")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Record Mode
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_context_record_json(repo_root: Path | None = None) -> dict:
|
||||||
|
"""Get record-mode context as a dictionary.
|
||||||
|
|
||||||
|
Focused on: my active tasks, git status, current task.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
developer = get_developer(repo_root)
|
||||||
|
tasks_dir = get_tasks_dir(repo_root)
|
||||||
|
|
||||||
|
root_git_info = _collect_root_git_info(repo_root)
|
||||||
|
|
||||||
|
# My tasks (single pass — collect statuses and filter by assignee)
|
||||||
|
all_tasks_list = list(iter_active_tasks(tasks_dir))
|
||||||
|
all_statuses = {t.dir_name: t.status for t in all_tasks_list}
|
||||||
|
|
||||||
|
my_tasks = []
|
||||||
|
for t in all_tasks_list:
|
||||||
|
if t.assignee == developer:
|
||||||
|
done = sum(
|
||||||
|
1 for c in t.children
|
||||||
|
if all_statuses.get(c) in ("completed", "done")
|
||||||
|
)
|
||||||
|
my_tasks.append({
|
||||||
|
"dir": t.dir_name,
|
||||||
|
"title": t.title,
|
||||||
|
"status": t.status,
|
||||||
|
"priority": t.priority,
|
||||||
|
"children": list(t.children),
|
||||||
|
"childrenDone": done,
|
||||||
|
"parent": t.parent,
|
||||||
|
"meta": t.meta,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Current task
|
||||||
|
current_task_info = None
|
||||||
|
current_task = get_current_task(repo_root)
|
||||||
|
if current_task:
|
||||||
|
source_type, context_key, _ = get_current_task_source(repo_root)
|
||||||
|
ct = load_task(repo_root / current_task)
|
||||||
|
if ct:
|
||||||
|
current_task_info = {
|
||||||
|
"path": current_task,
|
||||||
|
"name": ct.name,
|
||||||
|
"status": ct.status,
|
||||||
|
"source": source_type,
|
||||||
|
"contextKey": context_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Package git repos
|
||||||
|
pkg_git_info = _collect_package_git_info(
|
||||||
|
repo_root,
|
||||||
|
discover_unconfigured=not root_git_info["isRepo"],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"developer": developer or "",
|
||||||
|
"git": {
|
||||||
|
"isRepo": root_git_info["isRepo"],
|
||||||
|
"branch": root_git_info["branch"],
|
||||||
|
"isClean": root_git_info["isClean"],
|
||||||
|
"uncommittedChanges": root_git_info["uncommittedChanges"],
|
||||||
|
"recentCommits": root_git_info["recentCommits"],
|
||||||
|
},
|
||||||
|
"myTasks": my_tasks,
|
||||||
|
"currentTask": current_task_info,
|
||||||
|
}
|
||||||
|
|
||||||
|
if pkg_git_info:
|
||||||
|
result["packageGit"] = pkg_git_info
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_context_text_record(repo_root: Path | None = None) -> str:
|
||||||
|
"""Get context as formatted text for record-session mode.
|
||||||
|
|
||||||
|
Focused output: MY ACTIVE TASKS first (with [!!!] emphasis),
|
||||||
|
then GIT STATUS, RECENT COMMITS, CURRENT TASK.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append("========================================")
|
||||||
|
lines.append("SESSION CONTEXT (RECORD MODE)")
|
||||||
|
lines.append("========================================")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
developer = get_developer(repo_root)
|
||||||
|
if not developer:
|
||||||
|
lines.append(
|
||||||
|
f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
# MY ACTIVE TASKS — first and prominent
|
||||||
|
lines.append(f"## [!!!] MY ACTIVE TASKS (Assigned to {developer})")
|
||||||
|
lines.append("[!] Review whether any should be archived before recording this session.")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
tasks_dir = get_tasks_dir(repo_root)
|
||||||
|
my_task_count = 0
|
||||||
|
|
||||||
|
# Single pass — collect all tasks and filter by assignee
|
||||||
|
all_statuses = get_all_statuses(tasks_dir)
|
||||||
|
|
||||||
|
for t in iter_active_tasks(tasks_dir):
|
||||||
|
if t.assignee == developer:
|
||||||
|
progress = children_progress(t.children, all_statuses)
|
||||||
|
lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress} — {t.dir_name}")
|
||||||
|
my_task_count += 1
|
||||||
|
|
||||||
|
if my_task_count == 0:
|
||||||
|
lines.append("(no active tasks assigned to you)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
root_git_info = _collect_root_git_info(repo_root)
|
||||||
|
_append_root_git_context(lines, root_git_info)
|
||||||
|
|
||||||
|
# Package git repos — independent sub-repositories
|
||||||
|
_append_package_git_context(
|
||||||
|
lines,
|
||||||
|
_collect_package_git_info(
|
||||||
|
repo_root,
|
||||||
|
discover_unconfigured=not root_git_info["isRepo"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# CURRENT TASK
|
||||||
|
lines.append("## CURRENT TASK")
|
||||||
|
current_task = get_current_task(repo_root)
|
||||||
|
if current_task:
|
||||||
|
source_type, context_key, _ = get_current_task_source(repo_root)
|
||||||
|
lines.append(f"Path: {current_task}")
|
||||||
|
lines.append(
|
||||||
|
f"Source: {source_type}" + (f":{context_key}" if context_key else "")
|
||||||
|
)
|
||||||
|
ct = load_task(repo_root / current_task)
|
||||||
|
if ct:
|
||||||
|
lines.append(f"Name: {ct.name}")
|
||||||
|
lines.append(f"Status: {ct.status}")
|
||||||
|
else:
|
||||||
|
lines.append("(none)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("========================================")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def output_text(repo_root: Path | None = None) -> None:
|
||||||
|
"""Output context in text format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
update_hint = _get_update_hint(repo_root)
|
||||||
|
if update_hint:
|
||||||
|
print(update_hint)
|
||||||
|
print("")
|
||||||
|
print(get_context_text(repo_root))
|
||||||
Executable
+223
@@ -0,0 +1,223 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Task JSONL context management.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
cmd_add_context - Add entry to JSONL context file
|
||||||
|
cmd_validate - Validate JSONL context files
|
||||||
|
cmd_list_context - List JSONL context entries
|
||||||
|
|
||||||
|
Note:
|
||||||
|
``cmd_init_context`` was removed in v0.5.0-beta.12. JSONL context files
|
||||||
|
are now seeded at ``task.py create`` time with a self-describing
|
||||||
|
``_example`` line; the AI agent curates real entries during planning when
|
||||||
|
the task needs sub-agent/spec context. See ``.trellis/workflow.md`` for the
|
||||||
|
current planning artifact contract.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .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
|
||||||
Executable
+188
@@ -0,0 +1,188 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Task queue utility functions.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
list_tasks_by_status - List tasks by status
|
||||||
|
list_pending_tasks - List tasks with pending status
|
||||||
|
list_tasks_by_assignee - List tasks by assignee
|
||||||
|
list_my_tasks - List tasks assigned to current developer
|
||||||
|
get_task_stats - Get P0/P1/P2/P3 counts
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .paths import (
|
||||||
|
get_repo_root,
|
||||||
|
get_developer,
|
||||||
|
get_tasks_dir,
|
||||||
|
)
|
||||||
|
from .tasks import iter_active_tasks
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Internal helper
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _task_to_dict(t) -> dict:
|
||||||
|
"""Convert TaskInfo to the dict format callers expect."""
|
||||||
|
return {
|
||||||
|
"priority": t.priority,
|
||||||
|
"id": t.raw.get("id", ""),
|
||||||
|
"title": t.title,
|
||||||
|
"status": t.status,
|
||||||
|
"assignee": t.assignee or "-",
|
||||||
|
"dir": t.dir_name,
|
||||||
|
"children": list(t.children),
|
||||||
|
"parent": t.parent,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Public Functions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def list_tasks_by_status(
|
||||||
|
filter_status: str | None = None,
|
||||||
|
repo_root: Path | None = None
|
||||||
|
) -> list[dict]:
|
||||||
|
"""List tasks by status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filter_status: Optional status filter.
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of task info dicts with keys: priority, id, title, status, assignee.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
tasks_dir = get_tasks_dir(repo_root)
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for t in iter_active_tasks(tasks_dir):
|
||||||
|
if filter_status and t.status != filter_status:
|
||||||
|
continue
|
||||||
|
results.append(_task_to_dict(t))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def list_pending_tasks(repo_root: Path | None = None) -> list[dict]:
|
||||||
|
"""List pending tasks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of task info dicts.
|
||||||
|
"""
|
||||||
|
return list_tasks_by_status("planning", repo_root)
|
||||||
|
|
||||||
|
|
||||||
|
def list_tasks_by_assignee(
|
||||||
|
assignee: str,
|
||||||
|
filter_status: str | None = None,
|
||||||
|
repo_root: Path | None = None
|
||||||
|
) -> list[dict]:
|
||||||
|
"""List tasks assigned to a specific developer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
assignee: Developer name.
|
||||||
|
filter_status: Optional status filter.
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of task info dicts.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
tasks_dir = get_tasks_dir(repo_root)
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for t in iter_active_tasks(tasks_dir):
|
||||||
|
if (t.assignee or "-") != assignee:
|
||||||
|
continue
|
||||||
|
if filter_status and t.status != filter_status:
|
||||||
|
continue
|
||||||
|
results.append(_task_to_dict(t))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def list_my_tasks(
|
||||||
|
filter_status: str | None = None,
|
||||||
|
repo_root: Path | None = None
|
||||||
|
) -> list[dict]:
|
||||||
|
"""List tasks assigned to current developer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filter_status: Optional status filter.
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of task info dicts.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If developer not set.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
developer = get_developer(repo_root)
|
||||||
|
if not developer:
|
||||||
|
raise ValueError("Developer not set")
|
||||||
|
|
||||||
|
return list_tasks_by_assignee(developer, filter_status, repo_root)
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_stats(repo_root: Path | None = None) -> dict[str, int]:
|
||||||
|
"""Get task statistics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with keys: P0, P1, P2, P3, Total.
|
||||||
|
"""
|
||||||
|
if repo_root is None:
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
tasks_dir = get_tasks_dir(repo_root)
|
||||||
|
stats = {"P0": 0, "P1": 0, "P2": 0, "P3": 0, "Total": 0}
|
||||||
|
|
||||||
|
for t in iter_active_tasks(tasks_dir):
|
||||||
|
if t.priority in stats:
|
||||||
|
stats[t.priority] += 1
|
||||||
|
stats["Total"] += 1
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def format_task_stats(stats: dict[str, int]) -> str:
|
||||||
|
"""Format task stats as string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stats: Stats dict from get_task_stats.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string like "P0:0 P1:1 P2:2 P3:0 Total:3".
|
||||||
|
"""
|
||||||
|
return f"P0:{stats['P0']} P1:{stats['P1']} P2:{stats['P2']} P3:{stats['P3']} Total:{stats['Total']}"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Main Entry (for testing)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
stats = get_task_stats()
|
||||||
|
print(format_task_stats(stats))
|
||||||
|
print()
|
||||||
|
print("Pending tasks:")
|
||||||
|
for task in list_pending_tasks():
|
||||||
|
print(f" {task['priority']}|{task['id']}|{task['title']}|{task['status']}|{task['assignee']}")
|
||||||
Executable
+746
@@ -0,0 +1,746 @@
|
|||||||
|
#!/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 /
|
||||||
|
# Devin 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 `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. "
|
||||||
|
"Delete this line once real entries are added."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_subagent_platform(repo_root: Path) -> bool:
|
||||||
|
"""Return True if any sub-agent-capable platform is configured.
|
||||||
|
|
||||||
|
Detected by probing well-known config directories at the repo root. 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")
|
||||||
|
|
||||||
|
|
||||||
|
def _default_prd_content(title: str, description: str | None = None) -> str:
|
||||||
|
"""Return the default PRD skeleton created with every task."""
|
||||||
|
goal = (description or "").strip() or "TBD."
|
||||||
|
heading = title.strip() or "Untitled task"
|
||||||
|
return f"""# {heading}
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
{goal}
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] TBD
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Keep `prd.md` focused on requirements, constraints, and acceptance criteria.
|
||||||
|
- Lightweight tasks can remain PRD-only.
|
||||||
|
- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Command: create
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def cmd_create(args: argparse.Namespace) -> int:
|
||||||
|
"""Create a new task."""
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
if not args.title:
|
||||||
|
print(colored("Error: title is required", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Validate --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)
|
||||||
|
|
||||||
|
prd_path = task_dir / "prd.md"
|
||||||
|
if not prd_path.exists():
|
||||||
|
prd_path.write_text(
|
||||||
|
_default_prd_content(args.title, args.description),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Seed implement.jsonl / check.jsonl for sub-agent-capable platforms.
|
||||||
|
# Agent curates real entries during planning when the task needs them.
|
||||||
|
# Agent-less platforms (Kilo / Antigravity / Devin) skip this — they
|
||||||
|
# load specs via the trellis-before-dev skill instead of JSONL.
|
||||||
|
seeded_jsonl = False
|
||||||
|
if _has_subagent_platform(repo_root):
|
||||||
|
for jsonl_name in ("implement.jsonl", "check.jsonl"):
|
||||||
|
jsonl_path = task_dir / jsonl_name
|
||||||
|
if not jsonl_path.exists():
|
||||||
|
_write_seed_jsonl(jsonl_path)
|
||||||
|
seeded_jsonl = True
|
||||||
|
|
||||||
|
# Handle --parent: establish bidirectional link
|
||||||
|
if args.parent:
|
||||||
|
parent_dir = resolve_task_dir(args.parent, repo_root)
|
||||||
|
parent_json_path = parent_dir / FILE_TASK_JSON
|
||||||
|
if not parent_json_path.is_file():
|
||||||
|
print(colored(f"Warning: Parent task.json not found: {args.parent}", Colors.YELLOW), file=sys.stderr)
|
||||||
|
else:
|
||||||
|
parent_data = read_json(parent_json_path)
|
||||||
|
if parent_data:
|
||||||
|
# Add child to parent's children list
|
||||||
|
parent_children = parent_data.get("children", [])
|
||||||
|
if dir_name not in parent_children:
|
||||||
|
parent_children.append(dir_name)
|
||||||
|
parent_data["children"] = parent_children
|
||||||
|
write_json(parent_json_path, parent_data)
|
||||||
|
|
||||||
|
# Set parent in child's task.json
|
||||||
|
task_data["parent"] = parent_dir.name
|
||||||
|
write_json(task_json_path, task_data)
|
||||||
|
|
||||||
|
print(colored(f"Linked as child of: {parent_dir.name}", Colors.GREEN), file=sys.stderr)
|
||||||
|
|
||||||
|
# Auto-activate the new task so the per-turn breadcrumb fires planning
|
||||||
|
# state. Best-effort: gracefully degrade if no session identity (CLI run
|
||||||
|
# outside an AI session) — the task is still created, the user can run
|
||||||
|
# task.py start later. Pointer is session-scoped so this never affects
|
||||||
|
# other AI sessions.
|
||||||
|
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(" - Fill prd.md with requirements and acceptance criteria", file=sys.stderr)
|
||||||
|
print(" - Lightweight task: PRD-only is valid", file=sys.stderr)
|
||||||
|
print(" - Complex task: add design.md and implement.md before task.py start", file=sys.stderr)
|
||||||
|
if seeded_jsonl:
|
||||||
|
print(
|
||||||
|
" - Curate implement.jsonl / check.jsonl as spec/research manifests when sub-agents need context",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(" - Use /trellis:continue or phase context to decide the next step", file=sys.stderr)
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
|
||||||
|
# Output relative path for script chaining
|
||||||
|
print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}")
|
||||||
|
|
||||||
|
run_task_hooks("after_create", task_json_path, repo_root)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Command: archive
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def cmd_archive(args: argparse.Namespace) -> int:
|
||||||
|
"""Archive completed task."""
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
task_name = args.name
|
||||||
|
|
||||||
|
if not task_name:
|
||||||
|
print(colored("Error: Task name is required", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
tasks_dir = get_tasks_dir(repo_root)
|
||||||
|
|
||||||
|
# Resolve task directory (supports task name, relative path, or absolute path)
|
||||||
|
task_dir = resolve_task_dir(task_name, repo_root)
|
||||||
|
|
||||||
|
if not task_dir or not task_dir.is_dir():
|
||||||
|
print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr)
|
||||||
|
print("Active tasks:", file=sys.stderr)
|
||||||
|
# Import lazily to avoid circular dependency
|
||||||
|
from .tasks import iter_active_tasks
|
||||||
|
for t in iter_active_tasks(tasks_dir):
|
||||||
|
print(f" - {t.dir_name}/", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
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):
|
||||||
|
if not _auto_commit_archive(dir_name, repo_root, modified_children):
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
"Archive moved on disk, but git auto-commit did not complete. "
|
||||||
|
"Resolve `git status` before continuing.",
|
||||||
|
Colors.RED,
|
||||||
|
),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Return the archive path
|
||||||
|
print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}")
|
||||||
|
|
||||||
|
# Run hooks with the archived path
|
||||||
|
archived_json = archive_dest / FILE_TASK_JSON
|
||||||
|
run_task_hooks("after_archive", archived_json, repo_root)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_commit_archive(
|
||||||
|
task_name: str,
|
||||||
|
repo_root: Path,
|
||||||
|
modified_children: list[str] | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Stage Trellis-owned task paths and commit after archive.
|
||||||
|
|
||||||
|
Scoped narrowly to the archived task's source + destination paths
|
||||||
|
plus any child task dirs whose ``task.json`` was edited (parent →
|
||||||
|
children relationship update). Dirty changes in OTHER active task
|
||||||
|
dirs are NOT bundled into the archive commit.
|
||||||
|
|
||||||
|
If ``.gitignore`` blocks the paths, we warn + skip — we do NOT
|
||||||
|
retry with ``git add -f``. The warning explicitly forbids
|
||||||
|
``git add -f .trellis/`` (which would fan out to caches/backups)
|
||||||
|
and points users at ``session_auto_commit: false``.
|
||||||
|
|
||||||
|
Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when
|
||||||
|
set to ``false``, this function returns immediately without
|
||||||
|
touching git (the archive directory move on disk is unaffected).
|
||||||
|
"""
|
||||||
|
if not get_session_auto_commit(repo_root):
|
||||||
|
print(
|
||||||
|
"[OK] session_auto_commit: false — skipping git stage/commit.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
source_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}"
|
||||||
|
rc, tracked_out, _ = run_git(
|
||||||
|
["ls-files", "--", source_rel],
|
||||||
|
cwd=repo_root,
|
||||||
|
)
|
||||||
|
source_was_tracked = rc == 0 and bool(tracked_out.strip())
|
||||||
|
|
||||||
|
paths = safe_archive_paths_to_add(
|
||||||
|
repo_root, task_name=task_name, modified_children=modified_children
|
||||||
|
)
|
||||||
|
if not paths:
|
||||||
|
print("[OK] No task changes to commit.", file=sys.stderr)
|
||||||
|
return True
|
||||||
|
|
||||||
|
success, _, err = safe_git_add(paths, repo_root)
|
||||||
|
if not success:
|
||||||
|
if err and "ignored by" in err.lower():
|
||||||
|
print_gitignore_warning(paths)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"[WARN] git add failed: {err.strip() if err else 'unknown error'}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return not source_was_tracked
|
||||||
|
|
||||||
|
# Belt-and-suspenders for the phantom-delete bug: `safe_git_add` uses
|
||||||
|
# `git add` (no -A) which only stages additions/modifications. The
|
||||||
|
# source task directory was moved away by `shutil.move`, so its files
|
||||||
|
# need an explicit `git rm --cached` to stage the deletions in this
|
||||||
|
# same commit — otherwise they sit as uncommitted "phantom deletes"
|
||||||
|
# against HEAD until something later picks them up.
|
||||||
|
#
|
||||||
|
# `--ignore-unmatch` makes this a no-op when the task was never tracked
|
||||||
|
# (e.g. archiving a task that lived only in working tree).
|
||||||
|
run_git(
|
||||||
|
["rm", "-r", "--cached", "--ignore-unmatch", "--", source_rel],
|
||||||
|
cwd=repo_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
rc, _, _ = run_git(
|
||||||
|
["diff", "--cached", "--quiet", "--", *paths, source_rel],
|
||||||
|
cwd=repo_root,
|
||||||
|
)
|
||||||
|
if rc == 0:
|
||||||
|
print("[OK] No task changes to commit.", file=sys.stderr)
|
||||||
|
return True
|
||||||
|
|
||||||
|
commit_msg = f"chore(task): archive {task_name}"
|
||||||
|
rc, _, err = run_git(["commit", "-m", commit_msg], cwd=repo_root)
|
||||||
|
if rc == 0:
|
||||||
|
print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr)
|
||||||
|
return not source_was_tracked
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Command: add-subtask
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def cmd_add_subtask(args: argparse.Namespace) -> int:
|
||||||
|
"""Link a child task to a parent task."""
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
|
||||||
|
child_dir = resolve_task_dir(args.child_dir, repo_root)
|
||||||
|
|
||||||
|
parent_json_path = parent_dir / FILE_TASK_JSON
|
||||||
|
child_json_path = child_dir / FILE_TASK_JSON
|
||||||
|
|
||||||
|
if not parent_json_path.is_file():
|
||||||
|
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not child_json_path.is_file():
|
||||||
|
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
parent_data = read_json(parent_json_path)
|
||||||
|
child_data = read_json(child_json_path)
|
||||||
|
|
||||||
|
if not parent_data or not child_data:
|
||||||
|
print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Check if child already has a parent
|
||||||
|
existing_parent = child_data.get("parent")
|
||||||
|
if existing_parent:
|
||||||
|
print(colored(f"Error: Child task already has a parent: {existing_parent}", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Add child to parent's children list
|
||||||
|
parent_children = parent_data.get("children", [])
|
||||||
|
child_dir_name = child_dir.name
|
||||||
|
if child_dir_name not in parent_children:
|
||||||
|
parent_children.append(child_dir_name)
|
||||||
|
parent_data["children"] = parent_children
|
||||||
|
|
||||||
|
# Set parent in child's task.json
|
||||||
|
child_data["parent"] = parent_dir.name
|
||||||
|
|
||||||
|
# Write both
|
||||||
|
write_json(parent_json_path, parent_data)
|
||||||
|
write_json(child_json_path, child_data)
|
||||||
|
|
||||||
|
print(colored(f"Linked: {child_dir.name} -> {parent_dir.name}", Colors.GREEN), file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Command: remove-subtask
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def cmd_remove_subtask(args: argparse.Namespace) -> int:
|
||||||
|
"""Unlink a child task from a parent task."""
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
|
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
|
||||||
|
child_dir = resolve_task_dir(args.child_dir, repo_root)
|
||||||
|
|
||||||
|
parent_json_path = parent_dir / FILE_TASK_JSON
|
||||||
|
child_json_path = child_dir / FILE_TASK_JSON
|
||||||
|
|
||||||
|
if not parent_json_path.is_file():
|
||||||
|
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not child_json_path.is_file():
|
||||||
|
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
parent_data = read_json(parent_json_path)
|
||||||
|
child_data = read_json(child_json_path)
|
||||||
|
|
||||||
|
if not parent_data or not child_data:
|
||||||
|
print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Remove child from parent's children list
|
||||||
|
parent_children = parent_data.get("children", [])
|
||||||
|
child_dir_name = child_dir.name
|
||||||
|
if child_dir_name in parent_children:
|
||||||
|
parent_children.remove(child_dir_name)
|
||||||
|
parent_data["children"] = parent_children
|
||||||
|
|
||||||
|
# Clear parent in child's task.json
|
||||||
|
child_data["parent"] = None
|
||||||
|
|
||||||
|
# Write both
|
||||||
|
write_json(parent_json_path, parent_data)
|
||||||
|
write_json(child_json_path, child_data)
|
||||||
|
|
||||||
|
print(colored(f"Unlinked: {child_dir.name} from {parent_dir.name}", Colors.GREEN), file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Command: set-branch
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def cmd_set_branch(args: argparse.Namespace) -> int:
|
||||||
|
"""Set git branch for task."""
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||||
|
branch = args.branch
|
||||||
|
|
||||||
|
if not branch:
|
||||||
|
print(colored("Error: Missing arguments", Colors.RED))
|
||||||
|
print("Usage: python3 task.py set-branch <task-dir> <branch-name>")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
task_json = target_dir / FILE_TASK_JSON
|
||||||
|
if not task_json.is_file():
|
||||||
|
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
data = read_json(task_json)
|
||||||
|
if not data:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
data["branch"] = branch
|
||||||
|
write_json(task_json, data)
|
||||||
|
|
||||||
|
print(colored(f"✓ Branch set to: {branch}", Colors.GREEN))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Command: set-base-branch
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def cmd_set_base_branch(args: argparse.Namespace) -> int:
|
||||||
|
"""Set the base branch (PR target) for task."""
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||||
|
base_branch = args.base_branch
|
||||||
|
|
||||||
|
if not base_branch:
|
||||||
|
print(colored("Error: Missing arguments", Colors.RED))
|
||||||
|
print("Usage: python3 task.py set-base-branch <task-dir> <base-branch>")
|
||||||
|
print("Example: python3 task.py set-base-branch <dir> develop")
|
||||||
|
print()
|
||||||
|
print("This sets the target branch for PR (the branch your feature will merge into).")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
task_json = target_dir / FILE_TASK_JSON
|
||||||
|
if not task_json.is_file():
|
||||||
|
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
data = read_json(task_json)
|
||||||
|
if not data:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
data["base_branch"] = base_branch
|
||||||
|
write_json(task_json, data)
|
||||||
|
|
||||||
|
print(colored(f"✓ Base branch set to: {base_branch}", Colors.GREEN))
|
||||||
|
print(f" PR will target: {base_branch}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Command: set-scope
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def cmd_set_scope(args: argparse.Namespace) -> int:
|
||||||
|
"""Set scope for PR title."""
|
||||||
|
repo_root = get_repo_root()
|
||||||
|
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||||
|
scope = args.scope
|
||||||
|
|
||||||
|
if not scope:
|
||||||
|
print(colored("Error: Missing arguments", Colors.RED))
|
||||||
|
print("Usage: python3 task.py set-scope <task-dir> <scope>")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
task_json = target_dir / FILE_TASK_JSON
|
||||||
|
if not task_json.is_file():
|
||||||
|
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
data = read_json(task_json)
|
||||||
|
if not data:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
data["scope"] = scope
|
||||||
|
write_json(task_json, data)
|
||||||
|
|
||||||
|
print(colored(f"✓ Scope set to: {scope}", Colors.GREEN))
|
||||||
|
return 0
|
||||||
Executable
+274
@@ -0,0 +1,274 @@
|
|||||||
|
#!/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)}")
|
||||||
Executable
+112
@@ -0,0 +1,112 @@
|
|||||||
|
"""
|
||||||
|
Task data access layer.
|
||||||
|
|
||||||
|
Single source of truth for loading and iterating task directories.
|
||||||
|
Replaces scattered task.json parsing across 9+ files.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
load_task — Load a single task by directory path
|
||||||
|
iter_active_tasks — Iterate all non-archived tasks (sorted)
|
||||||
|
get_all_statuses — Get {dir_name: status} map for children progress
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .io import read_json
|
||||||
|
from .paths import FILE_TASK_JSON
|
||||||
|
from .types import TaskInfo
|
||||||
|
|
||||||
|
|
||||||
|
def load_task(task_dir: Path) -> TaskInfo | None:
|
||||||
|
"""Load task from a directory containing task.json.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_dir: Absolute path to the task directory.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TaskInfo if task.json exists and is valid, None otherwise.
|
||||||
|
"""
|
||||||
|
task_json = task_dir / FILE_TASK_JSON
|
||||||
|
if not task_json.is_file():
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = read_json(task_json)
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return TaskInfo(
|
||||||
|
dir_name=task_dir.name,
|
||||||
|
directory=task_dir,
|
||||||
|
title=data.get("title") or data.get("name") or "unknown",
|
||||||
|
status=data.get("status", "unknown"),
|
||||||
|
assignee=data.get("assignee", ""),
|
||||||
|
priority=data.get("priority", "P2"),
|
||||||
|
children=tuple(data.get("children", [])),
|
||||||
|
parent=data.get("parent"),
|
||||||
|
package=data.get("package"),
|
||||||
|
raw=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def iter_active_tasks(tasks_dir: Path) -> Iterator[TaskInfo]:
|
||||||
|
"""Iterate all active (non-archived) tasks, sorted by directory name.
|
||||||
|
|
||||||
|
Skips the "archive" directory and directories without valid task.json.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tasks_dir: Path to the tasks directory.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
TaskInfo for each valid task.
|
||||||
|
"""
|
||||||
|
if not tasks_dir.is_dir():
|
||||||
|
return
|
||||||
|
|
||||||
|
for d in sorted(tasks_dir.iterdir()):
|
||||||
|
if not d.is_dir() or d.name == "archive":
|
||||||
|
continue
|
||||||
|
info = load_task(d)
|
||||||
|
if info is not None:
|
||||||
|
yield info
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_statuses(tasks_dir: Path) -> dict[str, str]:
|
||||||
|
"""Get a {dir_name: status} mapping for all active tasks.
|
||||||
|
|
||||||
|
Useful for computing children progress without loading full TaskInfo.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tasks_dir: Path to the tasks directory.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping directory names to status strings.
|
||||||
|
"""
|
||||||
|
return {t.dir_name: t.status for t in iter_active_tasks(tasks_dir)}
|
||||||
|
|
||||||
|
|
||||||
|
def children_progress(
|
||||||
|
children: tuple[str, ...] | list[str],
|
||||||
|
all_statuses: dict[str, str],
|
||||||
|
) -> str:
|
||||||
|
"""Format children progress string like " [2/3 done]".
|
||||||
|
|
||||||
|
Args:
|
||||||
|
children: List of child directory names.
|
||||||
|
all_statuses: Status map from get_all_statuses().
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string, or "" if no children.
|
||||||
|
"""
|
||||||
|
if not children:
|
||||||
|
return ""
|
||||||
|
# A child missing from active statuses has been archived (cmd_archive
|
||||||
|
# sets status=completed before moving the dir). Count it as done so
|
||||||
|
# parent progress doesn't regress when children are archived.
|
||||||
|
done = sum(
|
||||||
|
1 for c in children
|
||||||
|
if c not in all_statuses or all_statuses.get(c) in ("completed", "done")
|
||||||
|
)
|
||||||
|
return f" [{done}/{len(children)} done]"
|
||||||
Executable
+131
@@ -0,0 +1,131 @@
|
|||||||
|
#!/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 {}
|
||||||
Executable
+110
@@ -0,0 +1,110 @@
|
|||||||
|
"""
|
||||||
|
Core type definitions for Trellis task data.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
TaskData — TypedDict for task.json shape (read-path type hints only)
|
||||||
|
TaskInfo — Frozen dataclass for loaded task (the public API type)
|
||||||
|
AgentRecord — TypedDict for registry.json agent entries
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# task.json shape (TypedDict — used only for read-path type hints)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TaskData(TypedDict, total=False):
|
||||||
|
"""Shape of task.json on disk.
|
||||||
|
|
||||||
|
Used only for type annotations when reading task.json.
|
||||||
|
Writes must use the original dict to avoid losing unknown fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
status: str
|
||||||
|
dev_type: str
|
||||||
|
scope: str | None
|
||||||
|
package: str | None
|
||||||
|
priority: str
|
||||||
|
creator: str
|
||||||
|
assignee: str
|
||||||
|
createdAt: str
|
||||||
|
completedAt: str | None
|
||||||
|
branch: str | None
|
||||||
|
base_branch: str | None
|
||||||
|
worktree_path: str | None
|
||||||
|
commit: str | None
|
||||||
|
pr_url: str | None
|
||||||
|
subtasks: list[str]
|
||||||
|
children: list[str]
|
||||||
|
parent: str | None
|
||||||
|
relatedFiles: list[str]
|
||||||
|
notes: str
|
||||||
|
meta: dict
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Loaded task object (frozen dataclass — the public API type)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TaskInfo:
|
||||||
|
"""Immutable view of a loaded task.
|
||||||
|
|
||||||
|
Created by load_task() / iter_active_tasks().
|
||||||
|
Contains the commonly accessed fields; the original dict
|
||||||
|
is preserved in `raw` for write-back and uncommon field access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
dir_name: str
|
||||||
|
directory: Path
|
||||||
|
title: str
|
||||||
|
status: str
|
||||||
|
assignee: str
|
||||||
|
priority: str
|
||||||
|
children: tuple[str, ...]
|
||||||
|
parent: str | None
|
||||||
|
package: str | None
|
||||||
|
raw: dict # original dict — use for writes and uncommon fields
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Task name (id or name field)."""
|
||||||
|
return self.raw.get("name") or self.raw.get("id") or self.dir_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
return self.raw.get("description", "")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def branch(self) -> str | None:
|
||||||
|
return self.raw.get("branch")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def meta(self) -> dict:
|
||||||
|
return self.raw.get("meta", {})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# registry.json agent entry
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class AgentRecord(TypedDict, total=False):
|
||||||
|
"""Shape of an agent entry in registry.json."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
pid: int
|
||||||
|
task_dir: str
|
||||||
|
worktree_path: str
|
||||||
|
branch: str
|
||||||
|
platform: str
|
||||||
|
started_at: str
|
||||||
|
status: str
|
||||||
Executable
+212
@@ -0,0 +1,212 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Workflow Phase Extraction.
|
||||||
|
|
||||||
|
Extracts step-level content from .trellis/workflow.md and optionally filters
|
||||||
|
platform-specific blocks.
|
||||||
|
|
||||||
|
Platform marker syntax in workflow.md:
|
||||||
|
|
||||||
|
[Claude Code, Cursor, ...]
|
||||||
|
agent-capable content
|
||||||
|
[/Claude Code, Cursor, ...]
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
get_phase_index - Extract the Phase Index section (no --step)
|
||||||
|
get_step - Extract a single step (#### X.X) section
|
||||||
|
filter_platform - Strip platform blocks that don't include the given name
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from .paths import DIR_WORKFLOW, get_repo_root
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow_md_path():
|
||||||
|
return get_repo_root() / DIR_WORKFLOW / "workflow.md"
|
||||||
|
|
||||||
|
# Match a line that *is* a platform marker: "[A, B, C]" or "[/A, B, C]"
|
||||||
|
_MARKER_RE = re.compile(r"^\[(/?)([A-Za-z][^\[\]]*)\]\s*$")
|
||||||
|
|
||||||
|
# Step heading: "#### 1.0 Title" or "#### 1.0 ..."
|
||||||
|
_STEP_HEADING_RE = re.compile(r"^####\s+(\d+\.\d+)\b.*$")
|
||||||
|
|
||||||
|
# Phase Index starts here; Phase 1/2/3 step bodies follow; ends at Breadcrumbs.
|
||||||
|
_PHASE_INDEX_HEADING = "## Phase Index"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_workflow() -> str:
|
||||||
|
path = _workflow_md_path()
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(f"workflow.md not found: {path}")
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_marker(line: str) -> tuple[bool, list[str]] | None:
|
||||||
|
"""Parse a platform marker line.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(is_closing, [platform_names]) if line is a marker, else None.
|
||||||
|
"""
|
||||||
|
m = _MARKER_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
is_closing = m.group(1) == "/"
|
||||||
|
names = [p.strip() for p in m.group(2).split(",") if p.strip()]
|
||||||
|
return is_closing, names
|
||||||
|
|
||||||
|
|
||||||
|
def get_phase_index() -> str:
|
||||||
|
"""Return the compact Phase Index summary from workflow.md.
|
||||||
|
|
||||||
|
SessionStart and no-step phase context use this small summary as their
|
||||||
|
orientation payload. Detailed Phase 1/2/3 instructions are loaded with
|
||||||
|
``get_step`` on demand. ``[workflow-state:STATUS]`` tag blocks are
|
||||||
|
consumed by the per-turn hook, so they're stripped from this output.
|
||||||
|
"""
|
||||||
|
text = _read_workflow()
|
||||||
|
lines = text.splitlines()
|
||||||
|
|
||||||
|
start: int | None = None
|
||||||
|
end: int | None = None
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
stripped = line.strip()
|
||||||
|
if start is None and stripped == _PHASE_INDEX_HEADING:
|
||||||
|
start = i
|
||||||
|
continue
|
||||||
|
if start is not None and stripped == "## Phase 1: Plan":
|
||||||
|
end = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if start is None:
|
||||||
|
return ""
|
||||||
|
if end is None:
|
||||||
|
end = len(lines)
|
||||||
|
|
||||||
|
section = "\n".join(lines[start:end]).rstrip()
|
||||||
|
# Strip [workflow-state:STATUS]...[/workflow-state:STATUS] blocks since
|
||||||
|
# they're injected separately by inject-workflow-state.py per-turn.
|
||||||
|
import re as _re
|
||||||
|
tag_re = _re.compile(
|
||||||
|
r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n.*?\n\s*\[/workflow-state:\1\]\n?",
|
||||||
|
_re.DOTALL,
|
||||||
|
)
|
||||||
|
return tag_re.sub("", section).rstrip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def get_step(step_id: str) -> str:
|
||||||
|
"""Return the `#### X.X` section matching step_id (header + body).
|
||||||
|
|
||||||
|
Body ends at the next `####` or `---` or `##` heading (whichever comes first).
|
||||||
|
"""
|
||||||
|
text = _read_workflow()
|
||||||
|
lines = text.splitlines()
|
||||||
|
|
||||||
|
start: int | None = None
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
m = _STEP_HEADING_RE.match(line)
|
||||||
|
if m and m.group(1) == step_id:
|
||||||
|
start = i
|
||||||
|
break
|
||||||
|
if start is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
end: int = len(lines)
|
||||||
|
for j in range(start + 1, len(lines)):
|
||||||
|
line = lines[j]
|
||||||
|
if line.startswith("#### "):
|
||||||
|
end = j
|
||||||
|
break
|
||||||
|
if line.startswith("## "):
|
||||||
|
end = j
|
||||||
|
break
|
||||||
|
# Horizontal rule at column 0
|
||||||
|
if line.strip() == "---":
|
||||||
|
end = j
|
||||||
|
break
|
||||||
|
|
||||||
|
return "\n".join(lines[start:end]).rstrip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _platform_matches(platform: str, block_names: list[str]) -> bool:
|
||||||
|
"""Case-insensitive fuzzy match: accept 'cursor', 'Cursor', 'claude-code', 'Claude Code'."""
|
||||||
|
needle = platform.lower().replace("-", "").replace("_", "").replace(" ", "")
|
||||||
|
for name in block_names:
|
||||||
|
hay = name.lower().replace("-", "").replace("_", "").replace(" ", "")
|
||||||
|
if needle == hay:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_effective_platform(platform: str, config: dict) -> str:
|
||||||
|
"""Map ``codex`` to a dispatch-mode-namespaced virtual platform name.
|
||||||
|
|
||||||
|
When ``--platform codex`` is passed, return ``"codex-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, Devin]``).
|
||||||
|
|
||||||
|
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"
|
||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Get Session Context for AI Agent.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 get_context.py Output context in text format
|
||||||
|
python3 get_context.py --json Output context in JSON format
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from common.git_context import main
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Get current developer name.
|
||||||
|
|
||||||
|
This is a wrapper that uses common/paths.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from common.paths import get_developer
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""CLI entry point."""
|
||||||
|
developer = get_developer()
|
||||||
|
if developer:
|
||||||
|
print(developer)
|
||||||
|
else:
|
||||||
|
print("Developer not initialized", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+243
@@ -0,0 +1,243 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Linear sync hook for Trellis task lifecycle.
|
||||||
|
|
||||||
|
Syncs task events to Linear via the `linearis` CLI.
|
||||||
|
|
||||||
|
Usage (called automatically by task.py hooks):
|
||||||
|
python3 .trellis/scripts/hooks/linear_sync.py create
|
||||||
|
python3 .trellis/scripts/hooks/linear_sync.py start
|
||||||
|
python3 .trellis/scripts/hooks/linear_sync.py archive
|
||||||
|
|
||||||
|
Manual usage:
|
||||||
|
TASK_JSON_PATH=.trellis/tasks/<name>/task.json python3 .trellis/scripts/hooks/linear_sync.py sync
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
TASK_JSON_PATH - Absolute path to task.json (set by task.py)
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
.trellis/hooks.local.json - Local config (gitignored), example:
|
||||||
|
{
|
||||||
|
"linear": {
|
||||||
|
"team": "TEAM_KEY",
|
||||||
|
"project": "Project Name",
|
||||||
|
"assignees": {
|
||||||
|
"dev-name": "linear-user-id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ─── Configuration ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Trellis priority → Linear priority (1=Urgent, 2=High, 3=Medium, 4=Low)
|
||||||
|
PRIORITY_MAP = {"P0": 1, "P1": 2, "P2": 3, "P3": 4}
|
||||||
|
|
||||||
|
# Linear status names (must match your team's workflow)
|
||||||
|
STATUS_IN_PROGRESS = "In Progress"
|
||||||
|
STATUS_DONE = "Done"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config() -> dict:
|
||||||
|
"""Load local hook config from .trellis/hooks.local.json."""
|
||||||
|
task_json_path = os.environ.get("TASK_JSON_PATH", "")
|
||||||
|
if task_json_path:
|
||||||
|
# Walk up from task.json to find .trellis/
|
||||||
|
trellis_dir = Path(task_json_path).parent.parent.parent
|
||||||
|
else:
|
||||||
|
trellis_dir = Path(".trellis")
|
||||||
|
|
||||||
|
config_path = trellis_dir / "hooks.local.json"
|
||||||
|
try:
|
||||||
|
with open(config_path, encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG = _load_config()
|
||||||
|
LINEAR_CFG = CONFIG.get("linear", {})
|
||||||
|
|
||||||
|
TEAM = LINEAR_CFG.get("team", "")
|
||||||
|
PROJECT = LINEAR_CFG.get("project", "")
|
||||||
|
ASSIGNEE_MAP = LINEAR_CFG.get("assignees", {})
|
||||||
|
|
||||||
|
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _read_task() -> tuple[dict, str]:
|
||||||
|
path = os.environ.get("TASK_JSON_PATH", "")
|
||||||
|
if not path:
|
||||||
|
print("TASK_JSON_PATH not set", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
return json.load(f), path
|
||||||
|
|
||||||
|
|
||||||
|
def _write_task(data: dict, path: str) -> None:
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _linearis(*args: str) -> dict | None:
|
||||||
|
result = subprocess.run(
|
||||||
|
["linearis", *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"linearis error: {result.stderr.strip()}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
stdout = result.stdout.strip()
|
||||||
|
if stdout:
|
||||||
|
return json.loads(stdout)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_linear_issue(task: dict) -> str | None:
|
||||||
|
meta = task.get("meta")
|
||||||
|
if isinstance(meta, dict):
|
||||||
|
return meta.get("linear_issue")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Actions ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_create() -> None:
|
||||||
|
if not TEAM:
|
||||||
|
print("No linear.team configured in hooks.local.json", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
task, path = _read_task()
|
||||||
|
|
||||||
|
# Skip if already linked
|
||||||
|
if _get_linear_issue(task):
|
||||||
|
print(f"Already linked: {_get_linear_issue(task)}")
|
||||||
|
return
|
||||||
|
|
||||||
|
title = task.get("title") or task.get("name") or "Untitled"
|
||||||
|
args = ["issues", "create", title, "--team", TEAM]
|
||||||
|
|
||||||
|
# Map priority
|
||||||
|
priority = PRIORITY_MAP.get(task.get("priority", ""), 0)
|
||||||
|
if priority:
|
||||||
|
args.extend(["-p", str(priority)])
|
||||||
|
|
||||||
|
# Set project
|
||||||
|
if PROJECT:
|
||||||
|
args.extend(["--project", PROJECT])
|
||||||
|
|
||||||
|
# Assign to Linear user
|
||||||
|
assignee = task.get("assignee", "")
|
||||||
|
linear_user_id = ASSIGNEE_MAP.get(assignee)
|
||||||
|
if linear_user_id:
|
||||||
|
args.extend(["--assignee", linear_user_id])
|
||||||
|
|
||||||
|
# Link to parent's Linear issue if available
|
||||||
|
parent_issue = _resolve_parent_linear_issue(task)
|
||||||
|
if parent_issue:
|
||||||
|
args.extend(["--parent-ticket", parent_issue])
|
||||||
|
|
||||||
|
result = _linearis(*args)
|
||||||
|
if result and "identifier" in result:
|
||||||
|
if not isinstance(task.get("meta"), dict):
|
||||||
|
task["meta"] = {}
|
||||||
|
task["meta"]["linear_issue"] = result["identifier"]
|
||||||
|
_write_task(task, path)
|
||||||
|
print(f"Created Linear issue: {result['identifier']}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_start() -> None:
|
||||||
|
task, _ = _read_task()
|
||||||
|
issue = _get_linear_issue(task)
|
||||||
|
if not issue:
|
||||||
|
return
|
||||||
|
_linearis("issues", "update", issue, "-s", STATUS_IN_PROGRESS)
|
||||||
|
print(f"Updated {issue} -> {STATUS_IN_PROGRESS}")
|
||||||
|
cmd_sync()
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_archive() -> None:
|
||||||
|
task, _ = _read_task()
|
||||||
|
issue = _get_linear_issue(task)
|
||||||
|
if not issue:
|
||||||
|
return
|
||||||
|
_linearis("issues", "update", issue, "-s", STATUS_DONE)
|
||||||
|
print(f"Updated {issue} -> {STATUS_DONE}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_sync() -> None:
|
||||||
|
"""Sync prd.md content to Linear issue description."""
|
||||||
|
task, _ = _read_task()
|
||||||
|
issue = _get_linear_issue(task)
|
||||||
|
if not issue:
|
||||||
|
print("No linear_issue in meta, run create first", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Find prd.md next to task.json
|
||||||
|
task_json_path = os.environ.get("TASK_JSON_PATH", "")
|
||||||
|
prd_path = Path(task_json_path).parent / "prd.md"
|
||||||
|
if not prd_path.is_file():
|
||||||
|
print(f"No prd.md found at {prd_path}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
description = prd_path.read_text(encoding="utf-8").strip()
|
||||||
|
_linearis("issues", "update", issue, "-d", description)
|
||||||
|
print(f"Synced prd.md to {issue} description")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Parent Issue Resolution ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_parent_linear_issue(task: dict) -> str | None:
|
||||||
|
"""Find parent task's Linear issue identifier."""
|
||||||
|
parent_name = task.get("parent")
|
||||||
|
if not parent_name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
task_json_path = os.environ.get("TASK_JSON_PATH", "")
|
||||||
|
if not task_json_path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
current_task_dir = Path(task_json_path).parent
|
||||||
|
tasks_dir = current_task_dir.parent
|
||||||
|
parent_json = tasks_dir / parent_name / "task.json"
|
||||||
|
|
||||||
|
if parent_json.exists():
|
||||||
|
try:
|
||||||
|
with open(parent_json, encoding="utf-8") as f:
|
||||||
|
parent_task = json.load(f)
|
||||||
|
return _get_linear_issue(parent_task)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
action = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||||
|
actions = {
|
||||||
|
"create": cmd_create,
|
||||||
|
"start": cmd_start,
|
||||||
|
"archive": cmd_archive,
|
||||||
|
"sync": cmd_sync,
|
||||||
|
}
|
||||||
|
fn = actions.get(action)
|
||||||
|
if fn:
|
||||||
|
fn()
|
||||||
|
else:
|
||||||
|
print(f"Unknown action: {action}", file=sys.stderr)
|
||||||
|
print(f"Valid actions: {', '.join(actions)}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
Executable
+51
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Initialize developer for workflow.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 init_developer.py <developer-name>
|
||||||
|
|
||||||
|
This creates:
|
||||||
|
- .trellis/.developer file with developer info
|
||||||
|
- .trellis/workspace/<name>/ directory structure
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from common.paths import (
|
||||||
|
DIR_WORKFLOW,
|
||||||
|
FILE_DEVELOPER,
|
||||||
|
get_developer,
|
||||||
|
)
|
||||||
|
from common.developer import init_developer
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""CLI entry point."""
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(f"Usage: {sys.argv[0]} <developer-name>")
|
||||||
|
print()
|
||||||
|
print("Example:")
|
||||||
|
print(f" {sys.argv[0]} john")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
name = sys.argv[1]
|
||||||
|
|
||||||
|
# Check if already initialized
|
||||||
|
existing = get_developer()
|
||||||
|
if existing:
|
||||||
|
print(f"Developer already initialized: {existing}")
|
||||||
|
print()
|
||||||
|
print(f"To reinitialize, remove {DIR_WORKFLOW}/{FILE_DEVELOPER} first")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if init_developer(name):
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+500
@@ -0,0 +1,500 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Task Management Script.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] [--package <pkg>]
|
||||||
|
python3 task.py add-context <dir> <file> <path> [reason] # Add jsonl entry
|
||||||
|
python3 task.py validate <dir> # Validate jsonl files
|
||||||
|
python3 task.py list-context <dir> # List jsonl entries
|
||||||
|
python3 task.py start <dir> # Set active task
|
||||||
|
python3 task.py current [--source] # Show active task
|
||||||
|
python3 task.py finish # Clear active task
|
||||||
|
python3 task.py set-branch <dir> <branch> # Set git branch
|
||||||
|
python3 task.py set-base-branch <dir> <branch> # Set PR target branch
|
||||||
|
python3 task.py set-scope <dir> <scope> # Set scope for PR title
|
||||||
|
python3 task.py archive <task-dir> # Archive completed task
|
||||||
|
python3 task.py list # List active tasks
|
||||||
|
python3 task.py list-archive [month] # List archived tasks
|
||||||
|
python3 task.py add-subtask <parent-dir> <child-dir> # Link child to parent
|
||||||
|
python3 task.py remove-subtask <parent-dir> <child-dir> # Unlink child from parent
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import 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:
|
||||||
|
python3 task.py create <title> Create new task directory
|
||||||
|
python3 task.py create <title> --package <pkg> Create task for a specific package
|
||||||
|
python3 task.py create <title> --parent <dir> Create task as child of parent
|
||||||
|
python3 task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl
|
||||||
|
python3 task.py validate <dir> Validate jsonl files
|
||||||
|
python3 task.py list-context <dir> List jsonl entries
|
||||||
|
python3 task.py start <dir> Set active task
|
||||||
|
python3 task.py current [--source] Show active task
|
||||||
|
python3 task.py finish Clear active task
|
||||||
|
python3 task.py set-branch <dir> <branch> Set git branch
|
||||||
|
python3 task.py set-base-branch <dir> <branch> Set PR target branch
|
||||||
|
python3 task.py set-scope <dir> <scope> Set scope for PR title
|
||||||
|
python3 task.py archive <task-dir> Archive completed task
|
||||||
|
python3 task.py add-subtask <parent> <child> Link child task to parent
|
||||||
|
python3 task.py remove-subtask <parent> <child> Unlink child from parent
|
||||||
|
python3 task.py list [--mine] [--status <status>] List tasks
|
||||||
|
python3 task.py list-archive [YYYY-MM] List archived tasks
|
||||||
|
|
||||||
|
Monorepo options:
|
||||||
|
--package <pkg> Package name (validated against config.yaml packages)
|
||||||
|
|
||||||
|
List options:
|
||||||
|
--mine, -m Show only tasks assigned to current developer
|
||||||
|
--status, -s <s> Filter by status (planning, in_progress, review, completed)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python3 task.py create "Add login feature" --slug add-login
|
||||||
|
python3 task.py create "Add login feature" --slug add-login --package cli
|
||||||
|
python3 task.py create "Child task" --slug child --parent .trellis/tasks/01-21-parent
|
||||||
|
python3 task.py add-context <dir> implement .trellis/spec/cli/backend/auth.md "Auth guidelines"
|
||||||
|
python3 task.py set-branch <dir> task/add-login
|
||||||
|
python3 task.py start .trellis/tasks/01-21-add-login
|
||||||
|
python3 task.py current --source
|
||||||
|
python3 task.py finish
|
||||||
|
python3 task.py archive add-login
|
||||||
|
python3 task.py add-subtask parent-task child-task # Link existing tasks
|
||||||
|
python3 task.py remove-subtask parent-task child-task
|
||||||
|
python3 task.py list # List all active tasks
|
||||||
|
python3 task.py list --mine # List my tasks only
|
||||||
|
python3 task.py list --mine --status in_progress # List my in-progress tasks
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Main Entry
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""CLI entry point."""
|
||||||
|
# Deprecation guard: `init-context` was removed in v0.5.0-beta.12.
|
||||||
|
# Detect early so argparse doesn't mask the real reason with a generic
|
||||||
|
# "invalid choice" error.
|
||||||
|
if len(sys.argv) >= 2 and sys.argv[1] == "init-context":
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
"Error: `task.py init-context` was removed in v0.5.0-beta.12.",
|
||||||
|
Colors.RED,
|
||||||
|
),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"implement.jsonl / check.jsonl are now seeded on `task.py create` for",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"sub-agent-capable platforms and curated by the AI during planning when needed.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print("See .trellis/workflow.md planning artifact guidance or run:", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
" python3 ./.trellis/scripts/get_context.py --mode phase --step 1",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"Use `task.py add-context <dir> implement|check <path> <reason>` to append entries.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Task Management Script",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
|
subparsers = parser.add_subparsers(dest="command", help="Commands")
|
||||||
|
|
||||||
|
# create
|
||||||
|
p_create = subparsers.add_parser("create", help="Create new task")
|
||||||
|
p_create.add_argument("title", help="Task title")
|
||||||
|
p_create.add_argument("--slug", "-s", help="Task slug")
|
||||||
|
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())
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
### Pattern 4: Repeated Payload Field Extraction
|
||||||
|
|
||||||
|
**Bad**: Multiple consumers cast the same JSON/event fields locally:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const description = (ev as { description?: string }).description;
|
||||||
|
const context = (ev as { context?: ContextEntry[] }).context;
|
||||||
|
```
|
||||||
|
|
||||||
|
This is duplicated contract logic even when the code is only two lines. Each
|
||||||
|
consumer now has its own definition of what a valid payload means.
|
||||||
|
|
||||||
|
**Good**: Put the decoder, type guard, or projection next to the data owner:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (isThreadEvent(ev)) {
|
||||||
|
renderThreadEvent(ev);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule**: If the same untyped payload field is read in 2+ places, create a
|
||||||
|
shared type guard / normalizer / projection before adding a third reader.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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?
|
||||||
|
|
||||||
|
### Reducers Should Use Exhaustive Structure
|
||||||
|
|
||||||
|
When state is derived from action-like values (`action`, `kind`, `status`,
|
||||||
|
`phase`), prefer a reducer with one `switch` over scattered `if/else` updates.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// BAD - action-specific state transitions are hard to audit
|
||||||
|
if (action === "opened") { ... }
|
||||||
|
else if (action === "comment") { ... }
|
||||||
|
else if (action === "status") { ... }
|
||||||
|
|
||||||
|
// GOOD - one reducer owns the transition table
|
||||||
|
switch (event.action) {
|
||||||
|
case "opened":
|
||||||
|
...
|
||||||
|
return;
|
||||||
|
case "comment":
|
||||||
|
...
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This matters when the event log is the source of truth. A reducer is the
|
||||||
|
documented replay model; display code and commands should not duplicate pieces
|
||||||
|
of that replay model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist Before Commit
|
||||||
|
|
||||||
|
- [ ] Searched for existing similar code
|
||||||
|
- [ ] No copy-pasted logic that should be shared
|
||||||
|
- [ ] No repeated untyped payload field extraction outside a shared decoder
|
||||||
|
- [ ] Constants defined in one place
|
||||||
|
- [ ] Similar patterns follow same structure
|
||||||
|
- [ ] Reducer/action transitions live in one reducer or command dispatcher
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gotcha: Python if/elif/else Exhaustive Check
|
||||||
|
|
||||||
|
**Problem**: Python's if/elif/else chains have no compile-time exhaustive check. When you add a new value to a `Literal` type (e.g., `Platform`), existing if/elif/else chains silently fall through to `else` with wrong defaults.
|
||||||
|
|
||||||
|
**Symptom**: New platform works partially — some methods return Claude defaults instead of platform-specific values. No error is raised.
|
||||||
|
|
||||||
|
**Example** (`cli_adapter.py`):
|
||||||
|
```python
|
||||||
|
# BAD: "gemini" falls through to else, returns "claude"
|
||||||
|
@property
|
||||||
|
def cli_name(self) -> str:
|
||||||
|
if self.platform == "opencode":
|
||||||
|
return "opencode"
|
||||||
|
else:
|
||||||
|
return "claude" # gemini silently gets "claude"!
|
||||||
|
|
||||||
|
# GOOD: explicit branch for every platform
|
||||||
|
@property
|
||||||
|
def cli_name(self) -> str:
|
||||||
|
if self.platform == "opencode":
|
||||||
|
return "opencode"
|
||||||
|
elif self.platform == "gemini":
|
||||||
|
return "gemini"
|
||||||
|
else:
|
||||||
|
return "claude"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prevention**: When adding a new value to a Python `Literal` type, search for ALL if/elif/else chains that switch on that type and add explicit branches. Don't rely on `else` being correct for new values.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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**:
|
||||||
|
- **Best**: Eliminate the asymmetry — have the manual path call the automatic one (e.g., `collectTemplateFiles()` calls `getAllScripts()` instead of maintaining its own list)
|
||||||
|
- **If asymmetry is unavoidable**: Add a regression test that compares outputs from both mechanisms
|
||||||
|
- When migrating directory structures, search for ALL code paths that reference the old structure
|
||||||
|
|
||||||
|
**Real example**: `trellis update` had a manual `files.set()` list for 11 scripts that `getAllScripts()` already tracked. Fix: replaced the manual list with a `for..of getAllScripts()` loop. See `update.ts` refactor in v0.4.0-beta.3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Template File Registration (Trellis-specific)
|
||||||
|
|
||||||
|
When adding new files to `src/templates/trellis/scripts/`:
|
||||||
|
|
||||||
|
**Single registration point**: `src/templates/trellis/index.ts`
|
||||||
|
|
||||||
|
1. Add `export const xxxScript = readTemplate("scripts/path/file.py");`
|
||||||
|
2. Add to `getAllScripts()` Map
|
||||||
|
|
||||||
|
That's it. `commands/update.ts` uses `getAllScripts()` directly — no manual sync needed.
|
||||||
|
|
||||||
|
**Why this matters**: Without registration in `getAllScripts()`, `trellis update` won't sync the file to user projects. Bug fixes and features won't propagate.
|
||||||
|
|
||||||
|
**History**: Before v0.4.0-beta.3, `update.ts` had its own hand-maintained file list that frequently fell out of sync with `getAllScripts()`. This caused 11 Python files to be silently skipped during `trellis update`. The fix was to eliminate the duplicate list and use `getAllScripts()` as the single source of truth.
|
||||||
|
|
||||||
|
### Quick Checklist for New Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# After adding a new .py file, verify it's in getAllScripts():
|
||||||
|
grep -l "newFileName" src/templates/trellis/index.ts # Should match
|
||||||
|
```
|
||||||
|
|
||||||
|
### Template Sync Convention
|
||||||
|
|
||||||
|
`.trellis/scripts/` (dogfooded) and `packages/cli/src/templates/trellis/scripts/` (template) must stay identical. After editing `.trellis/scripts/`, always sync:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rsync -av --delete --exclude='__pycache__' .trellis/scripts/ packages/cli/src/templates/trellis/scripts/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha**: Running rsync with wrong source/destination paths can create nested garbage directories (e.g., `.trellis/scripts/packages/cli/...`). Always double-check paths before running.
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
### Mistake 4: Every Consumer Parses The Same Payload
|
||||||
|
|
||||||
|
**Bad**: A command reads JSONL events and casts fields inline:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const thread = (ev as { thread?: string }).thread;
|
||||||
|
const labels = (ev as { labels?: string[] }).labels;
|
||||||
|
```
|
||||||
|
|
||||||
|
This looks local, but it means every consumer owns a private version of the
|
||||||
|
event contract. The next field change will update one command and miss another.
|
||||||
|
|
||||||
|
**Good**: Decode once at the event boundary, then export typed projections:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (!isThreadEvent(ev)) return false;
|
||||||
|
return ev.thread === filter.thread;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule**: For append-only logs, JSON streams, RPC payloads, or config files,
|
||||||
|
create one owner for:
|
||||||
|
|
||||||
|
- event / payload type definitions
|
||||||
|
- type guards and normalization from `unknown`
|
||||||
|
- metadata projections used by UI commands
|
||||||
|
- reducers that replay state from the source of truth
|
||||||
|
|
||||||
|
Rendering code may format fields, but it must not redefine the payload contract.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- [ ] Checked that consumers import shared decoders / projections instead of
|
||||||
|
casting payload fields locally
|
||||||
|
- [ ] Checked that derived state points back to the source event identifier
|
||||||
|
(`seq`, `id`, `version`) instead of inventing a second cursor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Versioned Documentation Boundary
|
||||||
|
|
||||||
|
Versioned documentation is a cross-layer boundary: source paths, `docs.json`
|
||||||
|
version routing, and the rendered version selector must all describe the same
|
||||||
|
release line.
|
||||||
|
|
||||||
|
### Checklist: Before Editing Versioned Docs
|
||||||
|
|
||||||
|
- [ ] Identify the target release line: stable, beta, or RC
|
||||||
|
- [ ] Verify the edited MDX path matches that line:
|
||||||
|
- stable: `docs-site/{start,advanced,...}` and `docs-site/zh/{start,advanced,...}`
|
||||||
|
- beta: `docs-site/beta/**` and `docs-site/zh/beta/**`
|
||||||
|
- RC: `docs-site/rc/**` and `docs-site/zh/rc/**`
|
||||||
|
- [ ] Verify `docs.json` navigation points the version label to the same paths
|
||||||
|
- [ ] Grep the opposite tree for release-line-specific terms before committing
|
||||||
|
- [ ] Treat beta content appearing under root release paths as a source-path bug,
|
||||||
|
not a rendering bug
|
||||||
|
|
||||||
|
**Real-world example**: A beta-only task workflow change documented
|
||||||
|
`prd.md` + `design.md` + `implement.md`, task-creation consent, and Codex
|
||||||
|
mode banners under root `start/` and `advanced/` paths. The docs site then
|
||||||
|
served 0.6 beta behavior under the Release selector. The fix was to restore root
|
||||||
|
release docs, move the 0.6 content to `beta/` and `zh/beta/`, and add a grep
|
||||||
|
audit for beta markers against the root release tree.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Event Log / Projection Boundary
|
||||||
|
|
||||||
|
Append-only logs are cross-layer contracts. A single event travels through:
|
||||||
|
|
||||||
|
```
|
||||||
|
CLI input → event writer → events.jsonl → reader → filter → reducer → display
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checklist: After Adding A New Event Kind Or Field
|
||||||
|
|
||||||
|
- [ ] Add the event kind to the central event taxonomy
|
||||||
|
- [ ] Add a typed event variant or type guard at the event layer
|
||||||
|
- [ ] Add normalization helpers for array/object fields that come from
|
||||||
|
user input or JSON
|
||||||
|
- [ ] Keep `seq` / `id` assignment in the event writer only
|
||||||
|
- [ ] Make filters and reducers consume the typed event guard, not local casts
|
||||||
|
- [ ] Make display code consume reducer output or typed events, not raw JSON
|
||||||
|
- [ ] Add at least one regression that proves history replay and live filtering
|
||||||
|
use the same filter model
|
||||||
|
|
||||||
|
**Real-world example**: Thread channels added `kind: "thread"`, `description`,
|
||||||
|
`context`, labels, and `lastSeq`. The first implementation replayed thread
|
||||||
|
state correctly, but several commands still re-parsed event payload fields with
|
||||||
|
local casts. The fix was to make the core event layer own `ThreadChannelEvent`
|
||||||
|
and `isThreadEvent`, make `reduceChannelMetadata` the only channel metadata
|
||||||
|
projection, and make `reduceThreads` the only thread replay reducer.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# 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
|
||||||
|
- [ ] You are adding an event kind, JSONL record, RPC payload, or config field
|
||||||
|
- [ ] UI / command code starts casting raw payload fields directly
|
||||||
|
|
||||||
|
→ 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!
|
||||||
|
- [ ] Two files read the same untyped payload field with local casts
|
||||||
|
- [ ] Multiple branches update the same derived state from `kind` / `action`
|
||||||
|
|
||||||
|
→ Read [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md)
|
||||||
|
|
||||||
|
### When Verifying AI Cross-Review Results
|
||||||
|
|
||||||
|
- [ ] Reviewer claims "user input can be malicious" → Check the actual data source (internal manifest? user config? external API?)
|
||||||
|
- [ ] Reviewer flags "missing validation" → Is the data from a trusted internal source?
|
||||||
|
- [ ] Reviewer says "behavior change" → Read the code comments — is it intentional design?
|
||||||
|
- [ ] Reviewer identifies a "bug" in test → Mentally delete the feature being tested — does the test still pass? If yes → tautological test
|
||||||
|
|
||||||
|
**Common AI reviewer false-positive patterns**:
|
||||||
|
1. **Trust boundary confusion**: Treating internal data (bundled JSON manifests) as untrusted external input
|
||||||
|
2. **Ignoring design comments**: Flagging intentional behavior documented in code comments as bugs
|
||||||
|
3. **Variable misreading**: Not tracing a variable to its actual definition (e.g., Map keyed by path vs name)
|
||||||
|
|
||||||
|
**Verification rule**: Every CRITICAL/WARNING finding must be verified against the actual code before prioritizing. Budget ~35% false-positive rate for AI reviews.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 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)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 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)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 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)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 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 | To fill |
|
||||||
|
| [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**.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 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)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# State Management
|
||||||
|
|
||||||
|
> How state is managed in this project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Document your project's state management conventions here.
|
||||||
|
|
||||||
|
Questions to answer:
|
||||||
|
- What state management solution do you use?
|
||||||
|
- How is local vs global state decided?
|
||||||
|
- How do you handle server state?
|
||||||
|
- What are the patterns for derived state?
|
||||||
|
-->
|
||||||
|
|
||||||
|
(To be filled by the team)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State Categories
|
||||||
|
|
||||||
|
<!-- Local state, global state, server state, URL state -->
|
||||||
|
|
||||||
|
(To be filled by the team)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
<!-- State management mistakes your team has made -->
|
||||||
|
|
||||||
|
(To be filled by the team)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 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)
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# 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 guidelines for vendor/ComfyUI_frontend
|
||||||
|
- [ ] Add code examples
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spec files to populate
|
||||||
|
|
||||||
|
### Package: vendor/ComfyUI_frontend (`spec/vendor/ComfyUI_frontend/`)
|
||||||
|
|
||||||
|
- Frontend guidelines: `.trellis/spec/vendor/ComfyUI_frontend/frontend/`
|
||||||
|
|
||||||
|
|
||||||
|
### 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
|
||||||
|
python3 ./.trellis/scripts/task.py finish
|
||||||
|
python3 ./.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?"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"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": "boos4721",
|
||||||
|
"assignee": "boos4721",
|
||||||
|
"createdAt": "2026-06-20",
|
||||||
|
"completedAt": null,
|
||||||
|
"branch": null,
|
||||||
|
"base_branch": null,
|
||||||
|
"worktree_path": null,
|
||||||
|
"commit": null,
|
||||||
|
"pr_url": null,
|
||||||
|
"subtasks": [],
|
||||||
|
"children": [],
|
||||||
|
"parent": null,
|
||||||
|
"relatedFiles": [
|
||||||
|
".trellis/spec/vendor/ComfyUI_frontend/"
|
||||||
|
],
|
||||||
|
"notes": "First-time setup task created by trellis init (fullstack project)",
|
||||||
|
"meta": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,706 @@
|
|||||||
|
# Development Workflow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
1. **Plan before code** — figure out what to do before you start
|
||||||
|
2. **Specs injected, not remembered** — guidelines are injected via hook/skill, not recalled from memory
|
||||||
|
3. **Persist everything** — research, decisions, and lessons all go to files; conversations get compacted, files don't
|
||||||
|
4. **Incremental development** — one task at a time
|
||||||
|
5. **Capture learnings** — after each task, review and write new knowledge back to spec
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Trellis System
|
||||||
|
|
||||||
|
### Developer Identity
|
||||||
|
|
||||||
|
On first use, initialize your identity:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./.trellis/scripts/init_developer.py <your-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates `.trellis/.developer` (gitignored) + `.trellis/workspace/<your-name>/`.
|
||||||
|
|
||||||
|
### Spec System
|
||||||
|
|
||||||
|
`.trellis/spec/` holds coding guidelines organized by package and layer.
|
||||||
|
|
||||||
|
- `.trellis/spec/<package>/<layer>/index.md` — entry point with **Pre-Development Checklist** + **Quality Check**. Actual guidelines live in the `.md` files it points to.
|
||||||
|
- `.trellis/spec/guides/index.md` — cross-package thinking guides.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./.trellis/scripts/get_context.py --mode packages # list packages / layers
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to update spec**: new pattern/convention found · bug-fix prevention to codify · new technical decision.
|
||||||
|
|
||||||
|
### Task System
|
||||||
|
|
||||||
|
Every task has its own directory under `.trellis/tasks/{MM-DD-name}/` holding `task.json`, `prd.md`, optional `design.md`, optional `implement.md`, optional `research/`, and context manifests (`implement.jsonl`, `check.jsonl`) for sub-agent-capable platforms.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Task lifecycle
|
||||||
|
python3 ./.trellis/scripts/task.py create "<title>" [--slug <name>] [--parent <dir>]
|
||||||
|
python3 ./.trellis/scripts/task.py start <name> # set active task (session-scoped when available)
|
||||||
|
python3 ./.trellis/scripts/task.py current --source # show active task and source
|
||||||
|
python3 ./.trellis/scripts/task.py finish # clear active task (triggers after_finish hooks)
|
||||||
|
python3 ./.trellis/scripts/task.py archive <name> # move to archive/{year-month}/
|
||||||
|
python3 ./.trellis/scripts/task.py list [--mine] [--status <s>]
|
||||||
|
python3 ./.trellis/scripts/task.py list-archive
|
||||||
|
|
||||||
|
# Code-spec context (injected into implement/check agents via JSONL).
|
||||||
|
# `implement.jsonl` / `check.jsonl` are seeded on `task create` for sub-agent-capable
|
||||||
|
# platforms; the AI curates real spec + research entries during planning when needed.
|
||||||
|
python3 ./.trellis/scripts/task.py add-context <name> <action> <file> <reason>
|
||||||
|
python3 ./.trellis/scripts/task.py list-context <name> [action]
|
||||||
|
python3 ./.trellis/scripts/task.py validate <name>
|
||||||
|
|
||||||
|
# Task metadata
|
||||||
|
python3 ./.trellis/scripts/task.py set-branch <name> <branch>
|
||||||
|
python3 ./.trellis/scripts/task.py set-base-branch <name> <branch> # PR target
|
||||||
|
python3 ./.trellis/scripts/task.py set-scope <name> <scope>
|
||||||
|
|
||||||
|
# Hierarchy (parent/child)
|
||||||
|
python3 ./.trellis/scripts/task.py add-subtask <parent> <child>
|
||||||
|
python3 ./.trellis/scripts/task.py remove-subtask <parent> <child>
|
||||||
|
|
||||||
|
# PR creation
|
||||||
|
python3 ./.trellis/scripts/task.py create-pr [name] [--dry-run]
|
||||||
|
```
|
||||||
|
|
||||||
|
> Run `python3 ./.trellis/scripts/task.py --help` to see the authoritative, up-to-date list.
|
||||||
|
|
||||||
|
**Current-task mechanism**: `task.py create` creates the task directory and (when session identity is available) auto-sets the per-session active-task pointer so the planning breadcrumb fires immediately. `task.py start` writes the same pointer (idempotent if already set) and flips `task.json.status` from `planning` to `in_progress`. State is stored under `.trellis/.runtime/sessions/`. If no context key is available from hook input, `TRELLIS_CONTEXT_ID`, or a platform-native session environment variable, there is no active task and `task.py start` fails with a session identity hint. `task.py finish` deletes the current session file (status unchanged). `task.py archive <task>` writes `status=completed`, moves the directory to `archive/`, and deletes any runtime session files that still point at the archived task.
|
||||||
|
|
||||||
|
### Workspace System
|
||||||
|
|
||||||
|
Records every AI session for cross-session tracking under `.trellis/workspace/<developer>/`.
|
||||||
|
|
||||||
|
- `journal-N.md` — session log. **Max 2000 lines per file**; a new `journal-(N+1).md` is auto-created when exceeded.
|
||||||
|
- `index.md` — personal index (total sessions, last active).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./.trellis/scripts/add_session.py --title "Title" --commit "hash" --summary "Summary"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Context Script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./.trellis/scripts/get_context.py # full session runtime
|
||||||
|
python3 ./.trellis/scripts/get_context.py --mode packages # available packages + spec layers
|
||||||
|
python3 ./.trellis/scripts/get_context.py --mode phase --step <X.Y> # detailed guide for a workflow step
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!--
|
||||||
|
WORKFLOW-STATE BREADCRUMB CONTRACT (read this before editing the tag blocks below)
|
||||||
|
|
||||||
|
The [workflow-state:STATUS] blocks embedded in the ## Phase Index section
|
||||||
|
below are the SINGLE source of truth for the per-turn `<workflow-state>`
|
||||||
|
breadcrumb that every supported AI platform's UserPromptSubmit hook
|
||||||
|
reads. inject-workflow-state.py (Python platforms) and
|
||||||
|
inject-workflow-state.js (OpenCode plugin) only parse them — there is no
|
||||||
|
fallback dict baked into the scripts after v0.5.0-rc.0.
|
||||||
|
|
||||||
|
STATUS charset: [A-Za-z0-9_-]+. When the hook can't find a tag, it
|
||||||
|
degrades to a generic "Refer to workflow.md for current step." line —
|
||||||
|
intentionally visible so users notice and fix a broken workflow.md.
|
||||||
|
|
||||||
|
INVARIANT (test/regression.test.ts):
|
||||||
|
Every workflow-walkthrough step marked `[required · once]` must have a
|
||||||
|
matching enforcement line in its phase's [workflow-state:*] block. The
|
||||||
|
breadcrumb is the only per-turn channel; if a mandatory step isn't
|
||||||
|
mentioned there, the AI silently skips it (Phase 1 planning gate
|
||||||
|
skip and Phase 3.4 commit skip both manifested via this gap).
|
||||||
|
|
||||||
|
TAG ↔ PHASE scoping:
|
||||||
|
[workflow-state:no_task] → no active task; before Phase 1
|
||||||
|
[workflow-state:planning] → all of Phase 1 (status='planning')
|
||||||
|
[workflow-state:planning-inline] → Codex inline variant of Phase 1
|
||||||
|
[workflow-state:in_progress] → Phase 2 + Phase 3.2-3.4
|
||||||
|
(status stays 'in_progress' from
|
||||||
|
task.py start until task.py archive)
|
||||||
|
[workflow-state:in_progress-inline] → Codex inline variant of Phase 2/3
|
||||||
|
[workflow-state:completed] → currently DEAD: cmd_archive flips
|
||||||
|
status and moves the dir in the same
|
||||||
|
call, so the resolver loses the
|
||||||
|
pointer (block kept for a future
|
||||||
|
explicit in_progress→completed
|
||||||
|
transition)
|
||||||
|
|
||||||
|
Editing checklist:
|
||||||
|
- When you change a [workflow-state:STATUS] block, also check the
|
||||||
|
matching phase's `[required · once]` walkthrough steps for sync
|
||||||
|
- Run `trellis update` after editing to push the new bodies to
|
||||||
|
downstream user projects (block-level managed replacement)
|
||||||
|
- Full runtime contract:
|
||||||
|
.trellis/spec/cli/backend/workflow-state-contract.md
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Phase Index
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1: Plan → classify, get task-creation consent, then write planning artifacts
|
||||||
|
Phase 2: Execute → implement only after task status is in_progress
|
||||||
|
Phase 3: Finish → verify, update spec, commit, and wrap up
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request Triage
|
||||||
|
|
||||||
|
- Simple conversation or small task: ask only whether this turn should create a Trellis task. If the user says no, skip Trellis for this session.
|
||||||
|
- Complex task: ask whether you may create a Trellis task and enter planning. If the user says no, do not do broad inline implementation; explain, clarify scope, or suggest a smaller split.
|
||||||
|
- User approval to create a task is not approval to start implementation. Planning still happens first.
|
||||||
|
|
||||||
|
### Planning Artifacts
|
||||||
|
|
||||||
|
- `prd.md` — requirements, constraints, and acceptance criteria. Do not put technical design or execution checklists here.
|
||||||
|
- `design.md` — technical design for complex tasks: boundaries, contracts, data flow, tradeoffs, compatibility, rollout / rollback shape.
|
||||||
|
- `implement.md` — execution plan for complex tasks: ordered checklist, validation commands, review gates, and rollback points.
|
||||||
|
- `implement.jsonl` / `check.jsonl` — spec and research manifests for sub-agent context. They do not replace `implement.md`.
|
||||||
|
- Lightweight tasks may be PRD-only. Complex tasks must have `prd.md`, `design.md`, and `implement.md` before `task.py start`.
|
||||||
|
|
||||||
|
### Parent / Child Task Trees
|
||||||
|
|
||||||
|
Use a parent task when one user request contains several independently verifiable deliverables. The parent task owns the source requirement set, the task map, cross-child acceptance criteria, and final integration review; it normally should not be the implementation target unless it also has direct work.
|
||||||
|
|
||||||
|
Use child tasks for deliverables that can be planned, implemented, checked, and archived independently. Parent/child structure is not a dependency system: if one child must wait for another, write that ordering in the child `prd.md` / `implement.md` and keep each child's acceptance criteria testable.
|
||||||
|
|
||||||
|
Create new children with `task.py create "<title>" --slug <name> --parent <parent-dir>`. Link existing tasks with `task.py add-subtask <parent> <child>`, and unlink mistakes with `task.py remove-subtask <parent> <child>`.
|
||||||
|
|
||||||
|
<!-- Per-turn breadcrumb: shown when there is no active task (before Phase 1) -->
|
||||||
|
|
||||||
|
[workflow-state:no_task]
|
||||||
|
No active task. First classify the current turn and ask for task-creation consent before creating any Trellis task.
|
||||||
|
Simple conversation / small task: ask only whether this turn should create a Trellis task. If the user says no, skip Trellis for this session.
|
||||||
|
Complex task: ask the user if you can create a Trellis task and enter the planning phase. If the user says no, explain, clarify scope, or suggest a smaller split.
|
||||||
|
[/workflow-state:no_task]
|
||||||
|
|
||||||
|
### Phase 1: Plan
|
||||||
|
- 1.0 Create task `[required · once]` (only after task-creation consent)
|
||||||
|
- 1.1 Requirement exploration `[required · repeatable]` (`prd.md`; complex tasks also need `design.md` + `implement.md`)
|
||||||
|
- 1.2 Research `[optional · repeatable]`
|
||||||
|
- 1.3 Configure context `[required · once]` — Claude Code, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi (sub-agent-dispatch platforms only; inline platforms skip)
|
||||||
|
- 1.4 Activate task `[required · once]` (review gate, then `task.py start`; status → in_progress)
|
||||||
|
- 1.5 Completion criteria
|
||||||
|
|
||||||
|
<!-- Per-turn breadcrumb: shown throughout Phase 1 (status='planning') -->
|
||||||
|
|
||||||
|
[workflow-state:planning]
|
||||||
|
Load `trellis-brainstorm`; stay in planning.
|
||||||
|
Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`.
|
||||||
|
Multi-deliverable scope: consider a parent task plus independently verifiable child tasks; dependencies must be written in child artifacts, not implied by tree position.
|
||||||
|
Sub-agent mode: curate `implement.jsonl` and `check.jsonl` as spec/research manifests before start.
|
||||||
|
[/workflow-state:planning]
|
||||||
|
|
||||||
|
<!-- Per-turn breadcrumb: shown throughout Phase 1 when codex.dispatch_mode=inline.
|
||||||
|
Codex-only opt-in alternate to [workflow-state:planning]. The main agent
|
||||||
|
edits code directly in Phase 2, so jsonl curation is skipped —
|
||||||
|
the inline workflow loads `trellis-before-dev` instead of injecting JSONL
|
||||||
|
into a sub-agent. -->
|
||||||
|
|
||||||
|
[workflow-state:planning-inline]
|
||||||
|
Load `trellis-brainstorm`; stay in planning.
|
||||||
|
Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`.
|
||||||
|
Multi-deliverable scope: consider a parent task plus independently verifiable child tasks; dependencies must be written in child artifacts, not implied by tree position.
|
||||||
|
Inline mode: skip jsonl curation; Phase 2 reads artifacts/specs via `trellis-before-dev`.
|
||||||
|
[/workflow-state:planning-inline]
|
||||||
|
|
||||||
|
### Phase 2: Execute
|
||||||
|
- 2.1 Implement `[required · repeatable]`
|
||||||
|
- 2.2 Quality check `[required · repeatable]`
|
||||||
|
- 2.3 Rollback `[on demand]`
|
||||||
|
|
||||||
|
<!-- Per-turn breadcrumb: shown while status='in_progress'.
|
||||||
|
Scope: all of Phase 2 + Phase 3.2-3.4 (status stays 'in_progress' from
|
||||||
|
task.py start until task.py archive; only archive flips it). The body
|
||||||
|
therefore must cover every required step from implementation through
|
||||||
|
commit, including Phase 3.3 spec update and Phase 3.4 commit. -->
|
||||||
|
|
||||||
|
Sub-agent dispatch protocol applies to all platforms and all sub-agents, including class-2 Codex/Copilot/Gemini/Qoder and `trellis-research`: every dispatch prompt starts with `Active task: <task path from task.py current>` before role-specific instructions.
|
||||||
|
|
||||||
|
[workflow-state:in_progress]
|
||||||
|
Tools: `trellis-implement` / `trellis-research` are sub-agent types only (Task/Agent tool, NOT Skill; there is no skill by these names). `trellis-update-spec` is a skill. `trellis-check` exists as both; prefer the Agent form when verifying after code changes.
|
||||||
|
Flow: `trellis-implement` -> `trellis-check` -> `trellis-update-spec` -> commit (Phase 3.4) -> `/trellis:finish-work`.
|
||||||
|
Main-session default: dispatch implement/check sub-agents. Sub-agent self-exemption: if already running as `trellis-implement`, do NOT spawn another `trellis-implement` or `trellis-check`; if already running as `trellis-check`, do NOT spawn another `trellis-check` or `trellis-implement`. Dispatch is main session only.
|
||||||
|
Dispatch prompt starts with `Active task: <task path from task.py current>`. Read context: jsonl entries -> `prd.md` -> `design.md if present` -> `implement.md if present`.
|
||||||
|
[/workflow-state:in_progress]
|
||||||
|
|
||||||
|
<!-- Per-turn breadcrumb: shown while status='in_progress' when
|
||||||
|
codex.dispatch_mode=inline. Codex-only opt-in alternate to
|
||||||
|
[workflow-state:in_progress]. The main session edits code directly
|
||||||
|
instead of dispatching sub-agents. -->
|
||||||
|
|
||||||
|
[workflow-state:in_progress-inline]
|
||||||
|
Flow: `trellis-before-dev` -> edit -> `trellis-check` -> validation -> `trellis-update-spec` -> commit (Phase 3.4) -> `/trellis:finish-work`.
|
||||||
|
Do not dispatch implement/check sub-agents in inline mode.
|
||||||
|
Read context: `prd.md` -> `design.md if present` -> `implement.md if present`, plus relevant spec/research loaded by skills.
|
||||||
|
[/workflow-state:in_progress-inline]
|
||||||
|
|
||||||
|
### Phase 3: Finish
|
||||||
|
- 3.2 Debug retrospective `[on demand]`
|
||||||
|
- 3.3 Spec update `[required · once]`
|
||||||
|
- 3.4 Commit changes `[required · once]`
|
||||||
|
- 3.5 Wrap-up reminder
|
||||||
|
|
||||||
|
> Note: step 3.1 was folded into 2.2 (last-iteration full-scope check) and 3.4 (commit preamble). Numbering kept stable to avoid breaking external references.
|
||||||
|
|
||||||
|
<!-- Per-turn breadcrumb: shown while status='completed'.
|
||||||
|
Currently DEAD in normal flow: cmd_archive writes status='completed' in
|
||||||
|
the same call that moves the task dir to archive/, so the active-task
|
||||||
|
resolver loses the pointer and the hook never fires on archived tasks.
|
||||||
|
Block preserved for a future status-transition redesign (e.g. an
|
||||||
|
explicit in_progress→completed command). Edit through the same spec
|
||||||
|
channel as the live blocks. -->
|
||||||
|
|
||||||
|
[workflow-state:completed]
|
||||||
|
Code committed. Run `/trellis:finish-work`; if dirty, return to Phase 3.4 first.
|
||||||
|
[/workflow-state:completed]
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
1. Identify which Phase you're in, then continue from the next step there
|
||||||
|
2. Run steps in order inside each Phase; `[required]` steps can't be skipped
|
||||||
|
3. Phases can roll back (e.g., Execute reveals a prd defect → return to Plan to fix, then re-enter Execute)
|
||||||
|
4. Steps tagged `[once]` are skipped if the output already exists; don't re-run
|
||||||
|
5. Artifact presence informs the next step; missing `design.md` / `implement.md` is valid for lightweight tasks and incomplete planning for complex tasks.
|
||||||
|
|
||||||
|
### Active Task Routing
|
||||||
|
|
||||||
|
When a user request matches one of these intents inside an active task, route first, then load the detailed phase step if needed.
|
||||||
|
|
||||||
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi]
|
||||||
|
|
||||||
|
- Planning or unclear requirements -> `trellis-brainstorm`.
|
||||||
|
- `in_progress` implementation/check -> dispatch `trellis-implement` / `trellis-check`.
|
||||||
|
- Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`.
|
||||||
|
|
||||||
|
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi]
|
||||||
|
|
||||||
|
[codex-inline, Kilo, Antigravity, Devin]
|
||||||
|
|
||||||
|
- Planning or unclear requirements -> `trellis-brainstorm`.
|
||||||
|
- Before editing -> `trellis-before-dev`; after editing -> `trellis-check`.
|
||||||
|
- Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`.
|
||||||
|
|
||||||
|
[/codex-inline, Kilo, Antigravity, Devin]
|
||||||
|
|
||||||
|
### Guardrails
|
||||||
|
|
||||||
|
- Task creation approval is not implementation approval; implementation waits for `task.py start` after artifact review.
|
||||||
|
- PRD-only is valid for lightweight tasks; complex tasks need `design.md` + `implement.md`.
|
||||||
|
- Planning must be persisted to task artifacts; checks must run before reporting completion.
|
||||||
|
|
||||||
|
### Loading Step Detail
|
||||||
|
|
||||||
|
At each step, run this to fetch detailed guidance:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./.trellis/scripts/get_context.py --mode phase --step <step>
|
||||||
|
# e.g. python3 ./.trellis/scripts/get_context.py --mode phase --step 1.1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Plan
|
||||||
|
|
||||||
|
Goal: classify the request, get task-creation consent when a task is needed, and produce the planning artifacts required before implementation.
|
||||||
|
|
||||||
|
#### 1.0 Create task `[required · once]`
|
||||||
|
|
||||||
|
Create the task directory only after task-creation consent. The command sets status to `planning`, writes `task.json`, creates a default `prd.md`, and auto-targets the new task when session identity is available:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./.trellis/scripts/task.py create "<task title>" --slug <name>
|
||||||
|
```
|
||||||
|
|
||||||
|
`--slug` is the human-readable name only. Do **not** include the `MM-DD-` date prefix; `task.py create` adds that prefix automatically.
|
||||||
|
|
||||||
|
For task trees, create the parent task first and then create each child with `--parent <parent-dir>`. Do not start the parent just because children exist; start the child that owns the next independently verifiable deliverable.
|
||||||
|
|
||||||
|
After this command succeeds, the per-turn breadcrumb auto-switches to `[workflow-state:planning]`, telling the AI to stay in planning.
|
||||||
|
|
||||||
|
Run only `create` here — do not also run `start`. `start` flips status to `in_progress`, which switches the breadcrumb to the implementation phase before planning artifacts are reviewed. Save `start` for step 1.4.
|
||||||
|
|
||||||
|
Skip when `python3 ./.trellis/scripts/task.py current --source` already points to a task.
|
||||||
|
|
||||||
|
#### 1.1 Requirement exploration `[required · repeatable]`
|
||||||
|
|
||||||
|
Load the `trellis-brainstorm` skill and explore requirements interactively with the user per the skill's guidance.
|
||||||
|
|
||||||
|
The brainstorm skill will guide you to:
|
||||||
|
- Ask one question at a time
|
||||||
|
- Prefer researching over asking the user
|
||||||
|
- Prefer offering options over open-ended questions
|
||||||
|
- Update `prd.md` immediately after each user answer
|
||||||
|
- Split large scopes into a parent task plus child tasks when the deliverables can be verified independently
|
||||||
|
- Keep `prd.md` focused on requirements and acceptance criteria
|
||||||
|
- For complex tasks, produce `design.md` and `implement.md` before implementation starts
|
||||||
|
|
||||||
|
When considering a parent/child split:
|
||||||
|
- Use a parent task when one request contains several independently verifiable deliverables.
|
||||||
|
- Parent tasks own source requirements, child-task mapping, cross-child acceptance criteria, and final integration review.
|
||||||
|
- Child tasks own actual deliverables that can be planned, implemented, checked, and archived independently.
|
||||||
|
- Parent/child structure is not a dependency system. If child B depends on child A, write that ordering in child B's `prd.md` / `implement.md`.
|
||||||
|
- Start the child task that owns the next deliverable. Do not start the parent unless the parent itself has direct implementation work.
|
||||||
|
|
||||||
|
Return to this step whenever requirements change and revise the relevant artifact.
|
||||||
|
|
||||||
|
#### 1.2 Research `[optional · repeatable]`
|
||||||
|
|
||||||
|
Research can happen at any time during requirement exploration. It isn't limited to local code — you can use any available tool (MCP servers, skills, web search, etc.) to look up external information, including third-party library docs, industry practices, API references, etc.
|
||||||
|
|
||||||
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi]
|
||||||
|
|
||||||
|
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, Devin]
|
||||||
|
|
||||||
|
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, Devin]
|
||||||
|
|
||||||
|
**Research artifact conventions**:
|
||||||
|
- One file per research topic (e.g. `research/auth-library-comparison.md`)
|
||||||
|
- Record third-party library usage examples, API references, version constraints in files
|
||||||
|
- Note relevant spec file paths you discovered for later reference
|
||||||
|
|
||||||
|
Brainstorm and research can interleave freely — pause to research a technical question, then return to talk with the user.
|
||||||
|
|
||||||
|
**Key principle**: Research output must be written to files, not left only in the chat. Conversations get compacted; files don't.
|
||||||
|
|
||||||
|
#### 1.3 Configure context `[required · once]`
|
||||||
|
|
||||||
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi]
|
||||||
|
|
||||||
|
Curate `implement.jsonl` and `check.jsonl` so the Phase 2 sub-agents get the right spec/research context. These files were seeded on `task create` with a single self-describing `_example` line; your job here is to fill in real entries.
|
||||||
|
|
||||||
|
**Location**: `{TASK_DIR}/implement.jsonl` and `{TASK_DIR}/check.jsonl` (already exist).
|
||||||
|
|
||||||
|
**Format**: one JSON object per line — `{"file": "<path>", "reason": "<why>"}`. Paths are repo-root relative.
|
||||||
|
|
||||||
|
**What to put in**:
|
||||||
|
- **Spec files** — `.trellis/spec/<package>/<layer>/index.md` and any specific guideline files (`error-handling.md`, `conventions.md`, etc.) relevant to this task
|
||||||
|
- **Research files** — `{TASK_DIR}/research/*.md` that the sub-agent will need to consult
|
||||||
|
|
||||||
|
**What NOT to put in**:
|
||||||
|
- Code files (`src/**`, `packages/**/*.ts`, etc.) — those are read by the sub-agent during implementation, not pre-registered here
|
||||||
|
- Files you're about to modify — same reason
|
||||||
|
|
||||||
|
**Split between the two files**:
|
||||||
|
- `implement.jsonl` → specs + research the implement sub-agent needs to write code correctly
|
||||||
|
- `check.jsonl` → specs for the check sub-agent (quality guidelines, check conventions, same research if needed)
|
||||||
|
|
||||||
|
These manifests do not replace `implement.md`. `implement.md` is the human-readable execution plan for a complex task; jsonl files only list context files to inject or load.
|
||||||
|
|
||||||
|
**How to discover relevant specs**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./.trellis/scripts/get_context.py --mode packages
|
||||||
|
```
|
||||||
|
|
||||||
|
Lists every package + its spec layers with paths. Pick the entries that match this task's domain.
|
||||||
|
|
||||||
|
**How to append entries**:
|
||||||
|
|
||||||
|
Either edit the jsonl file directly in your editor, or use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" implement "<path>" "<reason>"
|
||||||
|
python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" check "<path>" "<reason>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Delete the seed `_example` line once real entries exist (optional — it's skipped automatically by consumers).
|
||||||
|
|
||||||
|
Skip when: `implement.jsonl` and `check.jsonl` have 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, Devin]
|
||||||
|
|
||||||
|
Skip this step. Context is loaded directly by the `trellis-before-dev` skill in Phase 2.
|
||||||
|
|
||||||
|
[/codex-inline, Kilo, Antigravity, Devin]
|
||||||
|
|
||||||
|
#### 1.4 Activate task `[required · once]`
|
||||||
|
|
||||||
|
After artifact review, flip the task status to `in_progress`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./.trellis/scripts/task.py start <task-dir>
|
||||||
|
```
|
||||||
|
|
||||||
|
For lightweight tasks, `prd.md` can be enough. For complex tasks, `prd.md`, `design.md`, and `implement.md` must exist and be reviewed before start. On sub-agent-capable platforms, curate jsonl manifests when extra spec or research context is needed; seed-only manifests are tolerated by consumers.
|
||||||
|
|
||||||
|
After this command succeeds, the breadcrumb auto-switches to `[workflow-state:in_progress]`, and the rest of Phase 2 / 3 follows.
|
||||||
|
|
||||||
|
If `task.py start` errors with a session-identity message (no context key from hook input, `TRELLIS_CONTEXT_ID`, or platform-native session env), follow the hint in the error to set up session identity, then retry.
|
||||||
|
|
||||||
|
#### 1.5 Completion criteria
|
||||||
|
|
||||||
|
| Condition | Required |
|
||||||
|
|------|:---:|
|
||||||
|
| `prd.md` exists | ✅ |
|
||||||
|
| User confirms task should enter implementation | ✅ |
|
||||||
|
| `task.py start` has been run (status = in_progress) | ✅ |
|
||||||
|
| `research/` has artifacts (complex tasks) | recommended |
|
||||||
|
| `design.md` exists (complex tasks) | ✅ |
|
||||||
|
| `implement.md` exists (complex tasks) | ✅ |
|
||||||
|
|
||||||
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi]
|
||||||
|
|
||||||
|
| `implement.jsonl` / `check.jsonl` curated when extra spec or research context is needed | recommended |
|
||||||
|
|
||||||
|
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Execute
|
||||||
|
|
||||||
|
Goal: turn reviewed planning artifacts 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 reviewed task artifacts, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check
|
||||||
|
- **Dispatch prompt guard**: Tell the spawned agent it is already the `trellis-implement` sub-agent and must implement directly, not spawn another `trellis-implement` / `trellis-check`.
|
||||||
|
|
||||||
|
The platform hook/plugin auto-handles:
|
||||||
|
- Reads `implement.jsonl` and injects referenced spec/research files into the agent prompt
|
||||||
|
- Injects `prd.md`, `design.md` if present, and `implement.md` if present
|
||||||
|
|
||||||
|
[/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 reviewed task artifacts, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check
|
||||||
|
- **Dispatch prompt guard**: The prompt MUST start with `Active task: <task path>`, then explicitly say the spawned agent is already `trellis-implement` and must implement directly without spawning another `trellis-implement` / `trellis-check`.
|
||||||
|
|
||||||
|
The Codex sub-agent definition auto-handles the context load requirement:
|
||||||
|
- Resolves the active task with `task.py current --source`, then reads `prd.md`, `design.md` if present, and `implement.md` if present
|
||||||
|
- Reads `implement.jsonl` and requires the agent to load each referenced spec/research file before coding
|
||||||
|
|
||||||
|
[/codex-sub-agent]
|
||||||
|
|
||||||
|
[Kiro]
|
||||||
|
|
||||||
|
Spawn the implement sub-agent:
|
||||||
|
|
||||||
|
- **Agent type**: `trellis-implement`
|
||||||
|
- **Task description**: Implement the reviewed task artifacts, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check
|
||||||
|
- **Dispatch prompt guard**: Tell the spawned agent it is already the `trellis-implement` sub-agent and must implement directly, not spawn another `trellis-implement` / `trellis-check`.
|
||||||
|
|
||||||
|
The platform prelude auto-handles the context load requirement:
|
||||||
|
- Reads `implement.jsonl` and injects referenced spec/research files into the agent prompt
|
||||||
|
- Injects `prd.md`, `design.md` if present, and `implement.md` if present
|
||||||
|
|
||||||
|
[/Kiro]
|
||||||
|
|
||||||
|
[codex-inline, Kilo, Antigravity, Devin]
|
||||||
|
|
||||||
|
1. Load the `trellis-before-dev` skill to read project guidelines
|
||||||
|
2. Read `{TASK_DIR}/prd.md`, then `design.md` if present, then `implement.md` if present
|
||||||
|
3. Consult materials under `{TASK_DIR}/research/`
|
||||||
|
4. Implement the code per reviewed artifacts
|
||||||
|
5. Run project lint and type-check
|
||||||
|
|
||||||
|
[/codex-inline, Kilo, Antigravity, Devin]
|
||||||
|
|
||||||
|
#### 2.2 Quality check `[required · repeatable]`
|
||||||
|
|
||||||
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi]
|
||||||
|
|
||||||
|
Spawn the check sub-agent:
|
||||||
|
|
||||||
|
- **Agent type**: `trellis-check`
|
||||||
|
- **Task description**: Review all code changes against specs and task artifacts; fix any findings directly; ensure lint and type-check pass
|
||||||
|
- **Dispatch prompt guard**: Tell the spawned agent it is already the `trellis-check` sub-agent and must review/fix directly, not spawn another `trellis-check` / `trellis-implement`.
|
||||||
|
|
||||||
|
The check agent's job:
|
||||||
|
- Review code changes against specs
|
||||||
|
- Review code changes against `prd.md`, `design.md` if present, and `implement.md` if present
|
||||||
|
- Auto-fix issues it finds
|
||||||
|
- Run lint and typecheck to verify
|
||||||
|
|
||||||
|
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi]
|
||||||
|
|
||||||
|
[codex-inline, Kilo, Antigravity, Devin]
|
||||||
|
|
||||||
|
Load the `trellis-check` skill and verify the code per its guidance:
|
||||||
|
- Spec compliance
|
||||||
|
- lint / type-check / tests
|
||||||
|
- Cross-layer consistency (when changes span layers)
|
||||||
|
|
||||||
|
If issues are found → fix → re-check, until green.
|
||||||
|
|
||||||
|
[/codex-inline, Kilo, Antigravity, Devin]
|
||||||
|
|
||||||
|
**Final pass (before Phase 3.4 commit)**: the last 2.2 of a task must run full-scope, not just on the latest implement chunk. List all affected packages with `python3 ./.trellis/scripts/get_context.py --mode packages`, then load each package's spec index Quality Check section. This catches cross-layer / multi-package issues a mid-iteration local 2.2 cannot.
|
||||||
|
|
||||||
|
#### 2.3 Rollback `[on demand]`
|
||||||
|
|
||||||
|
- `check` reveals a prd defect → return to Phase 1, fix `prd.md`, then redo 2.1
|
||||||
|
- Implementation went wrong → revert code, redo 2.1
|
||||||
|
- Need more research → research (same as Phase 1.2), write findings into `research/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Finish
|
||||||
|
|
||||||
|
Goal: ensure code quality, capture lessons, record the work.
|
||||||
|
|
||||||
|
#### 3.2 Debug retrospective `[on demand]`
|
||||||
|
|
||||||
|
If this task involved repeated debugging (the same issue was fixed multiple times), load the `trellis-break-loop` skill to:
|
||||||
|
- Classify the root cause
|
||||||
|
- Explain why earlier fixes failed
|
||||||
|
- Propose prevention
|
||||||
|
|
||||||
|
The goal is to capture debugging lessons so the same class of issue doesn't recur.
|
||||||
|
|
||||||
|
#### 3.3 Spec update `[required · once]`
|
||||||
|
|
||||||
|
Load the `trellis-update-spec` skill and review whether this task produced new knowledge worth recording:
|
||||||
|
- Newly discovered patterns or conventions
|
||||||
|
- Pitfalls you hit
|
||||||
|
- New technical decisions
|
||||||
|
|
||||||
|
Update the docs under `.trellis/spec/` accordingly. Even if the conclusion is "nothing to update", walk through the judgment.
|
||||||
|
|
||||||
|
#### 3.4 Commit changes `[required · once]`
|
||||||
|
|
||||||
|
**Spec-sync preamble**: before drafting commits, ask: did this task fix a bug or surface non-obvious knowledge that should land in `.trellis/spec/` so future-you (or future-AI) doesn't repeat the mistake? If yes, return to Phase 3.3 first — spec writes belong in the same task's commit batch, not as a forgotten follow-up.
|
||||||
|
|
||||||
|
The AI drives a batched commit of this task's code changes so `/finish-work` can run cleanly afterwards. Goal: produce work commits FIRST, then bookkeeping (archive + journal) commits land after — never interleaved.
|
||||||
|
|
||||||
|
**Step-by-step**:
|
||||||
|
|
||||||
|
1. **Inspect dirty state**:
|
||||||
|
```bash
|
||||||
|
git status --porcelain
|
||||||
|
```
|
||||||
|
Snapshot every dirty path. If the working tree is clean, skip to 3.5.
|
||||||
|
|
||||||
|
2. **Learn commit style** from recent history (so drafted messages blend in):
|
||||||
|
```bash
|
||||||
|
git log --oneline -5
|
||||||
|
```
|
||||||
|
Note the prefix convention (`feat:` / `fix:` / `chore:` / `docs:` ...), language (中文/English), and length style.
|
||||||
|
|
||||||
|
3. **Classify dirty files into two groups**:
|
||||||
|
- **AI-edited this session** — files you wrote/edited via Edit/Write/Bash tool calls in this session. You know what changed and why.
|
||||||
|
- **Unrecognized** — dirty files you did NOT touch this session (could be the user's manual edits, leftover WIP from a previous session, or unrelated work). Do NOT silently include these.
|
||||||
|
|
||||||
|
4. **Draft a commit plan**. Group AI-edited files into logical commits (1 commit per coherent change unit, not 1 commit per file). Each entry: `<commit message>` + file list. List unrecognized files separately at the bottom.
|
||||||
|
|
||||||
|
5. **Present the plan once, ask for one-shot confirmation**. Format:
|
||||||
|
```
|
||||||
|
Proposed commits (in order):
|
||||||
|
1. <message>
|
||||||
|
- <file>
|
||||||
|
- <file>
|
||||||
|
2. <message>
|
||||||
|
- <file>
|
||||||
|
|
||||||
|
Unrecognized dirty files (NOT in any commit — confirm include/exclude):
|
||||||
|
- <file>
|
||||||
|
- <file>
|
||||||
|
|
||||||
|
Reply 'ok' / '行' to execute. Reply with edits, or '我自己来' / 'manual' to abort.
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **On confirmation**: run `git add <files>` + `git commit -m "<msg>"` for each batch in order. Do not amend. Do not push.
|
||||||
|
|
||||||
|
7. **On rejection** (user replies "不行" / "我自己来" / "manual" / any pushback on the plan): stop. Do not attempt a second plan. The user will commit by hand; you skip ahead to 3.5 once they confirm.
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
- No `git commit --amend` anywhere — three-stage three-commit flow (work commits → archive commit → journal commit).
|
||||||
|
- Never push to remote in this step.
|
||||||
|
- If the user wants different message wording but accepts the file grouping, edit the message and re-confirm once — but if they reject the grouping, exit to manual mode.
|
||||||
|
- The batched plan is one prompt; do not prompt per commit.
|
||||||
|
|
||||||
|
#### 3.5 Wrap-up reminder
|
||||||
|
|
||||||
|
After the above, remind the user they can run `/finish-work` to wrap up (archive the task, record the session).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Customizing Trellis (for forks)
|
||||||
|
|
||||||
|
This section is for developers who want to modify the Trellis workflow itself. All customization is done by editing this file; the scripts are parsers only.
|
||||||
|
|
||||||
|
### Changing what a step means
|
||||||
|
|
||||||
|
Edit the corresponding step's walkthrough body in the Phase 1 / 2 / 3 sections above. Critical invariants:
|
||||||
|
- No active task must triage first and ask for task-creation consent before creating a Trellis task.
|
||||||
|
- Planning must distinguish lightweight PRD-only tasks from complex tasks that require `prd.md`, `design.md`, and `implement.md` before start.
|
||||||
|
- Every required execution path must keep the Phase 3.4 commit reminder reachable before `/trellis:finish-work`.
|
||||||
|
|
||||||
|
All tag blocks live in the `## Phase Index` section above, immediately after each phase summary:
|
||||||
|
|
||||||
|
| Scope | Corresponding tag |
|
||||||
|
|---|---|
|
||||||
|
| No active task (before Phase 1) | `[workflow-state:no_task]` (after the Phase Index ASCII art) |
|
||||||
|
| All of Phase 1 (task created → ready for implementation) | `[workflow-state:planning]` (after Phase 1 summary) |
|
||||||
|
| Codex inline Phase 1 | `[workflow-state:planning-inline]` |
|
||||||
|
| Phase 2 + Phase 3.2–3.4 (implementation + check + wrap-up) | `[workflow-state:in_progress]` (after Phase 2 summary) |
|
||||||
|
| Codex inline Phase 2 + Phase 3.2–3.4 | `[workflow-state:in_progress-inline]` |
|
||||||
|
| After Phase 3.5 (archived) | `[workflow-state:completed]` (after Phase 3 summary; **currently DEAD**) |
|
||||||
|
|
||||||
|
### Changing the per-turn prompt text
|
||||||
|
|
||||||
|
Directly edit the body of the corresponding `[workflow-state:STATUS]` block. After editing, run `trellis update` (if you're a template maintainer) or restart your AI session (if you're customizing your own project) — no script changes required.
|
||||||
|
|
||||||
|
### Adding a custom status
|
||||||
|
|
||||||
|
Add a new block:
|
||||||
|
|
||||||
|
```
|
||||||
|
[workflow-state:my-status]
|
||||||
|
your per-turn prompt text
|
||||||
|
[/workflow-state:my-status]
|
||||||
|
```
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
- STATUS charset: `[A-Za-z0-9_-]+` (underscores and hyphens allowed, e.g. `in-review`, `blocked-by-team`)
|
||||||
|
- A lifecycle hook must write `task.json.status` to your custom value, otherwise the tag is never read
|
||||||
|
- Lifecycle hooks live in `task.json.hooks.after_*` and bind to one of `after_create / after_start / after_finish / after_archive`
|
||||||
|
|
||||||
|
### Adding a lifecycle hook
|
||||||
|
|
||||||
|
Add a `hooks` field to your `task.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"after_finish": [
|
||||||
|
"your-script-or-command-here"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported events: `after_create / after_start / after_finish / after_archive`. Note that `after_finish` ≠ a status change (it only clears the active-task pointer); use `after_archive` for "task is done" notifications.
|
||||||
|
|
||||||
|
### Full contract
|
||||||
|
|
||||||
|
For the workflow state machine's runtime contract, the locations of all status writers, pseudo-statuses (`no_task` / `stale_<source_type>`), the hook reachability matrix, and other deep details, see:
|
||||||
|
|
||||||
|
- `.trellis/spec/cli/backend/workflow-state-contract.md` — runtime contract + writer table + test invariants
|
||||||
|
- `.trellis/scripts/inject-workflow-state.py` — actual parser (reads workflow.md only, no embedded text)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Workspace Index - boos4721
|
||||||
|
|
||||||
|
> 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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Journal - boos4721 (Part 1)
|
||||||
|
|
||||||
|
> AI development session journal
|
||||||
|
> Started: 2026-06-20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Workspace Index
|
||||||
|
|
||||||
|
> Records of all AI Agent work records across all developers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This directory tracks records for all developers working with AI Agents on this project.
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
workspace/
|
||||||
|
|-- index.md # This file - main index
|
||||||
|
+-- {developer}/ # Per-developer directory
|
||||||
|
|-- index.md # Personal index with session history
|
||||||
|
|-- tasks/ # Task files
|
||||||
|
| |-- *.json # Active tasks
|
||||||
|
| +-- archive/ # Archived tasks by month
|
||||||
|
+-- journal-N.md # Journal files (sequential: 1, 2, 3...)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Active Developers
|
||||||
|
|
||||||
|
| Developer | Last Active | Sessions | Active File |
|
||||||
|
|-----------|-------------|----------|-------------|
|
||||||
|
| (none yet) | - | - | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### For New Developers
|
||||||
|
|
||||||
|
Run the initialization script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./.trellis/scripts/init_developer.py <your-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Create your identity file (gitignored)
|
||||||
|
2. Create your progress directory
|
||||||
|
3. Create your personal index
|
||||||
|
4. Create initial journal file
|
||||||
|
|
||||||
|
### For Returning Developers
|
||||||
|
|
||||||
|
1. Get your developer name:
|
||||||
|
```bash
|
||||||
|
python3 ./.trellis/scripts/get_developer.py
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Read your personal index:
|
||||||
|
```bash
|
||||||
|
cat .trellis/workspace/$(python3 ./.trellis/scripts/get_developer.py)/index.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
### Journal File Rules
|
||||||
|
|
||||||
|
- **Max 2000 lines** per journal file
|
||||||
|
- When limit is reached, create `journal-{N+1}.md`
|
||||||
|
- Update your personal `index.md` when creating new files
|
||||||
|
|
||||||
|
### Session Record Format
|
||||||
|
|
||||||
|
Each session should include:
|
||||||
|
- Summary: One-line description
|
||||||
|
- Branch: Which branch the work was done on
|
||||||
|
- Main Changes: What was modified
|
||||||
|
- Git Commits: Commit hashes and messages
|
||||||
|
- Next Steps: What to do next
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session Template
|
||||||
|
|
||||||
|
Use this template when recording sessions:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Session {N}: {Title}
|
||||||
|
|
||||||
|
**Date**: YYYY-MM-DD
|
||||||
|
**Task**: {task-name}
|
||||||
|
**Branch**: `{branch-name}`
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
{One-line summary}
|
||||||
|
|
||||||
|
### Main Changes
|
||||||
|
|
||||||
|
- {Change 1}
|
||||||
|
- {Change 2}
|
||||||
|
|
||||||
|
### Git Commits
|
||||||
|
|
||||||
|
| Hash | Message |
|
||||||
|
|------|---------|
|
||||||
|
| `abc1234` | {commit message} |
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- [OK] {Test result}
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
[OK] **Completed** / # **In Progress** / [P] **Blocked**
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
|
||||||
|
- {Next step 1}
|
||||||
|
- {Next step 2}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Language**: All documentation must be written in **English**.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
BoosAPI is an AI API gateway: an OpenAI/Anthropic/Gemini-compatible proxy with a token-routing/failover engine, a management console (React SPA), embedded ComfyUI workflow surfaces, billing, and payments. Backend is **pure Rust** (axum + sqlx + tokio); there is no Node.js runtime dependency at serving time — Node is only used to build frontend assets.
|
||||||
|
|
||||||
|
Project-internal docs are largely in Chinese (`AGENTS.md`, `README.md`); match that when editing them.
|
||||||
|
|
||||||
|
## Build & run
|
||||||
|
|
||||||
|
The wrapper script `./build-and-run.sh` is the normal entry point (builds frontend + backend, then runs). Key flags:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build-and-run.sh --check # cargo check only — fastest verification, no frontend, no run
|
||||||
|
./build-and-run.sh # dev profile, reuse frontend cache, compile + start
|
||||||
|
./build-and-run.sh --backend-only # recompile/restart backend, skip Vite
|
||||||
|
./build-and-run.sh --fast-release # near-prod performance, faster than --release
|
||||||
|
./build-and-run.sh --production # build + embed frontend/Workflow UI into binary -> dist/production/
|
||||||
|
```
|
||||||
|
|
||||||
|
Cargo profiles: `dev` (default), `release` (LTO, slow), `fast-release` (opt-level 2, no LTO — use for staging verification). Direct cargo also works: `cargo run --release`, `cargo check`.
|
||||||
|
|
||||||
|
Config comes from `config.toml` plus env overrides via figment. Env vars use the `BOOSAPI_` prefix with `__` as the nesting separator, e.g. `BOOSAPI_SERVER__PORT=4000`, `BOOSAPI_AUTH__AUTH_TOKEN=...`, `BOOSAPI_PAYMENT__STRIPE_SECRET_KEY=...`. Prefer env vars for all secrets.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test # Rust tests (rstest, wiremock, axum-test, tracing-test)
|
||||||
|
cargo test <name> # single Rust test by name substring
|
||||||
|
npm test # web tests: vitest run --root src/web
|
||||||
|
npm run test:watch # vitest watch
|
||||||
|
npx vitest run src/web/api.test.ts # single web test file
|
||||||
|
npm run typecheck # tsc for src/web (app + tests)
|
||||||
|
```
|
||||||
|
|
||||||
|
Rust tests live in `#[cfg(test)]` modules alongside source. Web tests are `src/web/**/*.test.ts(x)`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Proxy core (`src/proxy/`) — the heart of the system
|
||||||
|
|
||||||
|
Request flow for `/v1/*`: **downstream key auth → token route resolution → channel selection → upstream call (with failover) → protocol transform → response**.
|
||||||
|
|
||||||
|
- `token_router/` — `TokenRouter` resolves a requested model name to candidate upstream channels using DB-backed route rows + regex patterns, with a short (2s) in-process cache. Call `invalidate_route_cache()` after mutating routes/channels. Failure cooldown per channel is configurable.
|
||||||
|
- `conductor/` — `ProxyConductor::execute` drives attempt/failover loop (max 5 attempts). It is decoupled from the DB via injected closures (`select_channel`, `select_next_channel`, `record_success`, `record_failure`, optional `refresh_auth`). `retry_policy.rs` classifies failures into actions: failover, retry-same-channel, refresh-auth, or terminal.
|
||||||
|
- `executors/`, `providers/`, `surfaces/`, `capabilities/`, `cli_profiles/` — execution backends and provider/capability metadata.
|
||||||
|
|
||||||
|
### Protocol transformers (`src/transformers/`)
|
||||||
|
|
||||||
|
`openai/`, `anthropic/`, `gemini/` convert between client-facing protocol and upstream protocol around a `canonical/` intermediate form. When adding model/protocol support, work through the canonical layer rather than point-to-point conversions.
|
||||||
|
|
||||||
|
### Routes (`src/routes/`)
|
||||||
|
|
||||||
|
`mod.rs` assembles the axum router by `.merge()`-ing each module's `routes()` and serves the SPA as a fallback (embedded via `include_dir` in production, or from `dist/web` on disk). `admin/` is the management API; `proxy/` is the OpenAI-compatible entry; `comfyui/`, `comfyui_agent.rs`, `workflow_ui.rs`, `digital_human.rs`, `money_printer.rs`, `picture_agent.rs` are the ComfyUI/creative surfaces.
|
||||||
|
|
||||||
|
### Services (`src/services/`)
|
||||||
|
|
||||||
|
Background and cross-cutting logic: `billing/`, `payment.rs`, `cron/`, `notification/`, `runtime_auth.rs`, `builtin_keys.rs`, `comfyui_process.rs`, `route_rebuild.rs`. Shared `AppState` (in `src/lib.rs`) is cloned into handlers and holds config, DB pool, HTTP client, and these service handles.
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
**SQLite is the only runtime path** — `AppState.db` is `Arc<SqlitePool>`, and all runtime queries target SQLite. The Postgres dependency and `boosapi migrate-sqlite-to-postgres` subcommand exist only to copy/migrate schema+data into Postgres; full Postgres runtime query compatibility is not done. Do not assume queries run against Postgres. Migrations live in `migrations/` (SQLite); `db/schema_compat.rs` applies runtime compatibility fixups after migrations.
|
||||||
|
|
||||||
|
## ComfyUI Agent (local-mode contract)
|
||||||
|
|
||||||
|
The ComfyUI Agent defaults to **local mode**: it calls this service's own `/v1/*` routes using a bootstrapped built-in downstream key (`name='comfyui-agent' AND user_id IS NULL`, format `sk-comfyui-{uuid_hex}`, mirrored into `settings.builtin_key.comfyui_agent.secret`).
|
||||||
|
|
||||||
|
- Local (default): `upstream_base = http://{host}/v1` (host `0.0.0.0`/`::` rewritten to `127.0.0.1`); `/api/comfyui-agent/status` returns `mode: "local"`.
|
||||||
|
- Fallback-remote: set `BOOSAPI_UPSTREAM_BASE` (non-empty) to use an external upstream; `BOOSAPI_UPSTREAM_KEY` falls back to the built-in key if unset.
|
||||||
|
- The built-in key is **protected**: admin list masks it (`••••<last4>`), delete/disable return 403, and `builtin_key.*` settings are hidden from list / 403 on PUT. Startup `ensure_comfyui_agent_key` is idempotent (regenerates if missing, re-enables if disabled, keeps settings ↔ downstream_api_keys in sync).
|
||||||
|
|
||||||
|
Workflow backend modes (`[comfyui] backend`): `cloud` (Comfy Cloud, default), `runninghub`, `external` (your own ComfyUI), `boosapi` (built-in Rust workflow/API node executor), `docker` (legacy). Workflow UI has separate `cloud` and `external/runninghub` frontend asset bundles — install with `npm run workflow-ui:install:cloud` / `:external`.
|
||||||
|
|
||||||
|
## Frontend (two separate apps)
|
||||||
|
|
||||||
|
- `src/web/` — management console, React 18 + Vite, built to `dist/web/` and served by the Rust backend. `npm run dev` for HMR, `npm run build:web` to build.
|
||||||
|
- `website/` — standalone marketing site, **Next.js 16 App Router + React 19**, its own package. Use `npm run website:dev` / `website:build` / `website:typecheck` (these proxy to `npm --prefix website`).
|
||||||
|
|
||||||
|
### Auth/session conventions (src/web)
|
||||||
|
|
||||||
|
- Session state goes through `authSession.ts`: `commitSession / clearSession / getSessionToken`, bucketed by audience `'admin' | 'user'`. The old `persistAuthSession / clearAuthSession / getAuthToken / hasValidAuthSession` family is `@deprecated` — do not use in new code.
|
||||||
|
- Login pages are split under `pages/auth/`: `LoginShell` (shared shell) + `AdminLoginPage` (token / account modes) + `UserLoginPage`. OAuth entries render via `OAuthSlot` as placeholders only — do not wire onClick/navigation there.
|
||||||
|
- Login error text is normalized in `loginError.ts` via `resolveLoginErrorMessage(status, raw)` — add new login errors there, not inline.
|
||||||
|
|
||||||
|
## Shared TS/Rust contracts (`src/shared/`)
|
||||||
|
|
||||||
|
This directory holds logic mirrored between Rust (`.rs`) and TypeScript (`.ts`/`.js`) — e.g. `tokenRouteContract`, `tokenRoutePatterns`, `modelBrand`, `platformIdentity`, `proxyLogMeta`, `siteInitializationPresets`. When changing a routing/identity contract, keep the Rust and TS sides in sync.
|
||||||
Generated
+36
@@ -390,6 +390,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
|
"async-trait",
|
||||||
"axum 0.8.9",
|
"axum 0.8.9",
|
||||||
"axum-test",
|
"axum-test",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -407,6 +408,7 @@ dependencies = [
|
|||||||
"mime",
|
"mime",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"open-agent-sdk",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -1825,6 +1827,31 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "open-agent-sdk"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"base64",
|
||||||
|
"bytes",
|
||||||
|
"chrono",
|
||||||
|
"futures",
|
||||||
|
"glob",
|
||||||
|
"pin-project-lite",
|
||||||
|
"regex",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_yaml",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
"uuid",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.80"
|
version = "0.10.80"
|
||||||
@@ -3811,6 +3838,15 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.59.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ hex = "0.4"
|
|||||||
include_dir = "0.7"
|
include_dir = "0.7"
|
||||||
mime_guess = "2"
|
mime_guess = "2"
|
||||||
|
|
||||||
|
# Embedded AI agent engine (MIT, vendored as a path dependency). Provides the
|
||||||
|
# agentic loop, built-in tools, permission callback and cost tracking that the
|
||||||
|
# Agent Workbench (src/routes/agent.rs) drives through BoosAPI's own provider.
|
||||||
|
open-agent-sdk = { path = "../open-agent-sdk-rust" }
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
rstest = "0.24"
|
rstest = "0.24"
|
||||||
wiremock = "0.6"
|
wiremock = "0.6"
|
||||||
|
|||||||
BIN
Binary file not shown.
+35
-35
@@ -1,3 +1,6 @@
|
|||||||
|
# 必填项保留;空字符串字段、有默认值的字段、与凭证相关的内容都从 toml 移走,
|
||||||
|
# 改用环境变量(BOOSAPI_<SECTION>__<KEY>)或后台 admin 界面写入数据库。
|
||||||
|
|
||||||
[server]
|
[server]
|
||||||
host = "0.0.0.0"
|
host = "0.0.0.0"
|
||||||
port = 4000
|
port = 4000
|
||||||
@@ -8,13 +11,9 @@ cors_origins = ["*"]
|
|||||||
url = "sqlite:boosapi.db?mode=rwc"
|
url = "sqlite:boosapi.db?mode=rwc"
|
||||||
max_connections = 10
|
max_connections = 10
|
||||||
ssl = false
|
ssl = false
|
||||||
# PostgreSQL preparation example:
|
|
||||||
# url = "postgres://boosapi:password@127.0.0.1:5432/boosapi"
|
|
||||||
# Note: this build can create/migrate PostgreSQL schemas and copy SQLite data
|
|
||||||
# with `boosapi migrate-sqlite-to-postgres`; full runtime query compatibility
|
|
||||||
# is completed in a follow-up SQL dialect pass.
|
|
||||||
|
|
||||||
[auth]
|
[auth]
|
||||||
|
# 改成生产值前请先改 jwt_secret / auth_token / proxy_token,避免使用默认值。
|
||||||
jwt_secret = "boosapi-dev-secret-change-in-production"
|
jwt_secret = "boosapi-dev-secret-change-in-production"
|
||||||
jwt_expiry_hours = 72
|
jwt_expiry_hours = 72
|
||||||
auth_token = "boos4721"
|
auth_token = "boos4721"
|
||||||
@@ -25,15 +24,11 @@ upstream_timeout_secs = 300
|
|||||||
first_byte_timeout_secs = 60
|
first_byte_timeout_secs = 60
|
||||||
max_retries = 3
|
max_retries = 3
|
||||||
proxy_log_retention_days = 30
|
proxy_log_retention_days = 30
|
||||||
# Optional proxy for external template galleries/workflow template assets.
|
|
||||||
# Supports http://, https:// and socks5:// proxy URLs.
|
|
||||||
# template_proxy_url = "http://127.0.0.1:7890"
|
|
||||||
|
|
||||||
[payment]
|
[payment]
|
||||||
enabled = false
|
enabled = true
|
||||||
# Public HTTPS origin used to build Stripe success/cancel URLs and webhook docs.
|
# provider: "comfy_cloud" | "stripe"
|
||||||
# Example: "https://tybossai.com"
|
provider = "comfy_cloud"
|
||||||
public_base_url = ""
|
|
||||||
product_name_prefix = "BossAI 账户充值"
|
product_name_prefix = "BossAI 账户充值"
|
||||||
currency = "usd"
|
currency = "usd"
|
||||||
min_amount_usd = 1.0
|
min_amount_usd = 1.0
|
||||||
@@ -42,35 +37,40 @@ order_timeout_minutes = 30
|
|||||||
max_pending_orders = 3
|
max_pending_orders = 3
|
||||||
success_path = "/user/recharge?payment=success&order={ORDER_ID}"
|
success_path = "/user/recharge?payment=success&order={ORDER_ID}"
|
||||||
cancel_path = "/user/recharge?payment=cancel&order={ORDER_ID}"
|
cancel_path = "/user/recharge?payment=cancel&order={ORDER_ID}"
|
||||||
# Prefer environment variables for secrets:
|
comfy_customer_api_base_url = "https://api.comfy.org"
|
||||||
# BOOSAPI_PAYMENT__STRIPE_SECRET_KEY=sk_live_...
|
# 凭证统一走环境变量或后台「支付管理」写入;不要写在这里:
|
||||||
# BOOSAPI_PAYMENT__STRIPE_WEBHOOK_SECRET=whsec_...
|
# BOOSAPI_PAYMENT__PUBLIC_BASE_URL
|
||||||
stripe_secret_key = ""
|
# BOOSAPI_PAYMENT__STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET
|
||||||
stripe_webhook_secret = ""
|
# BOOSAPI_PAYMENT__COMFY_CUSTOMER_TOKEN / COMFY_CUSTOMER_TOKEN_ENDPOINT
|
||||||
|
# BOOSAPI_PAYMENT__COMFY_FIREBASE_API_KEY / COMFY_OAUTH_REFRESH_TOKEN
|
||||||
|
# BOOSAPI_PAYMENT__COMFY_OAUTH_CLIENT_ID / COMFY_OAUTH_CLIENT_SECRET
|
||||||
|
# BOOSAPI_PAYMENT__COMFY_CHECKOUT_ENDPOINT
|
||||||
|
|
||||||
[comfyui]
|
[comfyui]
|
||||||
enabled = false
|
enabled = false
|
||||||
|
# backend: "boosapi" | "cloud" | "runninghub" | "external" | "docker"
|
||||||
|
backend = "cloud"
|
||||||
python_bin = "python3"
|
python_bin = "python3"
|
||||||
port = 8188
|
port = 8188
|
||||||
extra_args = []
|
extra_args = []
|
||||||
max_restarts = 5
|
max_restarts = 5
|
||||||
# backend modes:
|
|
||||||
# - "boosapi": use BoosAPI's own Rust Workflow/API node executor.
|
|
||||||
# - "cloud": proxy to Comfy Cloud and use native ComfyUI /prompt and /ws.
|
|
||||||
# - "runninghub": proxy to RunningHub's ComfyUI-compatible API with external-compatible frontend.
|
|
||||||
# - "external": proxy to your own full ComfyUI server.
|
|
||||||
# - "docker": legacy local Docker ComfyUI mode; not recommended for the current API Workflow path.
|
|
||||||
backend = "cloud"
|
|
||||||
# Native Comfy task costs are charged against the user's downstream API Key
|
|
||||||
# only after successful output archival. Keep 0 while pricing is not finalized.
|
|
||||||
money_printer_video_cost = 0.0
|
|
||||||
digital_human_video_cost = 0.0
|
|
||||||
# docker_image = "boosapi-comfyui:multi-tenant"
|
|
||||||
# container_name = "boosapi-comfyui-workflow"
|
|
||||||
# base_url = "https://www.runninghub.cn/proxy-plus/94d1330ea62d4ae980ffb018ecdf82a2"
|
|
||||||
# For RunningHub, set backend = "runninghub" and use your RunningHub proxy/API URL.
|
|
||||||
# RunningHub proxy URL already contains the API key. Keep this empty unless
|
|
||||||
# you switch to a backend that requires Authorization / X-API-Key headers.
|
|
||||||
base_url = "https://cloud.comfy.org"
|
base_url = "https://cloud.comfy.org"
|
||||||
api_key = "comfyui-cbe4ebb1e22ee4ef2dab4d9293a76d5272a25e5ec847ac57387ab2b30798d86f"
|
api_key = "comfyui-cbe4ebb1e22ee4ef2dab4d9293a76d5272a25e5ec847ac57387ab2b30798d86f"
|
||||||
agent_model = "gpt-5.5"
|
agent_model = "deepseek-v4-flash"
|
||||||
|
|
||||||
|
[pixelle_video]
|
||||||
|
enabled = true
|
||||||
|
# Pixelle-Video 单独启动:
|
||||||
|
# git clone https://github.com/AIDC-AI/Pixelle-Video /opt/Pixelle-Video
|
||||||
|
# cd /opt/Pixelle-Video && uv run python api/app.py --host 127.0.0.1 --port 18000
|
||||||
|
api_base_url = "http://127.0.0.1:18000"
|
||||||
|
frame_template = "1080x1920/image_default.html"
|
||||||
|
media_workflow = "selfhost/video_wan2.1_fusionx.json"
|
||||||
|
tts_workflow = "selfhost/tts_edge.json"
|
||||||
|
runninghub_media_workflow = "runninghub/video_wan2.1_fusionx.json"
|
||||||
|
runninghub_tts_workflow = "runninghub/tts_edge.json"
|
||||||
|
mode = "fixed"
|
||||||
|
n_scenes = 5
|
||||||
|
video_fps = 30
|
||||||
|
poll_interval_secs = 5
|
||||||
|
timeout_secs = 900
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- Agent Workbench sessions: per-session token / cost accounting and metadata.
|
||||||
|
--
|
||||||
|
-- One row per agent conversation session. Token counts and cost accumulate
|
||||||
|
-- across the turns of a session; `updated_at` advances on every `/api/agent/chat`
|
||||||
|
-- round-trip. `user_id` is nullable so admin (global-token) sessions are allowed
|
||||||
|
-- without a user row, but every session is still isolated by its own sandbox
|
||||||
|
-- directory keyed on `id`.
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
user_id INTEGER,
|
||||||
|
title TEXT,
|
||||||
|
model TEXT NOT NULL DEFAULT '',
|
||||||
|
permission_mode TEXT NOT NULL DEFAULT 'default',
|
||||||
|
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cost_usd REAL NOT NULL DEFAULT 0,
|
||||||
|
num_turns INTEGER NOT NULL DEFAULT 0,
|
||||||
|
message_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS agent_sessions_user_id_idx
|
||||||
|
ON agent_sessions (user_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS agent_sessions_updated_at_idx
|
||||||
|
ON agent_sessions (updated_at);
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1246,3 +1246,215 @@ pub async fn execute_image_edit(
|
|||||||
|
|
||||||
Ok(json)
|
Ok(json)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Gemini native passthrough ───────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Gemini's native protocol carries the model in the URL path
|
||||||
|
// (`/v1beta/models/{model}:{op}`) and the API key in the `?key=` query, so the
|
||||||
|
// request body needs no rewrite — we forward it verbatim. This mirrors AQBot's
|
||||||
|
// `handle_native_request`, which deliberately skips `rewrite_body_model` for
|
||||||
|
// Gemini, and BoosAPI's own `/v1/responses` passthrough.
|
||||||
|
|
||||||
|
/// Normalize an upstream base into a Gemini `v1beta` root. Accepts bases that
|
||||||
|
/// already end in `/v1beta` (or contain `/v1beta/`) and leaves them as-is;
|
||||||
|
/// otherwise appends `/v1beta`. Trailing slashes are trimmed.
|
||||||
|
fn gemini_base_url(base_url: &str) -> String {
|
||||||
|
let trimmed = base_url.trim().trim_end_matches('/');
|
||||||
|
if trimmed.ends_with("/v1beta") || trimmed.contains("/v1beta/") {
|
||||||
|
trimmed.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{trimmed}/v1beta")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build `{base}/v1beta/models/{model}:{op}?key={api_key}[&{extra_query}]`.
|
||||||
|
/// `extra_query` carries client query params already filtered (e.g. `alt=sse`),
|
||||||
|
/// without a leading `?`/`&`.
|
||||||
|
fn gemini_operation_url(
|
||||||
|
base_url: &str,
|
||||||
|
model: &str,
|
||||||
|
operation: &str,
|
||||||
|
api_key: &str,
|
||||||
|
extra_query: Option<&str>,
|
||||||
|
) -> String {
|
||||||
|
let base = gemini_base_url(base_url);
|
||||||
|
let model = model.trim().trim_start_matches('/');
|
||||||
|
let model = model.strip_prefix("models/").unwrap_or(model);
|
||||||
|
let mut url = format!("{base}/models/{model}:{operation}");
|
||||||
|
let mut sep = '?';
|
||||||
|
if !api_key.is_empty() {
|
||||||
|
url.push(sep);
|
||||||
|
url.push_str("key=");
|
||||||
|
url.push_str(api_key);
|
||||||
|
sep = '&';
|
||||||
|
}
|
||||||
|
if let Some(query) = extra_query.filter(|q| !q.is_empty()) {
|
||||||
|
url.push(sep);
|
||||||
|
url.push_str(query);
|
||||||
|
}
|
||||||
|
url
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a non-streaming Gemini operation (`generateContent` / `countTokens`)
|
||||||
|
/// and return the upstream JSON verbatim.
|
||||||
|
pub async fn execute_gemini_operation(
|
||||||
|
state: AppState,
|
||||||
|
route: UpstreamRoute,
|
||||||
|
operation: &str,
|
||||||
|
body: Value,
|
||||||
|
extra_query: Option<&str>,
|
||||||
|
) -> AppResult<Value> {
|
||||||
|
let client = state.http.clone();
|
||||||
|
let upstream_url =
|
||||||
|
gemini_operation_url(&route.base_url, &route.model, operation, &route.api_key, extra_query);
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post(&upstream_url)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::UpstreamError(format!("Upstream request failed: {e}")))?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let text = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::UpstreamError(format!("Failed to read upstream response: {e}")))?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(AppError::UpstreamError(format!(
|
||||||
|
"Upstream returned {status}: {text}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_upstream_json(&text, &upstream_url, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a streaming Gemini operation (`streamGenerateContent`) and return the
|
||||||
|
/// live upstream response for SSE passthrough. `alt=sse` is enforced in the
|
||||||
|
/// query so the upstream emits `data:`-framed events the accumulator can parse.
|
||||||
|
pub async fn execute_gemini_stream_response(
|
||||||
|
state: AppState,
|
||||||
|
route: UpstreamRoute,
|
||||||
|
operation: &str,
|
||||||
|
body: Value,
|
||||||
|
extra_query: Option<&str>,
|
||||||
|
) -> AppResult<reqwest::Response> {
|
||||||
|
let client = state.http.clone();
|
||||||
|
let query = merge_alt_sse(extra_query);
|
||||||
|
let upstream_url = gemini_operation_url(
|
||||||
|
&route.base_url,
|
||||||
|
&route.model,
|
||||||
|
operation,
|
||||||
|
&route.api_key,
|
||||||
|
Some(&query),
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post(&upstream_url)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::UpstreamError(format!("Upstream request failed: {e}")))?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let text = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| "unknown error".to_string());
|
||||||
|
return Err(AppError::UpstreamError(format!(
|
||||||
|
"Upstream returned {status}: {text}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure `alt=sse` is present in the extra query (Gemini streams plain JSON
|
||||||
|
/// arrays without it). Preserves any other client-supplied params.
|
||||||
|
fn merge_alt_sse(extra_query: Option<&str>) -> String {
|
||||||
|
match extra_query.filter(|q| !q.is_empty()) {
|
||||||
|
Some(q) if q.split('&').any(|p| p == "alt=sse" || p.starts_with("alt=")) => q.to_string(),
|
||||||
|
Some(q) => format!("{q}&alt=sse"),
|
||||||
|
None => "alt=sse".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod gemini_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn base_url_appends_v1beta_when_missing() {
|
||||||
|
assert_eq!(
|
||||||
|
gemini_base_url("https://generativelanguage.googleapis.com"),
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn base_url_preserves_existing_v1beta() {
|
||||||
|
assert_eq!(
|
||||||
|
gemini_base_url("https://generativelanguage.googleapis.com/v1beta/"),
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn operation_url_embeds_model_op_and_key() {
|
||||||
|
let url = gemini_operation_url(
|
||||||
|
"https://generativelanguage.googleapis.com",
|
||||||
|
"gemini-2.0-flash",
|
||||||
|
"generateContent",
|
||||||
|
"secret",
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=secret"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn operation_url_strips_models_prefix_and_appends_query() {
|
||||||
|
let url = gemini_operation_url(
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta",
|
||||||
|
"models/gemini-1.5-pro",
|
||||||
|
"streamGenerateContent",
|
||||||
|
"k",
|
||||||
|
Some("alt=sse"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro:streamGenerateContent?key=k&alt=sse"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn operation_url_without_key_uses_question_mark_for_query() {
|
||||||
|
let url = gemini_operation_url(
|
||||||
|
"https://example.test/v1beta",
|
||||||
|
"gemini-x",
|
||||||
|
"countTokens",
|
||||||
|
"",
|
||||||
|
Some("foo=bar"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
"https://example.test/v1beta/models/gemini-x:countTokens?foo=bar"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_alt_sse_variants() {
|
||||||
|
assert_eq!(merge_alt_sse(None), "alt=sse");
|
||||||
|
assert_eq!(merge_alt_sse(Some("")), "alt=sse");
|
||||||
|
assert_eq!(merge_alt_sse(Some("foo=bar")), "foo=bar&alt=sse");
|
||||||
|
// already present → unchanged
|
||||||
|
assert_eq!(merge_alt_sse(Some("alt=sse")), "alt=sse");
|
||||||
|
assert_eq!(merge_alt_sse(Some("alt=json")), "alt=json");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2955,8 +2955,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
pixelle_video: Default::default(),
|
|
||||||
payment: Default::default(),
|
payment: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
|
|||||||
@@ -412,8 +412,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
pixelle_video: Default::default(),
|
|
||||||
payment: Default::default(),
|
payment: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
|
|||||||
@@ -0,0 +1,480 @@
|
|||||||
|
//! Agent Workbench HTTP surface.
|
||||||
|
//!
|
||||||
|
//! A general-purpose, server-side AI agent exposed to the SPA over SSE. The
|
||||||
|
//! agentic loop comes from the `open-agent-sdk` crate; every LLM call is routed
|
||||||
|
//! back through BoosAPI's own OpenAI-compatible `/v1` endpoint using the
|
||||||
|
//! built-in downstream key, so agent usage flows through the same aggregation
|
||||||
|
//! and accounting pipeline as any other consumer (and can use any aggregated
|
||||||
|
//! model).
|
||||||
|
//!
|
||||||
|
//! Routes (all require a Bearer token — admin token, proxy token, or a user JWT):
|
||||||
|
//! - `POST /api/agent/chat` Stream a turn as SSE (text / tool_use /
|
||||||
|
//! tool_result / approval_request / result / error / done).
|
||||||
|
//! - `POST /api/agent/approve` Resolve a pending tool approval.
|
||||||
|
//! - `GET /api/agent/status` Engine status + a session's accounting.
|
||||||
|
//!
|
||||||
|
//! ## Safety
|
||||||
|
//! Each session runs inside an isolated sandbox directory
|
||||||
|
//! (`data/agent_sessions/{id}/`). The SDK permission callback ([`build_can_use_tool`])
|
||||||
|
//! enforces two gates on every tool: (1) path arguments must stay within the
|
||||||
|
//! sandbox, and (2) writes/executes require explicit user approval unless the
|
||||||
|
//! session mode opts out. The default mode requires approval for anything that
|
||||||
|
//! mutates state or runs a command.
|
||||||
|
|
||||||
|
use crate::auth::jwt::validate_jwt;
|
||||||
|
use crate::services::agent_workbench::{
|
||||||
|
approval::{self, ApprovalOutcome, SettledState},
|
||||||
|
bash_sandbox::SandboxedBashTool,
|
||||||
|
permission::{classify_tool_risk, decide_permission, PermissionAction, PermissionMode},
|
||||||
|
sandbox,
|
||||||
|
session_store::{self, UsageDelta},
|
||||||
|
};
|
||||||
|
use crate::AppState;
|
||||||
|
use axum::{
|
||||||
|
extract::{DefaultBodyLimit, State},
|
||||||
|
http::{HeaderMap, StatusCode},
|
||||||
|
response::sse::{Event, Sse},
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use futures::stream::Stream;
|
||||||
|
use open_agent_sdk::api::openai::OpenAIProvider;
|
||||||
|
use open_agent_sdk::types::{ContentBlock, PermissionDecision};
|
||||||
|
use open_agent_sdk::{Agent, AgentOptions, LLMProvider, SDKMessage};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::convert::Infallible;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
|
||||||
|
use tokio_stream::wrappers::UnboundedReceiverStream;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Max agentic turns per chat request (bounds runaway loops / cost).
|
||||||
|
const MAX_TURNS: u32 = 20;
|
||||||
|
/// How long a tool approval waits for the user before auto-denying.
|
||||||
|
const APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
|
||||||
|
/// Body limit for a chat request (images/attachments can be large).
|
||||||
|
const AGENT_CHAT_BODY_LIMIT_BYTES: usize = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT: &str = "You are BoosAPI's built-in engineering agent. You can read and write files \
|
||||||
|
and run shell commands, but only inside the current working directory, which is an \
|
||||||
|
isolated sandbox for this session. Be concise. Explain what you are about to do before \
|
||||||
|
using a tool that writes files or runs commands. Never attempt to access paths outside \
|
||||||
|
the working directory.";
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/agent/chat", post(chat_handler))
|
||||||
|
.route("/api/agent/approve", post(approve_handler))
|
||||||
|
.route("/api/agent/status", get(status_handler))
|
||||||
|
.layer(DefaultBodyLimit::max(AGENT_CHAT_BODY_LIMIT_BYTES))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ChatRequest {
|
||||||
|
/// The user's prompt for this turn.
|
||||||
|
message: String,
|
||||||
|
/// Existing session id to continue; a new one is minted when absent.
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
/// Aggregated model id; falls back to the configured agent model.
|
||||||
|
#[serde(default)]
|
||||||
|
model: Option<String>,
|
||||||
|
/// Permission mode: "default" | "accept_edits" | "full_access".
|
||||||
|
#[serde(default)]
|
||||||
|
permission_mode: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ApproveRequest {
|
||||||
|
request_id: String,
|
||||||
|
approved: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolved caller identity for an agent request.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct AgentAuth {
|
||||||
|
/// `None` for admin/global-token callers; `Some(id)` for user JWTs.
|
||||||
|
user_id: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
|
||||||
|
headers
|
||||||
|
.get(axum::http::header::AUTHORIZATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.strip_prefix("Bearer "))
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticate the caller. Admin token / proxy token map to an admin
|
||||||
|
/// (user-less) session; a valid user/admin JWT maps to that user's id.
|
||||||
|
async fn resolve_auth(
|
||||||
|
headers: &HeaderMap,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<AgentAuth, (StatusCode, Json<Value>)> {
|
||||||
|
let unauthorized = || {
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(json!({ "error": "Unauthorized" })),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = extract_bearer_token(headers).ok_or_else(unauthorized)?;
|
||||||
|
|
||||||
|
if token == state.config.auth.auth_token || token == state.runtime_proxy_token() {
|
||||||
|
return Ok(AgentAuth { user_id: None });
|
||||||
|
}
|
||||||
|
|
||||||
|
let claims = validate_jwt(&state.config.auth.jwt_secret, token).map_err(|_| unauthorized())?;
|
||||||
|
if claims.role != "admin" && claims.role != "user" {
|
||||||
|
return Err((StatusCode::FORBIDDEN, Json(json!({ "error": "Forbidden" }))));
|
||||||
|
}
|
||||||
|
let user_id = claims
|
||||||
|
.sub
|
||||||
|
.parse::<i64>()
|
||||||
|
.map_err(|_| (StatusCode::UNAUTHORIZED, Json(json!({ "error": "Invalid user id" }))))?;
|
||||||
|
Ok(AgentAuth { user_id: Some(user_id) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send one SSE event; returns false if the receiver (client) has gone away.
|
||||||
|
fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) -> bool {
|
||||||
|
tx.send(Ok(Event::default().data(payload.to_string()))).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve BoosAPI's own base URL (no `/v1` suffix — the OpenAI provider appends
|
||||||
|
/// `/v1/chat/completions`).
|
||||||
|
fn boosapi_base(state: &AppState) -> String {
|
||||||
|
let host = match state.config.server.host.as_str() {
|
||||||
|
"0.0.0.0" | "::" => "127.0.0.1",
|
||||||
|
other => other,
|
||||||
|
};
|
||||||
|
format!("http://{}:{}", host, state.config.server.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the SDK permission callback that enforces sandbox path safety and the
|
||||||
|
/// approval flow. The callback runs inside the agent loop for every tool call.
|
||||||
|
///
|
||||||
|
/// Order of checks:
|
||||||
|
/// 1. Sandbox: any path argument that escapes `cwd` is hard-denied immediately.
|
||||||
|
/// 2. Risk + mode: read-only (and, per mode, writes) auto-allow; otherwise emit
|
||||||
|
/// an `approval_request` SSE event and block until the user responds (or the
|
||||||
|
/// approval times out, which denies).
|
||||||
|
fn build_can_use_tool(
|
||||||
|
sse_tx: UnboundedSender<Result<Event, Infallible>>,
|
||||||
|
cwd: std::path::PathBuf,
|
||||||
|
mode: PermissionMode,
|
||||||
|
) -> open_agent_sdk::types::CanUseToolFn {
|
||||||
|
Arc::new(move |tool_name: &str, input: &Value| {
|
||||||
|
let sse_tx = sse_tx.clone();
|
||||||
|
let cwd = cwd.clone();
|
||||||
|
let tool_name = tool_name.to_string();
|
||||||
|
let input = input.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
// Gate 1: sandbox path safety (always enforced, even in full access).
|
||||||
|
if let Err(reason) = sandbox::check_tool_paths(&tool_name, &input, &cwd) {
|
||||||
|
send_sse(
|
||||||
|
&sse_tx,
|
||||||
|
json!({
|
||||||
|
"type": "tool_denied",
|
||||||
|
"data": { "name": tool_name, "reason": reason }
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return PermissionDecision::Deny(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate 2: risk + permission mode.
|
||||||
|
let risk = classify_tool_risk(&tool_name);
|
||||||
|
match decide_permission(mode, risk) {
|
||||||
|
PermissionAction::AutoAllow => PermissionDecision::Allow,
|
||||||
|
PermissionAction::RequireApproval => {
|
||||||
|
let request_id = Uuid::new_v4().to_string();
|
||||||
|
// `_guard` removes the registry entry when this future ends
|
||||||
|
// (normal completion or drop on session abort) — no leak.
|
||||||
|
let (entry, _guard) = approval::register(&request_id);
|
||||||
|
send_sse(
|
||||||
|
&sse_tx,
|
||||||
|
json!({
|
||||||
|
"type": "approval_request",
|
||||||
|
"data": {
|
||||||
|
"request_id": request_id,
|
||||||
|
"name": tool_name,
|
||||||
|
"input": input,
|
||||||
|
"risk": format!("{risk:?}"),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Single source of truth: the settled atomic state decides
|
||||||
|
// allow vs deny. `resolve` reports back to the frontend
|
||||||
|
// whether its CAS won, so the two can never disagree.
|
||||||
|
let settled = approval::wait_for_decision(&entry, APPROVAL_TIMEOUT).await;
|
||||||
|
let (decision, wire) = match settled {
|
||||||
|
SettledState::Approved => (PermissionDecision::Allow, "approved"),
|
||||||
|
SettledState::Denied => {
|
||||||
|
(PermissionDecision::Deny("Denied by user".to_string()), "denied")
|
||||||
|
}
|
||||||
|
SettledState::TimedOut => (
|
||||||
|
PermissionDecision::Deny("Approval timed out".to_string()),
|
||||||
|
"timed_out",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
// Tell the client how this prompt actually resolved, so a
|
||||||
|
// late click (which the /approve call already reported as
|
||||||
|
// ok:false) can dismiss the modal with the real outcome.
|
||||||
|
send_sse(
|
||||||
|
&sse_tx,
|
||||||
|
json!({
|
||||||
|
"type": "approval_resolved",
|
||||||
|
"data": { "request_id": request_id, "decision": wire }
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
decision
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
async fn chat_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<ChatRequest>,
|
||||||
|
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, (StatusCode, Json<Value>)> {
|
||||||
|
let auth = resolve_auth(&headers, &state).await?;
|
||||||
|
|
||||||
|
if body.message.trim().is_empty() {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({ "error": "message is required" })),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let session_id = body
|
||||||
|
.session_id
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||||
|
let mode = PermissionMode::from_wire(body.permission_mode.as_deref().unwrap_or("default"));
|
||||||
|
let model = body
|
||||||
|
.model
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.unwrap_or_else(|| state.config.comfyui.agent_model.clone());
|
||||||
|
|
||||||
|
// Per-session sandbox working directory.
|
||||||
|
let cwd = match sandbox::ensure_session_dir(&session_id) {
|
||||||
|
Ok(dir) => dir,
|
||||||
|
Err(e) => {
|
||||||
|
return Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": format!("failed to create session sandbox: {e}") })),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Persist (idempotently) the session row before streaming.
|
||||||
|
if let Err(e) = session_store::ensure_session(
|
||||||
|
&state.db,
|
||||||
|
&session_id,
|
||||||
|
auth.user_id,
|
||||||
|
&model,
|
||||||
|
&format!("{mode:?}").to_lowercase(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(%e, "failed to ensure agent session row");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route the agent's LLM calls back through BoosAPI's own aggregation using
|
||||||
|
// the built-in downstream key.
|
||||||
|
let base_url = boosapi_base(&state);
|
||||||
|
let internal_key = state.builtin_keys.comfyui_agent.1.clone();
|
||||||
|
|
||||||
|
let (tx, rx) = unbounded_channel::<Result<Event, Infallible>>();
|
||||||
|
|
||||||
|
let db = state.db.clone();
|
||||||
|
let prompt = body.message;
|
||||||
|
let session_for_event = session_id.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// First event: hand the (possibly newly minted) session id to the client.
|
||||||
|
send_sse(
|
||||||
|
&tx,
|
||||||
|
json!({ "type": "session", "data": { "session_id": session_for_event, "model": model } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
let provider: Arc<dyn LLMProvider> = Arc::new(OpenAIProvider::new(
|
||||||
|
reqwest::Client::new(),
|
||||||
|
internal_key,
|
||||||
|
base_url,
|
||||||
|
Default::default(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let can_use_tool = build_can_use_tool(tx.clone(), cwd.clone(), mode);
|
||||||
|
|
||||||
|
// Replace the SDK's built-in Bash (a bare subprocess) with an
|
||||||
|
// OS-sandboxed one confined to this session's directory. Registered as
|
||||||
|
// a custom tool, it overrides the built-in by name; review modes run it
|
||||||
|
// under bwrap, FullAccess runs it unconfined with audit logging.
|
||||||
|
let sandboxed_bash: Arc<dyn open_agent_sdk::types::Tool> =
|
||||||
|
Arc::new(SandboxedBashTool::new(cwd.clone(), mode));
|
||||||
|
|
||||||
|
let agent = Agent::new(AgentOptions {
|
||||||
|
provider: Some(provider),
|
||||||
|
model: Some(model.clone()),
|
||||||
|
cwd: Some(cwd.to_string_lossy().to_string()),
|
||||||
|
system_prompt: Some(SYSTEM_PROMPT.to_string()),
|
||||||
|
max_turns: Some(MAX_TURNS),
|
||||||
|
can_use_tool: Some(can_use_tool),
|
||||||
|
custom_tools: vec![sandboxed_bash],
|
||||||
|
session_id: Some(session_id.clone()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut agent = match agent {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(e) => {
|
||||||
|
send_sse(&tx, json!({ "type": "error", "data": { "message": e } }));
|
||||||
|
send_sse(&tx, json!({ "type": "done" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (mut rx_msg, handle) = agent.query(&prompt).await;
|
||||||
|
|
||||||
|
while let Some(event) = rx_msg.recv().await {
|
||||||
|
// Stop early if the client disconnected.
|
||||||
|
if tx.is_closed() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
forward_sdk_event(&tx, &db, &session_id, event).await;
|
||||||
|
}
|
||||||
|
let _ = handle.await;
|
||||||
|
agent.close().await;
|
||||||
|
|
||||||
|
send_sse(&tx, json!({ "type": "done" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Sse::new(UnboundedReceiverStream::new(rx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate one SDK message into SSE event(s), and persist usage on Result.
|
||||||
|
async fn forward_sdk_event(
|
||||||
|
tx: &UnboundedSender<Result<Event, Infallible>>,
|
||||||
|
db: &sqlx::SqlitePool,
|
||||||
|
session_id: &str,
|
||||||
|
event: SDKMessage,
|
||||||
|
) {
|
||||||
|
match event {
|
||||||
|
SDKMessage::Assistant { message, .. } => {
|
||||||
|
for block in &message.content {
|
||||||
|
match block {
|
||||||
|
ContentBlock::Text { text } if !text.is_empty() => {
|
||||||
|
send_sse(tx, json!({ "type": "text", "data": { "text": text } }));
|
||||||
|
}
|
||||||
|
ContentBlock::ToolUse { id, name, input } => {
|
||||||
|
send_sse(
|
||||||
|
tx,
|
||||||
|
json!({ "type": "tool_use", "data": { "id": id, "name": name, "input": input } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ContentBlock::Thinking { thinking, .. } if !thinking.is_empty() => {
|
||||||
|
send_sse(tx, json!({ "type": "thinking", "data": { "thinking": thinking } }));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SDKMessage::ToolResult { tool_use_id, tool_name, content, is_error } => {
|
||||||
|
send_sse(
|
||||||
|
tx,
|
||||||
|
json!({
|
||||||
|
"type": "tool_result",
|
||||||
|
"data": { "tool_use_id": tool_use_id, "name": tool_name, "content": content, "is_error": is_error }
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
SDKMessage::Result { text, usage, num_turns, cost_usd, .. } => {
|
||||||
|
// Persist this turn's accounting (user prompt + assistant reply ≈ 2 msgs).
|
||||||
|
let delta = UsageDelta {
|
||||||
|
input_tokens: usage.input_tokens as i64,
|
||||||
|
output_tokens: usage.output_tokens as i64,
|
||||||
|
cost_usd,
|
||||||
|
num_turns: num_turns as i64,
|
||||||
|
};
|
||||||
|
if let Err(e) = session_store::record_turn(db, session_id, delta, 2).await {
|
||||||
|
tracing::warn!(%e, "failed to record agent turn usage");
|
||||||
|
}
|
||||||
|
send_sse(
|
||||||
|
tx,
|
||||||
|
json!({
|
||||||
|
"type": "result",
|
||||||
|
"data": {
|
||||||
|
"text": text,
|
||||||
|
"num_turns": num_turns,
|
||||||
|
"input_tokens": usage.input_tokens,
|
||||||
|
"output_tokens": usage.output_tokens,
|
||||||
|
"total_tokens": usage.input_tokens + usage.output_tokens,
|
||||||
|
"cost_usd": cost_usd,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
SDKMessage::Error { message } => {
|
||||||
|
send_sse(tx, json!({ "type": "error", "data": { "message": message } }));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn approve_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<ApproveRequest>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
// Auth required, but any authenticated caller may answer the prompt for
|
||||||
|
// their own running session (approvals are keyed by an unguessable uuid).
|
||||||
|
resolve_auth(&headers, &state).await?;
|
||||||
|
let outcome = if body.approved { ApprovalOutcome::Approve } else { ApprovalOutcome::Deny };
|
||||||
|
let delivered = approval::resolve(&body.request_id, outcome);
|
||||||
|
Ok(Json(json!({ "ok": delivered })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn status_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
axum::extract::Query(q): axum::extract::Query<StatusQuery>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
let auth = resolve_auth(&headers, &state).await?;
|
||||||
|
let session = match q.session_id.as_deref().filter(|s| !s.is_empty()) {
|
||||||
|
Some(sid) => session_store::get_session(&state.db, sid, auth.user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": e.to_string() })),
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Ok(Json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"engine": "open-agent-sdk",
|
||||||
|
"default_model": state.config.comfyui.agent_model,
|
||||||
|
"session": session,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct StatusQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
}
|
||||||
+4
-1
@@ -19,7 +19,9 @@ use serde_json::{json, Value};
|
|||||||
use std::net::{IpAddr, SocketAddr};
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
/// Credit (in USD) granted to a newly self-registered user's default API key.
|
/// Default spending cap (USD) granted to a newly created user's auto-generated
|
||||||
|
/// API key. Per the billing design (新用户默认 $1 额度), new users start with a
|
||||||
|
/// $1 allowance on their default downstream key.
|
||||||
pub const DEFAULT_NEW_USER_MAX_COST_USD: f64 = 1.0;
|
pub const DEFAULT_NEW_USER_MAX_COST_USD: f64 = 1.0;
|
||||||
|
|
||||||
// ── Request / Response types ─────────────────────────────────────────
|
// ── Request / Response types ─────────────────────────────────────────
|
||||||
@@ -1045,6 +1047,7 @@ mod tests {
|
|||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
pixelle_video: Default::default(),
|
pixelle_video: Default::default(),
|
||||||
payment: Default::default(),
|
payment: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
|
|||||||
@@ -126,8 +126,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: ComfyUIConfig::default(),
|
comfyui: ComfyUIConfig::default(),
|
||||||
pixelle_video: Default::default(),
|
|
||||||
payment: Default::default(),
|
payment: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
|
|||||||
@@ -1552,8 +1552,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
pixelle_video: Default::default(),
|
|
||||||
payment: Default::default(),
|
payment: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
|
|||||||
@@ -580,8 +580,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
pixelle_video: Default::default(),
|
|
||||||
payment: Default::default(),
|
payment: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const _: &[u8] = include_bytes!(concat!(
|
|||||||
));
|
));
|
||||||
|
|
||||||
mod admin;
|
mod admin;
|
||||||
|
mod agent;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod checkin;
|
mod checkin;
|
||||||
mod comfy_cloud_adapter;
|
mod comfy_cloud_adapter;
|
||||||
@@ -57,6 +58,7 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.merge(comfy_cloud_adapter::routes())
|
.merge(comfy_cloud_adapter::routes())
|
||||||
.merge(money_printer::routes())
|
.merge(money_printer::routes())
|
||||||
.merge(comfyui_agent::routes())
|
.merge(comfyui_agent::routes())
|
||||||
|
.merge(agent::routes())
|
||||||
.merge(digital_human::routes())
|
.merge(digital_human::routes())
|
||||||
.merge(comfyui::routes())
|
.merge(comfyui::routes())
|
||||||
.merge(crate::workflow::routes::routes())
|
.merge(crate::workflow::routes::routes())
|
||||||
|
|||||||
@@ -2277,8 +2277,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui,
|
comfyui,
|
||||||
pixelle_video: Default::default(),
|
|
||||||
payment: Default::default(),
|
payment: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
|
|||||||
+1
-1
@@ -2550,8 +2550,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: ComfyUIConfig::default(),
|
comfyui: ComfyUIConfig::default(),
|
||||||
pixelle_video: Default::default(),
|
|
||||||
payment: Default::default(),
|
payment: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
|
|||||||
+508
-1
@@ -73,6 +73,16 @@ pub fn routes() -> Router<AppState> {
|
|||||||
.route("/v1/videos", post(videos_handler))
|
.route("/v1/videos", post(videos_handler))
|
||||||
.route("/v1/videos/{id}", get(video_task_handler))
|
.route("/v1/videos/{id}", get(video_task_handler))
|
||||||
.route("/v1/models", get(list_models))
|
.route("/v1/models", get(list_models))
|
||||||
|
// ── Gemini native inbound (v1beta) ────────────────────────────
|
||||||
|
// Lets Gemini CLI / Google GenAI SDKs point their base URL straight
|
||||||
|
// at this gateway. The model lives in the path and the key in the
|
||||||
|
// `?key=` query (or x-goog-api-key header), so requests pass through
|
||||||
|
// to a Gemini-shaped upstream without body rewriting.
|
||||||
|
.route("/v1beta/models", get(gemini_list_models))
|
||||||
|
.route(
|
||||||
|
"/v1beta/models/{model_action}",
|
||||||
|
post(gemini_model_operation),
|
||||||
|
)
|
||||||
// ── ComfyUI shim aliases ──────────────────────────────────────
|
// ── ComfyUI shim aliases ──────────────────────────────────────
|
||||||
// ComfyUI's OpenAI nodes hit /proxy/openai/... paths (see
|
// ComfyUI's OpenAI nodes hit /proxy/openai/... paths (see
|
||||||
// comfy_api_nodes/nodes_openai.py). Mounting them here as aliases
|
// comfy_api_nodes/nodes_openai.py). Mounting them here as aliases
|
||||||
@@ -157,6 +167,209 @@ async fn responses_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Gemini native inbound handlers (v1beta) ────────────────────────────
|
||||||
|
//
|
||||||
|
// Gemini clients (Gemini CLI / Google GenAI SDK) carry the model in the URL
|
||||||
|
// path (`/v1beta/models/{model}:{op}`) and the gateway key in `?key=` or the
|
||||||
|
// `x-goog-api-key` header. We authenticate against our downstream keys, route
|
||||||
|
// the Gemini model through the same ProxyConductor as the OpenAI endpoints, and
|
||||||
|
// forward the body verbatim to a Gemini-shaped upstream (no body rewrite).
|
||||||
|
|
||||||
|
/// Gemini operations carried in the `{model}:{op}` path segment.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum GeminiOperation {
|
||||||
|
GenerateContent,
|
||||||
|
StreamGenerateContent,
|
||||||
|
CountTokens,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GeminiOperation {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
GeminiOperation::GenerateContent => "generateContent",
|
||||||
|
GeminiOperation::StreamGenerateContent => "streamGenerateContent",
|
||||||
|
GeminiOperation::CountTokens => "countTokens",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the `{model}:{operation}` path segment (e.g.
|
||||||
|
/// `gemini-2.5-pro:streamGenerateContent` or `models/gemini-x:countTokens`).
|
||||||
|
/// Splits on the last `:` so model names that contain none are rejected.
|
||||||
|
fn parse_gemini_model_action(model_action: &str) -> Option<(String, GeminiOperation)> {
|
||||||
|
let (model, action) = model_action.rsplit_once(':')?;
|
||||||
|
let operation = match action {
|
||||||
|
"generateContent" => GeminiOperation::GenerateContent,
|
||||||
|
"streamGenerateContent" => GeminiOperation::StreamGenerateContent,
|
||||||
|
"countTokens" => GeminiOperation::CountTokens,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
if model.trim().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((model.to_string(), operation))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticate a Gemini-native request: the key arrives in `?key=` or the
|
||||||
|
/// `x-goog-api-key` header (Gemini convention), not `Authorization: Bearer`.
|
||||||
|
/// Reuses the same downstream-key authorization + limits as `check_auth`.
|
||||||
|
async fn check_gemini_auth(
|
||||||
|
state: &AppState,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
query: &std::collections::HashMap<String, String>,
|
||||||
|
) -> Result<Option<i64>, AppError> {
|
||||||
|
let key = query
|
||||||
|
.get("key")
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.or_else(|| headers.get("x-goog-api-key").and_then(|v| v.to_str().ok()))
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::Unauthorized("Missing API key (use ?key= or x-goog-api-key)".into())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let proxy_token = state.runtime_proxy_token();
|
||||||
|
let result = authorize_downstream_token(key, &state.db, Some(&proxy_token))
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Unauthorized)?;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
DownstreamAuthResult::Managed { key } => {
|
||||||
|
check_downstream_key_limits(&key)?;
|
||||||
|
Ok(Some(key.id))
|
||||||
|
}
|
||||||
|
DownstreamAuthResult::Global { .. } => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild the upstream query string from client params, dropping the inbound
|
||||||
|
/// gateway `key` (the upstream key is injected by the executor) and `alt` (the
|
||||||
|
/// streaming path enforces its own `alt=sse`). Returns `None` when nothing
|
||||||
|
/// remains. Values are forwarded as-is: real Gemini clients only put `key`/`alt`
|
||||||
|
/// in the query and carry everything else (generationConfig, etc.) in the body.
|
||||||
|
fn gemini_forward_query(
|
||||||
|
query: &std::collections::HashMap<String, String>,
|
||||||
|
drop_alt: bool,
|
||||||
|
) -> Option<String> {
|
||||||
|
let mut parts: Vec<(&String, &String)> = query
|
||||||
|
.iter()
|
||||||
|
.filter(|(k, _)| k.as_str() != "key" && !(drop_alt && k.as_str() == "alt"))
|
||||||
|
.collect();
|
||||||
|
if parts.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Stable order so the URL is deterministic (and testable).
|
||||||
|
parts.sort_by(|a, b| a.0.cmp(b.0));
|
||||||
|
let encoded = parts
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| format!("{k}={v}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("&");
|
||||||
|
Some(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /v1beta/models — list models in Gemini's native shape.
|
||||||
|
/// Like `/v1/models`, this is a directory listing from configured routes; no
|
||||||
|
/// upstream call, no billing.
|
||||||
|
async fn gemini_list_models(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(query): Query<std::collections::HashMap<String, String>>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
info!("GET /v1beta/models");
|
||||||
|
|
||||||
|
let _ = check_gemini_auth(&state, &headers, &query).await?;
|
||||||
|
|
||||||
|
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||||
|
"SELECT DISTINCT
|
||||||
|
tr.model_pattern,
|
||||||
|
COALESCE(NULLIF(TRIM(tr.model_mapping), ''), tr.model_pattern)
|
||||||
|
FROM token_routes tr
|
||||||
|
JOIN route_channels rc ON rc.route_id = tr.id
|
||||||
|
JOIN accounts a ON a.id = rc.account_id
|
||||||
|
JOIN sites s ON s.id = a.site_id
|
||||||
|
LEFT JOIN account_tokens at ON at.id = rc.token_id AND at.enabled = 1
|
||||||
|
WHERE tr.enabled = 1
|
||||||
|
AND rc.enabled = 1
|
||||||
|
AND a.status = 'active'
|
||||||
|
AND s.status = 'active'
|
||||||
|
AND TRIM(COALESCE(s.url, '')) <> ''
|
||||||
|
AND (rc.cooldown_until IS NULL OR rc.cooldown_until <= datetime('now'))
|
||||||
|
AND (rc.token_id IS NULL OR at.id IS NOT NULL)
|
||||||
|
ORDER BY tr.id",
|
||||||
|
)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let models: Vec<Value> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(_pattern, model)| {
|
||||||
|
let name = if model.starts_with("models/") {
|
||||||
|
model.clone()
|
||||||
|
} else {
|
||||||
|
format!("models/{model}")
|
||||||
|
};
|
||||||
|
json!({
|
||||||
|
"name": name,
|
||||||
|
"supportedGenerationMethods": [
|
||||||
|
"generateContent",
|
||||||
|
"streamGenerateContent",
|
||||||
|
"countTokens",
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(json!({ "models": models })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /v1beta/models/{model}:{op} — dispatch to the matching Gemini op.
|
||||||
|
async fn gemini_model_operation(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
axum::extract::Path(model_action): axum::extract::Path<String>,
|
||||||
|
Query(query): Query<std::collections::HashMap<String, String>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<Value>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let Some((model, operation)) = parse_gemini_model_action(&model_action) else {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Unsupported Gemini model action '{model_action}' \
|
||||||
|
(expected {{model}}:generateContent|streamGenerateContent|countTokens)"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
info!("POST /v1beta/models/{model}:{}", operation.as_str());
|
||||||
|
|
||||||
|
let key_id = check_gemini_auth(&state, &headers, &query).await?;
|
||||||
|
|
||||||
|
match operation {
|
||||||
|
GeminiOperation::GenerateContent => {
|
||||||
|
let extra_query = gemini_forward_query(&query, false);
|
||||||
|
let result =
|
||||||
|
execute_gemini_with_conductor(state, key_id, model, body, extra_query).await?;
|
||||||
|
Ok(Json(result).into_response())
|
||||||
|
}
|
||||||
|
GeminiOperation::StreamGenerateContent => {
|
||||||
|
let extra_query = gemini_forward_query(&query, true);
|
||||||
|
execute_gemini_stream_with_conductor(state, key_id, model, body, extra_query).await
|
||||||
|
}
|
||||||
|
GeminiOperation::CountTokens => {
|
||||||
|
// countTokens is a metadata call (no generation) — route for a
|
||||||
|
// channel but skip billing, mirroring the lighter helper endpoints.
|
||||||
|
let extra_query = gemini_forward_query(&query, false);
|
||||||
|
let route = resolve_route(&state, &model).await?;
|
||||||
|
let result = executors::execute_gemini_operation(
|
||||||
|
state,
|
||||||
|
route,
|
||||||
|
"countTokens",
|
||||||
|
body,
|
||||||
|
extra_query.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(result).into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// POST /v1/chat/completions
|
/// POST /v1/chat/completions
|
||||||
async fn chat_completions(
|
async fn chat_completions(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@@ -1334,6 +1547,38 @@ fn responses_stream_usage_from_line(line: &str) -> Option<(i64, i64)> {
|
|||||||
(prompt != 0 || completion != 0).then_some((prompt, completion))
|
(prompt != 0 || completion != 0).then_some((prompt, completion))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract `(prompt, completion)` tokens from a Gemini response body's
|
||||||
|
/// `usageMetadata` (`promptTokenCount` / `candidatesTokenCount`). Returns
|
||||||
|
/// `(0, 0)` when the field is absent — mirrors `extract_usage` for OpenAI.
|
||||||
|
fn gemini_extract_usage(response: &Value) -> (i64, i64) {
|
||||||
|
let usage = response.get("usageMetadata");
|
||||||
|
let prompt = usage
|
||||||
|
.and_then(|u| u.get("promptTokenCount"))
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let completion = usage
|
||||||
|
.and_then(|u| u.get("candidatesTokenCount"))
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
(prompt, completion)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-line usage parser for Gemini `streamGenerateContent?alt=sse` output.
|
||||||
|
/// Gemini emits `data:`-framed JSON chunks; the final chunk carries the
|
||||||
|
/// cumulative `usageMetadata`. Feeds the shared `StreamUsageAccumulator`.
|
||||||
|
fn gemini_stream_usage_from_line(line: &str) -> Option<(i64, i64)> {
|
||||||
|
let payload = line
|
||||||
|
.strip_prefix("data: ")
|
||||||
|
.or_else(|| line.strip_prefix("data:"))?
|
||||||
|
.trim();
|
||||||
|
if payload.is_empty() || payload == "[DONE]" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let value: Value = serde_json::from_str(payload).ok()?;
|
||||||
|
let (prompt, completion) = gemini_extract_usage(&value);
|
||||||
|
(prompt != 0 || completion != 0).then_some((prompt, completion))
|
||||||
|
}
|
||||||
|
|
||||||
fn build_conductor(state: &AppState) -> ProxyConductor {
|
fn build_conductor(state: &AppState) -> ProxyConductor {
|
||||||
let db = state.db.clone();
|
let db = state.db.clone();
|
||||||
let select_db = db.clone();
|
let select_db = db.clone();
|
||||||
@@ -1881,6 +2126,185 @@ async fn execute_responses_stream_with_conductor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Non-streaming Gemini op (`generateContent`) through the ProxyConductor.
|
||||||
|
/// Mirrors `execute_responses_with_conductor` but builds a Gemini upstream URL
|
||||||
|
/// (model in path, key in query) and forwards the body verbatim — Gemini bodies
|
||||||
|
/// carry no `model` field, so there is no body rewrite.
|
||||||
|
async fn execute_gemini_with_conductor(
|
||||||
|
state: AppState,
|
||||||
|
key_id: Option<i64>,
|
||||||
|
model: String,
|
||||||
|
body: Value,
|
||||||
|
extra_query: Option<String>,
|
||||||
|
) -> AppResult<Value> {
|
||||||
|
let started_at = Instant::now();
|
||||||
|
let request_log_body = body.clone();
|
||||||
|
let conductor = build_conductor(&state);
|
||||||
|
let billing_state = state.clone();
|
||||||
|
let billing_model = model.clone();
|
||||||
|
let result = conductor
|
||||||
|
.execute(ExecuteInput {
|
||||||
|
requested_model: model,
|
||||||
|
downstream_policy: None,
|
||||||
|
attempt_fn: Box::new(move |ctx| {
|
||||||
|
let state = state.clone();
|
||||||
|
let body = body.clone();
|
||||||
|
let extra_query = extra_query.clone();
|
||||||
|
async move {
|
||||||
|
match executors::execute_gemini_operation(
|
||||||
|
state,
|
||||||
|
selected_channel_to_route(ctx.selected),
|
||||||
|
"generateContent",
|
||||||
|
body,
|
||||||
|
extra_query.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(response) => AttemptResult::Success {
|
||||||
|
response,
|
||||||
|
latency_ms: None,
|
||||||
|
cost: None,
|
||||||
|
},
|
||||||
|
Err(err) => AttemptResult::Failure {
|
||||||
|
action: AttemptFailureAction::Failover,
|
||||||
|
status: upstream_status_from_error(&err),
|
||||||
|
raw_error_text: Some(err.to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.boxed()
|
||||||
|
}),
|
||||||
|
on_terminal_failure: None,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let (selected, response, attempts) = match result {
|
||||||
|
ExecuteResult::Success {
|
||||||
|
selected,
|
||||||
|
response,
|
||||||
|
attempts,
|
||||||
|
} => (selected, response, attempts),
|
||||||
|
ExecuteResult::Failure {
|
||||||
|
reason,
|
||||||
|
selected,
|
||||||
|
status,
|
||||||
|
raw_error_text,
|
||||||
|
attempts,
|
||||||
|
} => {
|
||||||
|
record_proxy_log_failure(
|
||||||
|
&billing_state,
|
||||||
|
key_id,
|
||||||
|
selected.as_ref(),
|
||||||
|
&billing_model,
|
||||||
|
status,
|
||||||
|
Some(elapsed_ms_since(started_at)),
|
||||||
|
raw_error_text.as_deref(),
|
||||||
|
attempts,
|
||||||
|
false,
|
||||||
|
proxy_log_content_from_request(&request_log_body),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Err(AppError::UpstreamError(format!(
|
||||||
|
"Proxy conductor failed after {attempts} attempts ({reason:?}, status: {:?}): {}",
|
||||||
|
status,
|
||||||
|
raw_error_text.unwrap_or_else(|| "no upstream error text".to_string())
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (prompt, completion) = gemini_extract_usage(&response);
|
||||||
|
let cost = estimate_request_cost(&selected, prompt, completion);
|
||||||
|
record_proxy_log_success(
|
||||||
|
&billing_state,
|
||||||
|
key_id,
|
||||||
|
&selected,
|
||||||
|
&billing_model,
|
||||||
|
Some(elapsed_ms_since(started_at)),
|
||||||
|
prompt,
|
||||||
|
completion,
|
||||||
|
cost,
|
||||||
|
attempts,
|
||||||
|
false,
|
||||||
|
proxy_log_content_from_request_response(&request_log_body, Some(&response)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
record_billing(
|
||||||
|
&billing_state,
|
||||||
|
key_id,
|
||||||
|
&billing_model,
|
||||||
|
prompt,
|
||||||
|
completion,
|
||||||
|
cost,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streaming Gemini op (`streamGenerateContent`) through the ProxyConductor.
|
||||||
|
/// Mirrors `execute_responses_stream_with_conductor`, opening the upstream with
|
||||||
|
/// failover and SSE-passing it through with the Gemini usage parser so streamed
|
||||||
|
/// `usageMetadata` is billed.
|
||||||
|
async fn execute_gemini_stream_with_conductor(
|
||||||
|
state: AppState,
|
||||||
|
key_id: Option<i64>,
|
||||||
|
model: String,
|
||||||
|
body: Value,
|
||||||
|
extra_query: Option<String>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let started_at = Instant::now();
|
||||||
|
let request_log_body = body.clone();
|
||||||
|
let body_for_attempt = body.clone();
|
||||||
|
let opened = open_stream_with_failover(&state, &model, move |state, selected| {
|
||||||
|
let body = body_for_attempt.clone();
|
||||||
|
let extra_query = extra_query.clone();
|
||||||
|
async move {
|
||||||
|
executors::execute_gemini_stream_response(
|
||||||
|
state,
|
||||||
|
selected_channel_to_route(selected),
|
||||||
|
"streamGenerateContent",
|
||||||
|
body,
|
||||||
|
extra_query.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match opened {
|
||||||
|
Ok(opened) => sse_passthrough_response(
|
||||||
|
state,
|
||||||
|
opened,
|
||||||
|
key_id,
|
||||||
|
model,
|
||||||
|
started_at,
|
||||||
|
proxy_log_content_from_request(&request_log_body),
|
||||||
|
gemini_stream_usage_from_line,
|
||||||
|
),
|
||||||
|
Err(failure) => {
|
||||||
|
record_proxy_log_failure(
|
||||||
|
&state,
|
||||||
|
key_id,
|
||||||
|
failure.selected.as_ref(),
|
||||||
|
&model,
|
||||||
|
failure.status,
|
||||||
|
Some(elapsed_ms_since(started_at)),
|
||||||
|
failure.raw_error_text.as_deref(),
|
||||||
|
failure.attempts,
|
||||||
|
true,
|
||||||
|
proxy_log_content_from_request(&request_log_body),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Err(AppError::UpstreamError(format!(
|
||||||
|
"Proxy conductor failed after {} attempts (stream open, status: {:?}): {}",
|
||||||
|
failure.attempts,
|
||||||
|
failure.status,
|
||||||
|
failure
|
||||||
|
.raw_error_text
|
||||||
|
.unwrap_or_else(|| "no upstream error text".to_string())
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -2121,8 +2545,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
pixelle_video: Default::default(),
|
|
||||||
payment: Default::default(),
|
payment: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
crate::services::builtin_keys::BuiltinKeys {
|
crate::services::builtin_keys::BuiltinKeys {
|
||||||
@@ -2157,4 +2581,87 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(fail_count > 0);
|
assert!(fail_count > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Gemini native inbound unit tests ───────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_gemini_model_action_known_ops() {
|
||||||
|
let (m, op) = parse_gemini_model_action("gemini-2.5-pro:generateContent").unwrap();
|
||||||
|
assert_eq!(m, "gemini-2.5-pro");
|
||||||
|
assert_eq!(op, GeminiOperation::GenerateContent);
|
||||||
|
|
||||||
|
let (m, op) = parse_gemini_model_action("gemini-2.5-pro:streamGenerateContent").unwrap();
|
||||||
|
assert_eq!(m, "gemini-2.5-pro");
|
||||||
|
assert_eq!(op, GeminiOperation::StreamGenerateContent);
|
||||||
|
|
||||||
|
// model names may carry the `models/` prefix
|
||||||
|
let (m, op) = parse_gemini_model_action("models/gemini-1.5-pro:countTokens").unwrap();
|
||||||
|
assert_eq!(m, "models/gemini-1.5-pro");
|
||||||
|
assert_eq!(op, GeminiOperation::CountTokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_gemini_model_action_rejects_bad_input() {
|
||||||
|
// no operation suffix
|
||||||
|
assert!(parse_gemini_model_action("gemini-2.5-pro").is_none());
|
||||||
|
// unknown operation
|
||||||
|
assert!(parse_gemini_model_action("gemini-2.5-pro:embedContent").is_none());
|
||||||
|
// empty model
|
||||||
|
assert!(parse_gemini_model_action(":generateContent").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gemini_extract_usage_reads_usage_metadata() {
|
||||||
|
let body = json!({
|
||||||
|
"candidates": [],
|
||||||
|
"usageMetadata": {
|
||||||
|
"promptTokenCount": 11,
|
||||||
|
"candidatesTokenCount": 22,
|
||||||
|
"totalTokenCount": 33
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert_eq!(gemini_extract_usage(&body), (11, 22));
|
||||||
|
// absent usageMetadata → zeros
|
||||||
|
assert_eq!(gemini_extract_usage(&json!({})), (0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gemini_stream_usage_from_line_parses_data_frame() {
|
||||||
|
let line = r#"data: {"candidates":[{"content":{"parts":[]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":4,"candidatesTokenCount":6,"totalTokenCount":10}}"#;
|
||||||
|
assert_eq!(gemini_stream_usage_from_line(line), Some((4, 6)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gemini_stream_usage_from_line_ignores_non_usage_and_done() {
|
||||||
|
// a chunk with no usageMetadata yields None (don't overwrite accumulator)
|
||||||
|
let no_usage = r#"data: {"candidates":[{"content":{"parts":[{"text":"hi"}]}}]}"#;
|
||||||
|
assert_eq!(gemini_stream_usage_from_line(no_usage), None);
|
||||||
|
// sentinels / blanks / non-data lines
|
||||||
|
assert_eq!(gemini_stream_usage_from_line("data: [DONE]"), None);
|
||||||
|
assert_eq!(gemini_stream_usage_from_line("data:"), None);
|
||||||
|
assert_eq!(gemini_stream_usage_from_line(": comment"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gemini_forward_query_drops_key_and_alt() {
|
||||||
|
let mut q = std::collections::HashMap::new();
|
||||||
|
q.insert("key".to_string(), "secret".to_string());
|
||||||
|
q.insert("alt".to_string(), "sse".to_string());
|
||||||
|
// stream path drops both key and alt → nothing left
|
||||||
|
assert_eq!(gemini_forward_query(&q, true), None);
|
||||||
|
// non-stream keeps alt but always drops key
|
||||||
|
assert_eq!(gemini_forward_query(&q, false), Some("alt=sse".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gemini_forward_query_preserves_other_params_sorted() {
|
||||||
|
let mut q = std::collections::HashMap::new();
|
||||||
|
q.insert("key".to_string(), "secret".to_string());
|
||||||
|
q.insert("pageSize".to_string(), "50".to_string());
|
||||||
|
q.insert("filter".to_string(), "x".to_string());
|
||||||
|
assert_eq!(
|
||||||
|
gemini_forward_query(&q, true),
|
||||||
|
Some("filter=x&pageSize=50".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -696,8 +696,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: ComfyUIConfig::default(),
|
comfyui: ComfyUIConfig::default(),
|
||||||
pixelle_video: Default::default(),
|
|
||||||
payment: Default::default(),
|
payment: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
//! Pending-approval registry for the Agent Workbench.
|
||||||
|
//!
|
||||||
|
//! When a session runs in a mode that requires approval for a risky tool, the
|
||||||
|
//! permission callback registers a pending approval, emits an `approval_request`
|
||||||
|
//! SSE event, and waits for either the user's decision or a timeout. The
|
||||||
|
//! `POST /api/agent/approve` endpoint resolves the pending entry.
|
||||||
|
//!
|
||||||
|
//! ## Single source of truth
|
||||||
|
//!
|
||||||
|
//! Each entry carries an [`AtomicU8`] state machine (`Pending → Approved /
|
||||||
|
//! Denied / TimedOut`) plus a [`Notify`]. The decision is made by exactly one
|
||||||
|
//! `compare_exchange` from `Pending`: whichever side wins (the approver or the
|
||||||
|
//! timeout) settles the state; everyone else observes a losing CAS and no-ops.
|
||||||
|
//!
|
||||||
|
//! This closes the race that a bare `oneshot` channel had, where the HTTP
|
||||||
|
//! response (derived from `Sender::send`'s bool) and the loop's allow/deny
|
||||||
|
//! decision (derived from which `select`/`timeout` arm fired) were two
|
||||||
|
//! independent signals that could disagree. Now both read the same atomic:
|
||||||
|
//! `resolve` reports back whether *its* CAS won, and the callback decides purely
|
||||||
|
//! from the settled state — so the frontend's `ok` and the executed decision can
|
||||||
|
//! never contradict each other.
|
||||||
|
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicU8, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::sync::Notify;
|
||||||
|
|
||||||
|
/// Settled decision states. `Pending` is the only non-terminal value.
|
||||||
|
const PENDING: u8 = 0;
|
||||||
|
const APPROVED: u8 = 1;
|
||||||
|
const DENIED: u8 = 2;
|
||||||
|
const TIMED_OUT: u8 = 3;
|
||||||
|
|
||||||
|
/// User's decision on a pending tool approval.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ApprovalOutcome {
|
||||||
|
Approve,
|
||||||
|
Deny,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Final, settled state of an approval — the single source of truth read by the
|
||||||
|
/// permission callback to decide allow vs deny.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SettledState {
|
||||||
|
Approved,
|
||||||
|
Denied,
|
||||||
|
TimedOut,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared approval entry: an atomic state machine plus a wakeup primitive.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ApprovalEntry {
|
||||||
|
state: AtomicU8,
|
||||||
|
notify: Notify,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApprovalEntry {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
state: AtomicU8::new(PENDING),
|
||||||
|
notify: Notify::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to settle this entry to a terminal state from `Pending`.
|
||||||
|
/// Returns `true` iff this caller's CAS won the race.
|
||||||
|
fn try_settle(&self, target: u8) -> bool {
|
||||||
|
self.state
|
||||||
|
.compare_exchange(PENDING, target, Ordering::SeqCst, Ordering::SeqCst)
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settled(&self) -> Option<SettledState> {
|
||||||
|
match self.state.load(Ordering::SeqCst) {
|
||||||
|
APPROVED => Some(SettledState::Approved),
|
||||||
|
DENIED => Some(SettledState::Denied),
|
||||||
|
TIMED_OUT => Some(SettledState::TimedOut),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static REGISTRY: Lazy<Mutex<HashMap<String, Arc<ApprovalEntry>>>> =
|
||||||
|
Lazy::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
/// RAII guard that removes its entry from the registry when dropped, so a
|
||||||
|
/// pending approval is cleaned up whether the callback finishes normally or its
|
||||||
|
/// future is dropped (session aborted/disconnected). No leak on any path.
|
||||||
|
pub struct PendingGuard {
|
||||||
|
request_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PendingGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
REGISTRY.lock().unwrap().remove(&self.request_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a new pending approval. Returns the shared entry (await its
|
||||||
|
/// [`wait_settled`](ApprovalEntry)/notify and read its settled state) plus a
|
||||||
|
/// [`PendingGuard`] that the caller must hold for the lifetime of the wait; when
|
||||||
|
/// the guard drops, the registry entry is removed.
|
||||||
|
pub fn register(request_id: &str) -> (Arc<ApprovalEntry>, PendingGuard) {
|
||||||
|
let entry = Arc::new(ApprovalEntry::new());
|
||||||
|
REGISTRY
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(request_id.to_string(), entry.clone());
|
||||||
|
(
|
||||||
|
entry,
|
||||||
|
PendingGuard {
|
||||||
|
request_id: request_id.to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait until the entry is settled or `timeout` elapses, then return the settled
|
||||||
|
/// state. On timeout this claims `TimedOut` (if still pending) so the result is
|
||||||
|
/// always a terminal state read from the single atomic.
|
||||||
|
pub async fn wait_for_decision(
|
||||||
|
entry: &ApprovalEntry,
|
||||||
|
timeout: std::time::Duration,
|
||||||
|
) -> SettledState {
|
||||||
|
// Create the notified future *before* checking state to avoid a lost wakeup.
|
||||||
|
let notified = entry.notify.notified();
|
||||||
|
|
||||||
|
// Fast path: already settled by a resolve that raced ahead of us.
|
||||||
|
if let Some(s) = entry.settled() {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokio::time::timeout(timeout, notified).await.is_err() {
|
||||||
|
// Timer fired first — claim TimedOut if nobody settled in the meantime.
|
||||||
|
entry.try_settle(TIMED_OUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single source of truth: whatever the atomic settled to. If a wakeup was
|
||||||
|
// lost but the state is set, we still read the correct decision; if somehow
|
||||||
|
// still pending (shouldn't happen — we just CAS'd), treat as timed out.
|
||||||
|
entry.settled().unwrap_or(SettledState::TimedOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a pending approval with the user's decision.
|
||||||
|
///
|
||||||
|
/// Returns `true` iff this call's `compare_exchange` won — i.e. it is the call
|
||||||
|
/// that actually settled the decision. Returns `false` truthfully when the id is
|
||||||
|
/// unknown (already cleaned up) or when the timeout (or another resolve) already
|
||||||
|
/// claimed the entry, so the frontend is never told its vote took effect when it
|
||||||
|
/// did not.
|
||||||
|
pub fn resolve(request_id: &str, outcome: ApprovalOutcome) -> bool {
|
||||||
|
let entry = REGISTRY.lock().unwrap().get(request_id).cloned();
|
||||||
|
let Some(entry) = entry else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let target = match outcome {
|
||||||
|
ApprovalOutcome::Approve => APPROVED,
|
||||||
|
ApprovalOutcome::Deny => DENIED,
|
||||||
|
};
|
||||||
|
if entry.try_settle(target) {
|
||||||
|
entry.notify.notify_one();
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn approve_before_wait_is_observed() {
|
||||||
|
let (entry, _guard) = register("r1");
|
||||||
|
assert!(resolve("r1", ApprovalOutcome::Approve));
|
||||||
|
let s = wait_for_decision(&entry, Duration::from_secs(5)).await;
|
||||||
|
assert_eq!(s, SettledState::Approved);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn deny_is_observed() {
|
||||||
|
let (entry, _guard) = register("r2");
|
||||||
|
assert!(resolve("r2", ApprovalOutcome::Deny));
|
||||||
|
assert_eq!(
|
||||||
|
wait_for_decision(&entry, Duration::from_secs(5)).await,
|
||||||
|
SettledState::Denied
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn timeout_settles_timed_out_and_later_resolve_loses() {
|
||||||
|
let (entry, _guard) = register("r3");
|
||||||
|
// Very short timeout → times out with no resolve.
|
||||||
|
let s = wait_for_decision(&entry, Duration::from_millis(20)).await;
|
||||||
|
assert_eq!(s, SettledState::TimedOut);
|
||||||
|
// A late approve must report false: the CAS already went to TimedOut.
|
||||||
|
assert!(!resolve("r3", ApprovalOutcome::Approve));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn first_cas_wins_second_resolve_loses() {
|
||||||
|
let (_entry, _guard) = register("r4");
|
||||||
|
assert!(resolve("r4", ApprovalOutcome::Approve));
|
||||||
|
assert!(!resolve("r4", ApprovalOutcome::Deny));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resolve_unknown_id_is_false() {
|
||||||
|
assert!(!resolve("nope", ApprovalOutcome::Approve));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn guard_drop_removes_entry() {
|
||||||
|
{
|
||||||
|
let (_entry, _guard) = register("r5");
|
||||||
|
} // guard dropped here
|
||||||
|
// Entry gone → resolve can't find it.
|
||||||
|
assert!(!resolve("r5", ApprovalOutcome::Approve));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_resolve_and_timeout_never_both_win() {
|
||||||
|
// Stress the boundary: race a resolve against a near-zero timeout many
|
||||||
|
// times and assert the frontend-ok and the settled decision always agree.
|
||||||
|
for i in 0..2000 {
|
||||||
|
let id = format!("race-{i}");
|
||||||
|
let (entry, _guard) = register(&id);
|
||||||
|
let id2 = id.clone();
|
||||||
|
let resolver = tokio::spawn(async move { resolve(&id2, ApprovalOutcome::Approve) });
|
||||||
|
let decided = wait_for_decision(&entry, Duration::from_nanos(1)).await;
|
||||||
|
let won = resolver.await.unwrap();
|
||||||
|
// The single-atomic invariant: ok==true <=> decision==Approved.
|
||||||
|
match (won, decided) {
|
||||||
|
(true, SettledState::Approved) => {}
|
||||||
|
(false, SettledState::TimedOut) => {}
|
||||||
|
other => panic!("inconsistent outcome: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
//! OS-level sandboxed Bash tool for the Agent Workbench.
|
||||||
|
//!
|
||||||
|
//! The open-agent-sdk's built-in `Bash` tool runs commands as a plain
|
||||||
|
//! subprocess with only `current_dir` set — no OS boundary. On a server that
|
||||||
|
//! means an agent's shell can `cat /etc/shadow`, `cd /`, write anywhere the
|
||||||
|
//! process uid can, and reach the network. This tool replaces it (same name +
|
||||||
|
//! schema, registered via `custom_tools` so it overrides the built-in) and
|
||||||
|
//! confines the command with [bubblewrap](https://github.com/containers/bubblewrap):
|
||||||
|
//!
|
||||||
|
//! - **Default / AcceptEdits** (review modes): the command runs inside a
|
||||||
|
//! bwrap namespace with the system mounted read-only, the session sandbox
|
||||||
|
//! directory bind-mounted read-write at its real path, `/etc`, `/root`,
|
||||||
|
//! `/home` and every other host path *absent*, and the network unshared.
|
||||||
|
//! Filesystem reads/writes outside the session dir and all network egress are
|
||||||
|
//! therefore impossible at the kernel level, not merely discouraged.
|
||||||
|
//! - **FullAccess** (explicitly dangerous): the command runs unconfined, the
|
||||||
|
//! same behaviour the bare SDK had, but every command is audit-logged with a
|
||||||
|
//! loud warning.
|
||||||
|
//!
|
||||||
|
//! **Fail-closed:** in a review mode, if `bwrap` is unavailable the command is
|
||||||
|
//! refused rather than silently run unconfined — we never fabricate a boundary.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Stdio;
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
use crate::services::agent_workbench::permission::PermissionMode;
|
||||||
|
use open_agent_sdk::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS: u64 = 120_000;
|
||||||
|
const MAX_TIMEOUT_MS: u64 = 600_000;
|
||||||
|
const MAX_OUTPUT_SIZE: usize = 100_000;
|
||||||
|
|
||||||
|
/// Read-only host paths exposed inside the sandbox so common tooling works.
|
||||||
|
/// Deliberately excludes `/etc`, `/root`, `/home`, `/var`, `/proc`-host, etc.
|
||||||
|
const RO_SYSTEM_PATHS: &[&str] = &["/usr", "/bin", "/sbin", "/lib", "/lib64", "/opt"];
|
||||||
|
|
||||||
|
/// A Bash tool that confines commands to the session sandbox via bubblewrap.
|
||||||
|
pub struct SandboxedBashTool {
|
||||||
|
/// Canonical session sandbox directory (bind-mounted rw inside bwrap).
|
||||||
|
session_dir: PathBuf,
|
||||||
|
/// Session permission mode — decides confined vs unconfined.
|
||||||
|
mode: PermissionMode,
|
||||||
|
/// Resolved `bwrap` binary path, if present on this host.
|
||||||
|
bwrap: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SandboxedBashTool {
|
||||||
|
pub fn new(session_dir: PathBuf, mode: PermissionMode) -> Self {
|
||||||
|
Self {
|
||||||
|
session_dir,
|
||||||
|
mode,
|
||||||
|
bwrap: find_bwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this mode confines bash (everything except FullAccess).
|
||||||
|
fn is_confined(&self) -> bool {
|
||||||
|
!matches!(self.mode, PermissionMode::FullAccess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locate the `bwrap` binary (PATH + well-known locations).
|
||||||
|
fn find_bwrap() -> Option<PathBuf> {
|
||||||
|
for cand in ["/usr/bin/bwrap", "/bin/bwrap", "/usr/local/bin/bwrap"] {
|
||||||
|
let p = Path::new(cand);
|
||||||
|
if p.is_file() {
|
||||||
|
return Some(p.to_path_buf());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::env::var_os("PATH").and_then(|paths| {
|
||||||
|
std::env::split_paths(&paths)
|
||||||
|
.map(|d| d.join("bwrap"))
|
||||||
|
.find(|p| p.is_file())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the bwrap argument vector that confines `command` to `session_dir`.
|
||||||
|
///
|
||||||
|
/// Pure and unit-testable. The session directory is bound read-write at its own
|
||||||
|
/// real absolute path and becomes the working directory; the rest of the
|
||||||
|
/// filesystem is either read-only (system paths) or absent. `--unshare-all`
|
||||||
|
/// (which includes the network namespace) plus `--die-with-parent` and
|
||||||
|
/// `--new-session` complete the isolation.
|
||||||
|
pub fn build_bwrap_args(session_dir: &Path, command: &str) -> Vec<String> {
|
||||||
|
let sd = session_dir.to_string_lossy().to_string();
|
||||||
|
let mut args: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for p in RO_SYSTEM_PATHS {
|
||||||
|
// `--ro-bind-try` tolerates paths that don't exist on this host.
|
||||||
|
args.push("--ro-bind-try".into());
|
||||||
|
args.push((*p).into());
|
||||||
|
args.push((*p).into());
|
||||||
|
}
|
||||||
|
// Minimal virtual filesystems.
|
||||||
|
args.push("--dev".into());
|
||||||
|
args.push("/dev".into());
|
||||||
|
args.push("--proc".into());
|
||||||
|
args.push("/proc".into());
|
||||||
|
args.push("--tmpfs".into());
|
||||||
|
args.push("/tmp".into());
|
||||||
|
// The session sandbox, bound rw at its real path, then made the cwd.
|
||||||
|
args.push("--bind".into());
|
||||||
|
args.push(sd.clone());
|
||||||
|
args.push(sd.clone());
|
||||||
|
args.push("--chdir".into());
|
||||||
|
args.push(sd);
|
||||||
|
// Full namespace isolation incl. network; tear down with the parent.
|
||||||
|
args.push("--unshare-all".into());
|
||||||
|
args.push("--die-with-parent".into());
|
||||||
|
args.push("--new-session".into());
|
||||||
|
// Run the command under a shell inside the sandbox.
|
||||||
|
args.push("/bin/bash".into());
|
||||||
|
args.push("-c".into());
|
||||||
|
args.push(command.into());
|
||||||
|
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for SandboxedBashTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"Bash"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Executes a bash command inside an OS-level sandbox confined to the \
|
||||||
|
session working directory (read-only system, no network) unless the \
|
||||||
|
session is in full-access mode. Use for shell operations."
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input_schema(&self) -> ToolInputSchema {
|
||||||
|
ToolInputSchema {
|
||||||
|
schema_type: "object".to_string(),
|
||||||
|
properties: HashMap::from([
|
||||||
|
(
|
||||||
|
"command".to_string(),
|
||||||
|
json!({ "type": "string", "description": "The command to execute" }),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"timeout".to_string(),
|
||||||
|
json!({ "type": "number", "description": "Optional timeout in milliseconds (max 600000)" }),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"description".to_string(),
|
||||||
|
json!({ "type": "string", "description": "Clear description of what this command does" }),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
required: vec!["command".to_string()],
|
||||||
|
additional_properties: Some(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call(&self, input: Value, context: &ToolUseContext) -> Result<ToolResult, ToolError> {
|
||||||
|
let command = input
|
||||||
|
.get("command")
|
||||||
|
.and_then(|c| c.as_str())
|
||||||
|
.ok_or_else(|| ToolError::InvalidInput("Missing 'command' field".to_string()))?;
|
||||||
|
|
||||||
|
let timeout_ms = input
|
||||||
|
.get("timeout")
|
||||||
|
.and_then(|t| t.as_u64())
|
||||||
|
.unwrap_or(DEFAULT_TIMEOUT_MS)
|
||||||
|
.min(MAX_TIMEOUT_MS);
|
||||||
|
|
||||||
|
// Build the runner per mode.
|
||||||
|
let (program, args): (String, Vec<String>) = if self.is_confined() {
|
||||||
|
let Some(bwrap) = &self.bwrap else {
|
||||||
|
// Fail closed: never downgrade a review-mode session to an
|
||||||
|
// unconfined shell just because the sandbox tool is missing.
|
||||||
|
return Ok(ToolResult::error(
|
||||||
|
"Bash is disabled: the OS sandbox (bwrap) is not available on \
|
||||||
|
this host, and this session runs in a review mode that requires \
|
||||||
|
confinement. Re-run in full-access mode (explicitly unsafe) or \
|
||||||
|
install bubblewrap."
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
(
|
||||||
|
bwrap.to_string_lossy().to_string(),
|
||||||
|
build_bwrap_args(&self.session_dir, command),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// FullAccess: unconfined, but audit every command loudly.
|
||||||
|
tracing::warn!(
|
||||||
|
target: "boosapi::agent::bash",
|
||||||
|
mode = "full_access",
|
||||||
|
command = command,
|
||||||
|
"UNCONFINED agent bash command (full-access mode)"
|
||||||
|
);
|
||||||
|
(
|
||||||
|
"/bin/bash".to_string(),
|
||||||
|
vec!["-c".to_string(), command.to_string()],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Unconfined mode honours the SDK's working dir; confined mode's cwd is
|
||||||
|
// set inside bwrap via --chdir, so the outer cwd is irrelevant.
|
||||||
|
let outer_cwd = if self.is_confined() {
|
||||||
|
self.session_dir.clone()
|
||||||
|
} else {
|
||||||
|
PathBuf::from(&context.working_dir)
|
||||||
|
};
|
||||||
|
|
||||||
|
let fut = run_capture(&program, &args, &outer_cwd);
|
||||||
|
match tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), fut).await {
|
||||||
|
Ok(Ok((stdout, stderr, code))) => Ok(format_result(stdout, stderr, code)),
|
||||||
|
Ok(Err(e)) => Ok(ToolResult::error(format!("Command failed: {e}"))),
|
||||||
|
Err(_) => Ok(ToolResult::error(format!(
|
||||||
|
"Command timed out after {timeout_ms}ms"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_capture(
|
||||||
|
program: &str,
|
||||||
|
args: &[String],
|
||||||
|
cwd: &Path,
|
||||||
|
) -> Result<(String, String, i32), std::io::Error> {
|
||||||
|
let output = Command::new(program)
|
||||||
|
.args(args)
|
||||||
|
.current_dir(cwd)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
Ok((
|
||||||
|
String::from_utf8_lossy(&output.stdout).to_string(),
|
||||||
|
String::from_utf8_lossy(&output.stderr).to_string(),
|
||||||
|
output.status.code().unwrap_or(-1),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_result(stdout: String, stderr: String, exit_code: i32) -> ToolResult {
|
||||||
|
let mut result = String::new();
|
||||||
|
if !stdout.is_empty() {
|
||||||
|
result.push_str(&stdout);
|
||||||
|
}
|
||||||
|
if !stderr.is_empty() {
|
||||||
|
if !result.is_empty() {
|
||||||
|
result.push('\n');
|
||||||
|
}
|
||||||
|
result.push_str("STDERR:\n");
|
||||||
|
result.push_str(&stderr);
|
||||||
|
}
|
||||||
|
if result.len() > MAX_OUTPUT_SIZE {
|
||||||
|
result.truncate(MAX_OUTPUT_SIZE);
|
||||||
|
result.push_str("\n... (output truncated)");
|
||||||
|
}
|
||||||
|
if exit_code != 0 {
|
||||||
|
result.push_str(&format!("\n\nExit code: {exit_code}"));
|
||||||
|
}
|
||||||
|
if result.is_empty() {
|
||||||
|
result = "(no output)".to_string();
|
||||||
|
}
|
||||||
|
if exit_code != 0 {
|
||||||
|
ToolResult::error(result)
|
||||||
|
} else {
|
||||||
|
ToolResult::text(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn args_bind_session_dir_and_isolate() {
|
||||||
|
let sd = Path::new("/srv/data/agent_sessions/abc");
|
||||||
|
let args = build_bwrap_args(sd, "echo hi");
|
||||||
|
let joined = args.join(" ");
|
||||||
|
// Session dir is bound rw and made the cwd.
|
||||||
|
assert!(joined.contains("--bind /srv/data/agent_sessions/abc /srv/data/agent_sessions/abc"));
|
||||||
|
assert!(joined.contains("--chdir /srv/data/agent_sessions/abc"));
|
||||||
|
// Network + everything else unshared, parent-bound.
|
||||||
|
assert!(args.iter().any(|a| a == "--unshare-all"));
|
||||||
|
assert!(args.iter().any(|a| a == "--die-with-parent"));
|
||||||
|
// /etc is never bound.
|
||||||
|
assert!(!joined.contains("/etc"));
|
||||||
|
// System paths are read-only.
|
||||||
|
assert!(joined.contains("--ro-bind-try /usr /usr"));
|
||||||
|
// The command is the last argument, run under bash -c.
|
||||||
|
assert_eq!(args.last().unwrap(), "echo hi");
|
||||||
|
let n = args.len();
|
||||||
|
assert_eq!(args[n - 3], "/bin/bash");
|
||||||
|
assert_eq!(args[n - 2], "-c");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn confinement_follows_mode() {
|
||||||
|
let sd = PathBuf::from("/tmp/x");
|
||||||
|
assert!(SandboxedBashTool::new(sd.clone(), PermissionMode::Default).is_confined());
|
||||||
|
assert!(SandboxedBashTool::new(sd.clone(), PermissionMode::AcceptEdits).is_confined());
|
||||||
|
assert!(!SandboxedBashTool::new(sd, PermissionMode::FullAccess).is_confined());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn name_overrides_builtin_bash() {
|
||||||
|
let t = SandboxedBashTool::new(PathBuf::from("/tmp/x"), PermissionMode::Default);
|
||||||
|
assert_eq!(t.name(), "Bash");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
//! Agent Workbench: a general-purpose, server-side AI agent exposed over SSE.
|
||||||
|
//!
|
||||||
|
//! The agent loop is provided by the `open-agent-sdk` crate. This module owns
|
||||||
|
//! the BoosAPI-specific concerns layered on top of it:
|
||||||
|
//!
|
||||||
|
//! - [`permission`]: tool risk classification + the per-mode decision matrix.
|
||||||
|
//! - [`sandbox`]: per-session isolated working directory + path validation.
|
||||||
|
//! - [`bash_sandbox`]: OS-level (bubblewrap) confinement for the Bash tool.
|
||||||
|
//! - [`approval`]: process-global registry bridging the SSE approval round-trip.
|
||||||
|
//! - [`session_store`]: persistence of per-session token/cost accounting.
|
||||||
|
//!
|
||||||
|
//! The HTTP surface (SSE chat, approval callback, status) lives in
|
||||||
|
//! `crate::routes::agent`.
|
||||||
|
|
||||||
|
pub mod approval;
|
||||||
|
pub mod bash_sandbox;
|
||||||
|
pub mod permission;
|
||||||
|
pub mod sandbox;
|
||||||
|
pub mod session_store;
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
//! Tool risk classification and permission decision matrix for the Agent Workbench.
|
||||||
|
//!
|
||||||
|
//! Ported and adapted from the AQBot agent crate. Deliberately SDK-free so it
|
||||||
|
//! can be unit-tested in isolation; the route layer maps [`PermissionAction`]
|
||||||
|
//! onto the SDK's `PermissionDecision`.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Risk level of a tool, inferred from its name.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RiskLevel {
|
||||||
|
/// Reads only; never mutates the filesystem or runs commands.
|
||||||
|
ReadOnly,
|
||||||
|
/// Mutates files (write/edit/create/delete/...).
|
||||||
|
Write,
|
||||||
|
/// Executes arbitrary commands (bash/shell/...).
|
||||||
|
Execute,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permission mode for an agent session.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum PermissionMode {
|
||||||
|
/// Standard review: read auto-allowed, write/execute require approval.
|
||||||
|
Default,
|
||||||
|
/// Auto-accept edits: read + write auto-allowed, execute requires approval.
|
||||||
|
AcceptEdits,
|
||||||
|
/// Everything auto-allowed (no approvals).
|
||||||
|
FullAccess,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PermissionMode {
|
||||||
|
/// Parse a mode from a wire string. Unknown values fall back to the safest
|
||||||
|
/// mode ([`PermissionMode::Default`]).
|
||||||
|
pub fn from_wire(s: &str) -> Self {
|
||||||
|
match s {
|
||||||
|
"accept_edits" | "acceptEdits" => Self::AcceptEdits,
|
||||||
|
"full_access" | "fullAccess" => Self::FullAccess,
|
||||||
|
_ => Self::Default,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the permission system decides for a given tool invocation.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PermissionAction {
|
||||||
|
/// Allow without prompting the user.
|
||||||
|
AutoAllow,
|
||||||
|
/// Pause and require explicit user approval before running.
|
||||||
|
RequireApproval,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify a tool's risk level from its name (case-insensitive).
|
||||||
|
///
|
||||||
|
/// Conservative by construction: anything that looks like it executes a command
|
||||||
|
/// is [`RiskLevel::Execute`], anything that looks like it mutates state is
|
||||||
|
/// [`RiskLevel::Write`], and everything else is [`RiskLevel::ReadOnly`].
|
||||||
|
pub fn classify_tool_risk(tool_name: &str) -> RiskLevel {
|
||||||
|
let n = tool_name.to_lowercase();
|
||||||
|
|
||||||
|
if matches!(n.as_str(), "bash" | "shell" | "run_command" | "execute")
|
||||||
|
|| n.contains("exec")
|
||||||
|
|| n.contains("bash")
|
||||||
|
|| n.contains("shell")
|
||||||
|
|| n.contains("command")
|
||||||
|
|| n.contains("run")
|
||||||
|
|| n.contains("terminal")
|
||||||
|
|| n.contains("spawn")
|
||||||
|
|| n.contains("process")
|
||||||
|
{
|
||||||
|
return RiskLevel::Execute;
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(
|
||||||
|
n.as_str(),
|
||||||
|
"write" | "edit" | "create" | "delete" | "rename" | "patch" | "move" | "mkdir" | "remove"
|
||||||
|
) || n.contains("write")
|
||||||
|
|| n.contains("edit")
|
||||||
|
|| n.contains("create")
|
||||||
|
|| n.contains("delete")
|
||||||
|
|| n.contains("patch")
|
||||||
|
|| n.contains("apply")
|
||||||
|
{
|
||||||
|
return RiskLevel::Write;
|
||||||
|
}
|
||||||
|
|
||||||
|
RiskLevel::ReadOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide what to do for a tool invocation given the session mode and the tool's
|
||||||
|
/// risk level.
|
||||||
|
pub fn decide_permission(mode: PermissionMode, risk: RiskLevel) -> PermissionAction {
|
||||||
|
match (mode, risk) {
|
||||||
|
(PermissionMode::Default, RiskLevel::ReadOnly) => PermissionAction::AutoAllow,
|
||||||
|
(PermissionMode::Default, _) => PermissionAction::RequireApproval,
|
||||||
|
|
||||||
|
(PermissionMode::AcceptEdits, RiskLevel::ReadOnly) => PermissionAction::AutoAllow,
|
||||||
|
(PermissionMode::AcceptEdits, RiskLevel::Write) => PermissionAction::AutoAllow,
|
||||||
|
(PermissionMode::AcceptEdits, RiskLevel::Execute) => PermissionAction::RequireApproval,
|
||||||
|
|
||||||
|
(PermissionMode::FullAccess, _) => PermissionAction::AutoAllow,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_execute_tools() {
|
||||||
|
for t in ["Bash", "bash", "shell", "run_command", "execute", "RunInTerminal"] {
|
||||||
|
assert_eq!(classify_tool_risk(t), RiskLevel::Execute, "tool={t}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_write_tools() {
|
||||||
|
for t in ["Write", "Edit", "create_file", "delete_file", "patch", "ApplyPatch"] {
|
||||||
|
assert_eq!(classify_tool_risk(t), RiskLevel::Write, "tool={t}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_read_tools() {
|
||||||
|
for t in ["Read", "Glob", "Grep", "WebFetch", "list_dir"] {
|
||||||
|
assert_eq!(classify_tool_risk(t), RiskLevel::ReadOnly, "tool={t}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_mode_requires_approval_for_write_and_execute() {
|
||||||
|
assert_eq!(
|
||||||
|
decide_permission(PermissionMode::Default, RiskLevel::ReadOnly),
|
||||||
|
PermissionAction::AutoAllow
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decide_permission(PermissionMode::Default, RiskLevel::Write),
|
||||||
|
PermissionAction::RequireApproval
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decide_permission(PermissionMode::Default, RiskLevel::Execute),
|
||||||
|
PermissionAction::RequireApproval
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accept_edits_allows_write_but_not_execute() {
|
||||||
|
assert_eq!(
|
||||||
|
decide_permission(PermissionMode::AcceptEdits, RiskLevel::Write),
|
||||||
|
PermissionAction::AutoAllow
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decide_permission(PermissionMode::AcceptEdits, RiskLevel::Execute),
|
||||||
|
PermissionAction::RequireApproval
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_access_allows_everything() {
|
||||||
|
for r in [RiskLevel::ReadOnly, RiskLevel::Write, RiskLevel::Execute] {
|
||||||
|
assert_eq!(
|
||||||
|
decide_permission(PermissionMode::FullAccess, r),
|
||||||
|
PermissionAction::AutoAllow
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mode_parsing_falls_back_to_default() {
|
||||||
|
assert_eq!(PermissionMode::from_wire("accept_edits"), PermissionMode::AcceptEdits);
|
||||||
|
assert_eq!(PermissionMode::from_wire("full_access"), PermissionMode::FullAccess);
|
||||||
|
assert_eq!(PermissionMode::from_wire("garbage"), PermissionMode::Default);
|
||||||
|
assert_eq!(PermissionMode::from_wire(""), PermissionMode::Default);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
//! Per-session filesystem sandbox for the Agent Workbench.
|
||||||
|
//!
|
||||||
|
//! Every agent session is confined to an isolated working directory
|
||||||
|
//! (`data/agent_sessions/{session_id}/`). Tool path arguments are validated to
|
||||||
|
//! resolve *within* that directory before the tool is allowed to run, so one
|
||||||
|
//! tenant's agent can never read or write another tenant's files (or escape to
|
||||||
|
//! the host filesystem via `..`/symlinks/absolute paths).
|
||||||
|
//!
|
||||||
|
//! Adapted from the AQBot agent crate's `security.rs`. SDK-free: the route layer
|
||||||
|
//! turns an [`Err`] into a permission denial.
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Root directory that holds all per-session sandboxes, relative to the server's
|
||||||
|
/// working directory.
|
||||||
|
pub const SESSIONS_ROOT: &str = "data/agent_sessions";
|
||||||
|
|
||||||
|
/// Compute the sandbox directory for a session id (not yet created on disk).
|
||||||
|
pub fn session_dir(session_id: &str) -> PathBuf {
|
||||||
|
Path::new(SESSIONS_ROOT).join(sanitize_session_id(session_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the sandbox directory for a session if missing, returning its
|
||||||
|
/// canonical absolute path (suitable for use as the agent cwd).
|
||||||
|
pub fn ensure_session_dir(session_id: &str) -> std::io::Result<PathBuf> {
|
||||||
|
let dir = session_dir(session_id);
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
dir.canonicalize()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip anything that could let a session id traverse outside SESSIONS_ROOT.
|
||||||
|
/// Keeps only `[A-Za-z0-9_-]`; everything else becomes `_`.
|
||||||
|
fn sanitize_session_id(session_id: &str) -> String {
|
||||||
|
let cleaned: String = session_id
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||||
|
.collect();
|
||||||
|
if cleaned.is_empty() {
|
||||||
|
"default".to_string()
|
||||||
|
} else {
|
||||||
|
cleaned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate that `path` (absolute or relative to `cwd`) resolves to a location
|
||||||
|
/// within `cwd`. Returns the resolved path on success, or an error message
|
||||||
|
/// describing the escape attempt.
|
||||||
|
///
|
||||||
|
/// For existing paths the target is canonicalized (resolving symlinks). For
|
||||||
|
/// not-yet-existing paths the nearest existing ancestor is canonicalized and
|
||||||
|
/// checked, so writes to new files inside the sandbox are permitted while writes
|
||||||
|
/// that would escape it are rejected.
|
||||||
|
///
|
||||||
|
/// Symlinks are handled explicitly with `lstat` (not `exists()`, which follows
|
||||||
|
/// links): any component that is a symlink — including a *dangling* one whose
|
||||||
|
/// target does not exist yet — has its target resolved and is rejected if it
|
||||||
|
/// points outside the sandbox. This stops an approved `ln -s /outside x` from
|
||||||
|
/// being written through to escape (a write to `x` would otherwise follow the
|
||||||
|
/// link). Note this closes the escape, not the check-vs-open TOCTOU race; the
|
||||||
|
/// actual open happens inside the tool and would need `O_NOFOLLOW`/`openat2`
|
||||||
|
/// there to be fully atomic.
|
||||||
|
pub fn validate_path_within_cwd(path: &str, cwd: &Path) -> Result<PathBuf, String> {
|
||||||
|
let cwd_canonical = cwd
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|e| format!("cannot resolve sandbox directory '{}': {e}", cwd.display()))?;
|
||||||
|
|
||||||
|
let target = Path::new(path);
|
||||||
|
let resolved = if target.is_absolute() {
|
||||||
|
target.to_path_buf()
|
||||||
|
} else {
|
||||||
|
cwd_canonical.join(target)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Final component: if it is a symlink (dangling or not), trust only its
|
||||||
|
// target. lstat does not follow the link, so a dangling symlink is detected
|
||||||
|
// here instead of slipping into the not-yet-existing branch below.
|
||||||
|
if let Ok(meta) = std::fs::symlink_metadata(&resolved) {
|
||||||
|
if meta.file_type().is_symlink() {
|
||||||
|
let tgt = symlink_real_target(&resolved)?;
|
||||||
|
return if tgt.starts_with(&cwd_canonical) {
|
||||||
|
Ok(resolved)
|
||||||
|
} else {
|
||||||
|
Err(format!(
|
||||||
|
"path '{path}' is a symlink that escapes the session sandbox"
|
||||||
|
))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if resolved.exists() {
|
||||||
|
let canonical = resolved
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|e| format!("cannot resolve path '{path}': {e}"))?;
|
||||||
|
return if canonical.starts_with(&cwd_canonical) {
|
||||||
|
Ok(canonical)
|
||||||
|
} else {
|
||||||
|
Err(format!(
|
||||||
|
"path '{path}' resolves outside the session sandbox"
|
||||||
|
))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not-yet-existing path: walk up to the nearest existing ancestor. Use
|
||||||
|
// lstat so an intermediate *dangling* symlink directory is caught (an
|
||||||
|
// existing symlink ancestor with a live target is already caught by
|
||||||
|
// canonicalize, but a dangling one would otherwise be skipped over).
|
||||||
|
let mut ancestor = resolved.parent();
|
||||||
|
while let Some(dir) = ancestor {
|
||||||
|
match std::fs::symlink_metadata(dir) {
|
||||||
|
Ok(meta) if meta.file_type().is_symlink() => {
|
||||||
|
let tgt = symlink_real_target(dir)?;
|
||||||
|
return if tgt.starts_with(&cwd_canonical) {
|
||||||
|
Ok(resolved)
|
||||||
|
} else {
|
||||||
|
Err(format!(
|
||||||
|
"path '{path}' traverses a symlink that escapes the session sandbox"
|
||||||
|
))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
// Existing real directory/file: canonicalize and bound-check.
|
||||||
|
let anc_canonical = dir
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|e| format!("cannot resolve ancestor of '{path}': {e}"))?;
|
||||||
|
return if anc_canonical.starts_with(&cwd_canonical) {
|
||||||
|
Ok(resolved)
|
||||||
|
} else {
|
||||||
|
Err(format!("path '{path}' is outside the session sandbox"))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Component does not exist in any form yet: keep walking up.
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
ancestor = dir.parent();
|
||||||
|
}
|
||||||
|
Err(format!("cannot verify path '{path}' against the session sandbox"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve where a (possibly dangling) symlink points, as a real absolute path,
|
||||||
|
/// without requiring the target to exist: read the link, make it absolute
|
||||||
|
/// relative to the link's parent, then canonicalize its nearest existing
|
||||||
|
/// ancestor and re-append the missing remainder.
|
||||||
|
fn symlink_real_target(link: &Path) -> Result<PathBuf, String> {
|
||||||
|
let raw = std::fs::read_link(link)
|
||||||
|
.map_err(|e| format!("cannot read symlink '{}': {e}", link.display()))?;
|
||||||
|
let abs = if raw.is_absolute() {
|
||||||
|
raw
|
||||||
|
} else {
|
||||||
|
link.parent().unwrap_or_else(|| Path::new("/")).join(raw)
|
||||||
|
};
|
||||||
|
real_location(&abs)
|
||||||
|
.ok_or_else(|| format!("cannot resolve symlink target '{}'", abs.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real absolute location of a path that may not fully exist: canonicalize the
|
||||||
|
/// nearest existing ancestor (resolving any symlinks/`..` in the existing part)
|
||||||
|
/// and re-append the non-existing remainder lexically.
|
||||||
|
fn real_location(p: &Path) -> Option<PathBuf> {
|
||||||
|
if let Ok(c) = p.canonicalize() {
|
||||||
|
return Some(c);
|
||||||
|
}
|
||||||
|
let mut remainder: Vec<std::ffi::OsString> = Vec::new();
|
||||||
|
let mut cur = p.to_path_buf();
|
||||||
|
loop {
|
||||||
|
let parent = cur.parent()?;
|
||||||
|
if let Some(name) = cur.file_name() {
|
||||||
|
remainder.push(name.to_os_string());
|
||||||
|
}
|
||||||
|
if let Ok(mut base) = parent.canonicalize() {
|
||||||
|
while let Some(name) = remainder.pop() {
|
||||||
|
base.push(name);
|
||||||
|
}
|
||||||
|
return Some(base);
|
||||||
|
}
|
||||||
|
cur = parent.to_path_buf();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether a tool's path-bearing arguments stay within the sandbox.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(())` when the tool either takes no path arguments or all of its
|
||||||
|
/// path arguments resolve within `cwd`; returns `Err(reason)` on the first
|
||||||
|
/// argument that escapes. Bash/shell are intentionally not path-checked here —
|
||||||
|
/// they are gated by the approval flow and run with the sandbox dir as cwd.
|
||||||
|
pub fn check_tool_paths(tool_name: &str, input: &Value, cwd: &Path) -> Result<(), String> {
|
||||||
|
let n = tool_name.to_lowercase();
|
||||||
|
match n.as_str() {
|
||||||
|
"read" | "read_file" | "write" | "write_file" | "edit" | "edit_file" | "create"
|
||||||
|
| "create_file" | "delete" | "delete_file" | "rename" | "list_dir" | "listdir"
|
||||||
|
| "multiedit" => {
|
||||||
|
check_field(input, &["path", "file_path", "file"], cwd)?;
|
||||||
|
check_field(input, &["new_path", "destination", "dest"], cwd)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
"glob" | "glob_search" | "grep" | "search" | "ripgrep" => {
|
||||||
|
// Optional explicit base path; the pattern itself is relative to cwd.
|
||||||
|
check_field(input, &["path", "dir", "directory"], cwd)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
// Command execution and unknown tools: no path arg to validate here.
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_field(input: &Value, keys: &[&str], cwd: &Path) -> Result<(), String> {
|
||||||
|
for key in keys {
|
||||||
|
if let Some(p) = input.get(*key).and_then(|v| v.as_str()) {
|
||||||
|
validate_path_within_cwd(p, cwd)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn tmp_sandbox() -> PathBuf {
|
||||||
|
let base = std::env::temp_dir().join(format!("agent_sb_{}", uuid::Uuid::new_v4().simple()));
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
base.canonicalize().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitizes_session_id() {
|
||||||
|
assert_eq!(sanitize_session_id("../../etc"), "______etc");
|
||||||
|
assert_eq!(sanitize_session_id("abc-123_DEF"), "abc-123_DEF");
|
||||||
|
assert_eq!(sanitize_session_id(""), "default");
|
||||||
|
assert_eq!(sanitize_session_id("a/b"), "a_b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allows_path_inside_sandbox() {
|
||||||
|
let sb = tmp_sandbox();
|
||||||
|
assert!(validate_path_within_cwd("notes.txt", &sb).is_ok());
|
||||||
|
assert!(validate_path_within_cwd("sub/dir/new.txt", &sb).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_parent_traversal() {
|
||||||
|
let sb = tmp_sandbox();
|
||||||
|
assert!(validate_path_within_cwd("../escape.txt", &sb).is_err());
|
||||||
|
assert!(validate_path_within_cwd("../../etc/passwd", &sb).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_absolute_outside() {
|
||||||
|
let sb = tmp_sandbox();
|
||||||
|
assert!(validate_path_within_cwd("/etc/passwd", &sb).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_tool_paths_blocks_escaping_write() {
|
||||||
|
let sb = tmp_sandbox();
|
||||||
|
let bad = json!({"path": "../../secret.txt", "content": "x"});
|
||||||
|
assert!(check_tool_paths("write", &bad, &sb).is_err());
|
||||||
|
|
||||||
|
let ok = json!({"path": "out.txt", "content": "x"});
|
||||||
|
assert!(check_tool_paths("write", &ok, &sb).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_tool_paths_ignores_bash() {
|
||||||
|
let sb = tmp_sandbox();
|
||||||
|
let input = json!({"command": "ls -la"});
|
||||||
|
assert!(check_tool_paths("bash", &input, &sb).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn rejects_dangling_symlink_escape() {
|
||||||
|
// Models an approved `ln -s /etc/cron.d/pwn evil` inside the sandbox,
|
||||||
|
// whose target does not exist yet. A later Write to `evil` must NOT be
|
||||||
|
// allowed to follow the link out of the sandbox.
|
||||||
|
let sb = tmp_sandbox();
|
||||||
|
std::os::unix::fs::symlink("/etc/cron.d/pwn_dangling", sb.join("evil")).unwrap();
|
||||||
|
let res = validate_path_within_cwd("evil", &sb);
|
||||||
|
assert!(res.is_err(), "dangling symlink escape must be rejected: {res:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn rejects_dangling_symlink_to_absolute_outside() {
|
||||||
|
let sb = tmp_sandbox();
|
||||||
|
std::os::unix::fs::symlink("/tmp/definitely_outside_xyz", sb.join("link")).unwrap();
|
||||||
|
assert!(validate_path_within_cwd("link", &sb).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn rejects_relative_dangling_symlink_escape() {
|
||||||
|
// `evil -> ../../escape` (target missing) must be rejected too.
|
||||||
|
let sb = tmp_sandbox();
|
||||||
|
std::os::unix::fs::symlink("../../escape_target", sb.join("evil")).unwrap();
|
||||||
|
assert!(validate_path_within_cwd("evil", &sb).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn allows_dangling_symlink_pointing_inside() {
|
||||||
|
// A symlink whose (missing) target is still inside the sandbox is fine.
|
||||||
|
let sb = tmp_sandbox();
|
||||||
|
std::os::unix::fs::symlink(sb.join("inside_target"), sb.join("ok_link")).unwrap();
|
||||||
|
assert!(validate_path_within_cwd("ok_link", &sb).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn rejects_write_through_intermediate_dangling_symlink_dir() {
|
||||||
|
// `evildir -> /etc` (dangling), then write to `evildir/pwn`.
|
||||||
|
let sb = tmp_sandbox();
|
||||||
|
std::os::unix::fs::symlink("/etc/nonexistent_dir_xyz", sb.join("evildir")).unwrap();
|
||||||
|
assert!(validate_path_within_cwd("evildir/pwn", &sb).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn check_tool_paths_blocks_dangling_symlink_write() {
|
||||||
|
let sb = tmp_sandbox();
|
||||||
|
std::os::unix::fs::symlink("/etc/cron.d/pwn2", sb.join("evil")).unwrap();
|
||||||
|
let bad = json!({"path": "evil", "content": "* * * * * root sh"});
|
||||||
|
assert!(check_tool_paths("write", &bad, &sb).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
//! Persistence for Agent Workbench session accounting.
|
||||||
|
//!
|
||||||
|
//! Each agent session has one row in `agent_sessions` tracking cumulative token
|
||||||
|
//! usage and cost across its turns. A session is created (idempotently) on its
|
||||||
|
//! first chat request and its counters are advanced after every completed turn,
|
||||||
|
//! using the usage/cost reported by the SDK `Result` event.
|
||||||
|
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use serde::Serialize;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
/// One agent session's persisted accounting state.
|
||||||
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct AgentSession {
|
||||||
|
pub id: String,
|
||||||
|
pub user_id: Option<i64>,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub model: String,
|
||||||
|
pub permission_mode: String,
|
||||||
|
pub input_tokens: i64,
|
||||||
|
pub output_tokens: i64,
|
||||||
|
pub total_tokens: i64,
|
||||||
|
pub cost_usd: f64,
|
||||||
|
pub num_turns: i64,
|
||||||
|
pub message_count: i64,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Token/cost delta produced by one completed agent turn.
|
||||||
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
|
pub struct UsageDelta {
|
||||||
|
pub input_tokens: i64,
|
||||||
|
pub output_tokens: i64,
|
||||||
|
pub cost_usd: f64,
|
||||||
|
pub num_turns: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the session row if it does not exist yet. Idempotent: an existing
|
||||||
|
/// session keeps its counters and metadata untouched.
|
||||||
|
pub async fn ensure_session(
|
||||||
|
db: &SqlitePool,
|
||||||
|
session_id: &str,
|
||||||
|
user_id: Option<i64>,
|
||||||
|
model: &str,
|
||||||
|
permission_mode: &str,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO agent_sessions (id, user_id, model, permission_mode) \
|
||||||
|
VALUES (?, ?, ?, ?) \
|
||||||
|
ON CONFLICT(id) DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(session_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(model)
|
||||||
|
.bind(permission_mode)
|
||||||
|
.execute(db)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance a session's cumulative counters by one turn's usage delta and bump
|
||||||
|
/// `updated_at`. The user-visible message count is incremented by `messages`
|
||||||
|
/// (typically 2: one user prompt + one assistant reply).
|
||||||
|
pub async fn record_turn(
|
||||||
|
db: &SqlitePool,
|
||||||
|
session_id: &str,
|
||||||
|
delta: UsageDelta,
|
||||||
|
messages: i64,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE agent_sessions SET \
|
||||||
|
input_tokens = input_tokens + ?, \
|
||||||
|
output_tokens = output_tokens + ?, \
|
||||||
|
total_tokens = total_tokens + ?, \
|
||||||
|
cost_usd = cost_usd + ?, \
|
||||||
|
num_turns = num_turns + ?, \
|
||||||
|
message_count = message_count + ?, \
|
||||||
|
updated_at = datetime('now') \
|
||||||
|
WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(delta.input_tokens)
|
||||||
|
.bind(delta.output_tokens)
|
||||||
|
.bind(delta.input_tokens + delta.output_tokens)
|
||||||
|
.bind(delta.cost_usd)
|
||||||
|
.bind(delta.num_turns)
|
||||||
|
.bind(messages)
|
||||||
|
.bind(session_id)
|
||||||
|
.execute(db)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch a single session by id (scoped to `user_id` when provided, so a user
|
||||||
|
/// cannot read another tenant's session). Admin callers pass `None`.
|
||||||
|
pub async fn get_session(
|
||||||
|
db: &SqlitePool,
|
||||||
|
session_id: &str,
|
||||||
|
user_id: Option<i64>,
|
||||||
|
) -> AppResult<Option<AgentSession>> {
|
||||||
|
let row = match user_id {
|
||||||
|
Some(uid) => {
|
||||||
|
sqlx::query_as::<_, AgentSession>(
|
||||||
|
"SELECT * FROM agent_sessions WHERE id = ? AND user_id = ?",
|
||||||
|
)
|
||||||
|
.bind(session_id)
|
||||||
|
.bind(uid)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
sqlx::query_as::<_, AgentSession>("SELECT * FROM agent_sessions WHERE id = ?")
|
||||||
|
.bind(session_id)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
|
||||||
|
async fn pool() -> SqlitePool {
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(include_str!(
|
||||||
|
"../../../migrations/20260622000001_agent_sessions.sql"
|
||||||
|
))
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ensure_is_idempotent_and_accumulates() {
|
||||||
|
let db = pool().await;
|
||||||
|
ensure_session(&db, "s1", Some(7), "gpt-4o", "default")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// Second ensure must not reset counters.
|
||||||
|
record_turn(
|
||||||
|
&db,
|
||||||
|
"s1",
|
||||||
|
UsageDelta { input_tokens: 100, output_tokens: 40, cost_usd: 0.5, num_turns: 3 },
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
ensure_session(&db, "s1", Some(7), "gpt-4o", "default")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let s = get_session(&db, "s1", Some(7)).await.unwrap().unwrap();
|
||||||
|
assert_eq!(s.input_tokens, 100);
|
||||||
|
assert_eq!(s.output_tokens, 40);
|
||||||
|
assert_eq!(s.total_tokens, 140);
|
||||||
|
assert!((s.cost_usd - 0.5).abs() < 1e-9);
|
||||||
|
assert_eq!(s.num_turns, 3);
|
||||||
|
assert_eq!(s.message_count, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn record_turn_adds_across_turns() {
|
||||||
|
let db = pool().await;
|
||||||
|
ensure_session(&db, "s2", None, "sonnet", "default").await.unwrap();
|
||||||
|
for _ in 0..3 {
|
||||||
|
record_turn(
|
||||||
|
&db,
|
||||||
|
"s2",
|
||||||
|
UsageDelta { input_tokens: 10, output_tokens: 5, cost_usd: 0.01, num_turns: 1 },
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let s = get_session(&db, "s2", None).await.unwrap().unwrap();
|
||||||
|
assert_eq!(s.total_tokens, 45);
|
||||||
|
assert_eq!(s.num_turns, 3);
|
||||||
|
assert_eq!(s.message_count, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_session_is_user_scoped() {
|
||||||
|
let db = pool().await;
|
||||||
|
ensure_session(&db, "s3", Some(1), "m", "default").await.unwrap();
|
||||||
|
// Wrong user cannot see it.
|
||||||
|
assert!(get_session(&db, "s3", Some(2)).await.unwrap().is_none());
|
||||||
|
// Owner can.
|
||||||
|
assert!(get_session(&db, "s3", Some(1)).await.unwrap().is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod announcement;
|
pub mod announcement;
|
||||||
|
pub mod agent_workbench;
|
||||||
pub mod billing;
|
pub mod billing;
|
||||||
pub mod builtin_keys;
|
pub mod builtin_keys;
|
||||||
pub mod comfyui_process;
|
pub mod comfyui_process;
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const Models = lazy(() => import('./pages/Models.js'));
|
|||||||
const About = lazy(() => import('./pages/About.js'));
|
const About = lazy(() => import('./pages/About.js'));
|
||||||
const ModelTester = lazy(() => import('./pages/ModelTester.js'));
|
const ModelTester = lazy(() => import('./pages/ModelTester.js'));
|
||||||
const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js'));
|
const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js'));
|
||||||
|
const AgentWorkbench = lazy(() => import('./pages/AgentWorkbench.js'));
|
||||||
const PictureAgent = lazy(() => import('./pages/PictureAgent.js'));
|
const PictureAgent = lazy(() => import('./pages/PictureAgent.js'));
|
||||||
const DigitalHuman = lazy(() => import('./pages/DigitalHuman.js'));
|
const DigitalHuman = lazy(() => import('./pages/DigitalHuman.js'));
|
||||||
const MoneyPrinter = lazy(() => import('./pages/MoneyPrinter.js'));
|
const MoneyPrinter = lazy(() => import('./pages/MoneyPrinter.js'));
|
||||||
@@ -626,6 +627,7 @@ export const sidebarGroups: SidebarGroup[] = [
|
|||||||
{ to: '/digital-human', label: '短视频工厂/数字人', icon: navIcons.digitalHuman },
|
{ to: '/digital-human', label: '短视频工厂/数字人', icon: navIcons.digitalHuman },
|
||||||
{ to: '/workflows', label: '工作流', icon: navIcons.workflow },
|
{ to: '/workflows', label: '工作流', icon: navIcons.workflow },
|
||||||
{ to: '/comfyui-agent', label: '视频助手', icon: navIcons.assistant },
|
{ to: '/comfyui-agent', label: '视频助手', icon: navIcons.assistant },
|
||||||
|
{ to: '/agent-workbench', label: 'AI Agent', icon: navIcons.assistant },
|
||||||
{ to: '/workspace', label: '工作空间', icon: navIcons.workspace },
|
{ to: '/workspace', label: '工作空间', icon: navIcons.workspace },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -658,6 +660,7 @@ export const userSidebarGroups = [
|
|||||||
{ to: '/user/digital-human', label: '短视频工厂/数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
|
{ to: '/user/digital-human', label: '短视频工厂/数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
|
||||||
{ to: '/user/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> },
|
{ to: '/user/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> },
|
||||||
{ to: '/user/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
|
{ to: '/user/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
|
||||||
|
{ to: '/user/agent-workbench', label: 'AI Agent', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg> },
|
||||||
{ to: '/user/workspace', label: '工作空间', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 7a2 2 0 012-2h4l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H6a2 2 0 01-2-2V7z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 13h8M8 16h5" /></svg> },
|
{ to: '/user/workspace', label: '工作空间', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 7a2 2 0 012-2h4l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H6a2 2 0 01-2-2V7z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 13h8M8 16h5" /></svg> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -1667,6 +1670,7 @@ function AppShell() {
|
|||||||
<Route path="/workflows" element={<Workflows />} />
|
<Route path="/workflows" element={<Workflows />} />
|
||||||
<Route path="/video-agent" element={<Navigate to="/workflows" replace />} />
|
<Route path="/video-agent" element={<Navigate to="/workflows" replace />} />
|
||||||
<Route path="/comfyui-agent" element={<ComfyUIAgent />} />
|
<Route path="/comfyui-agent" element={<ComfyUIAgent />} />
|
||||||
|
<Route path="/agent-workbench" element={<AgentWorkbench />} />
|
||||||
<Route path="/workspace" element={<Workspace />} />
|
<Route path="/workspace" element={<Workspace />} />
|
||||||
<Route path="/about" element={<About />} />
|
<Route path="/about" element={<About />} />
|
||||||
<Route path="/users" element={<UserManagement />} />
|
<Route path="/users" element={<UserManagement />} />
|
||||||
@@ -1690,6 +1694,7 @@ function AppShell() {
|
|||||||
<Route path="/user/workflows" element={<Workflows />} />
|
<Route path="/user/workflows" element={<Workflows />} />
|
||||||
<Route path="/user/video-agent" element={<Navigate to="/user/workflows" replace />} />
|
<Route path="/user/video-agent" element={<Navigate to="/user/workflows" replace />} />
|
||||||
<Route path="/user/comfyui-agent" element={<ComfyUIAgent />} />
|
<Route path="/user/comfyui-agent" element={<ComfyUIAgent />} />
|
||||||
|
<Route path="/user/agent-workbench" element={<AgentWorkbench />} />
|
||||||
<Route path="/user/workspace" element={<Workspace />} />
|
<Route path="/user/workspace" element={<Workspace />} />
|
||||||
<Route path="/user/about" element={<About />} />
|
<Route path="/user/about" element={<About />} />
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,482 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { getSessionToken } from '../authSession.js';
|
||||||
|
import { useToast } from '../components/Toast.js';
|
||||||
|
|
||||||
|
// Agent Workbench — a minimal chat UI over POST /api/agent/chat (SSE).
|
||||||
|
// Streams assistant text, renders tool-call cards, surfaces interactive
|
||||||
|
// approval prompts for risky tools, and shows running session token/cost.
|
||||||
|
|
||||||
|
type PermissionMode = 'default' | 'accept_edits' | 'full_access';
|
||||||
|
|
||||||
|
type ToolCall = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
input: unknown;
|
||||||
|
result?: string;
|
||||||
|
isError?: boolean;
|
||||||
|
denied?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChatMessage = {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
text: string;
|
||||||
|
tools: ToolCall[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApprovalPrompt = {
|
||||||
|
requestId: string;
|
||||||
|
name: string;
|
||||||
|
input: unknown;
|
||||||
|
risk: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionUsage = {
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
costUsd: number;
|
||||||
|
numTurns: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SseEvent = { type: string; data?: Record<string, unknown> };
|
||||||
|
|
||||||
|
const MODE_LABELS: Record<PermissionMode, string> = {
|
||||||
|
default: '标准审核',
|
||||||
|
accept_edits: '自动接受编辑',
|
||||||
|
full_access: '完全放行',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Read a `data:`-framed SSE stream, invoking `onEvent` per JSON line. */
|
||||||
|
async function readSse(response: Response, onEvent: (e: SseEvent) => void): Promise<void> {
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) throw new Error('浏览器无法读取流式响应');
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
for (const raw of lines) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line.startsWith('data:')) continue;
|
||||||
|
const data = line.slice(5).trim();
|
||||||
|
if (!data) continue;
|
||||||
|
try {
|
||||||
|
onEvent(JSON.parse(data) as SseEvent);
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed frame */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCost(usd: number): string {
|
||||||
|
return `$${usd.toFixed(4)}`;
|
||||||
|
}
|
||||||
|
export default function AgentWorkbench() {
|
||||||
|
const toast = useToast();
|
||||||
|
const location = useLocation();
|
||||||
|
const audience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||||
|
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [mode, setMode] = useState<PermissionMode>('default');
|
||||||
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
|
const [usage, setUsage] = useState<SessionUsage | null>(null);
|
||||||
|
const [approval, setApproval] = useState<ApprovalPrompt | null>(null);
|
||||||
|
|
||||||
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
||||||
|
}, [messages, approval]);
|
||||||
|
|
||||||
|
const authToken = useCallback(
|
||||||
|
() => getSessionToken(audience, localStorage) ?? '',
|
||||||
|
[audience],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mutate the last assistant message in place as stream events arrive.
|
||||||
|
const updateAssistant = useCallback((mut: (m: ChatMessage) => void) => {
|
||||||
|
setMessages((prev) => {
|
||||||
|
if (prev.length === 0) return prev;
|
||||||
|
const next = prev.slice();
|
||||||
|
const last = { ...next[next.length - 1] };
|
||||||
|
if (last.role !== 'assistant') return prev;
|
||||||
|
last.tools = last.tools.slice();
|
||||||
|
mut(last);
|
||||||
|
next[next.length - 1] = last;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resolveApproval = useCallback(
|
||||||
|
async (requestId: string, approved: boolean) => {
|
||||||
|
setApproval(null);
|
||||||
|
try {
|
||||||
|
await fetch('/api/agent/approve', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${authToken()}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ request_id: requestId, approved }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
toast.error('提交审批失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[authToken, toast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEvent = useCallback(
|
||||||
|
(e: SseEvent) => {
|
||||||
|
const d = e.data ?? {};
|
||||||
|
switch (e.type) {
|
||||||
|
case 'session':
|
||||||
|
if (typeof d.session_id === 'string') setSessionId(d.session_id);
|
||||||
|
break;
|
||||||
|
case 'text':
|
||||||
|
if (typeof d.text === 'string') {
|
||||||
|
const chunk = d.text;
|
||||||
|
updateAssistant((m) => {
|
||||||
|
m.text += chunk;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'tool_use':
|
||||||
|
updateAssistant((m) => {
|
||||||
|
m.tools.push({
|
||||||
|
id: String(d.id ?? ''),
|
||||||
|
name: String(d.name ?? ''),
|
||||||
|
input: d.input,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'tool_result':
|
||||||
|
updateAssistant((m) => {
|
||||||
|
const tc = m.tools.find((t) => t.id === d.tool_use_id);
|
||||||
|
if (tc) {
|
||||||
|
tc.result = String(d.content ?? '');
|
||||||
|
tc.isError = Boolean(d.is_error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'tool_denied':
|
||||||
|
updateAssistant((m) => {
|
||||||
|
const tc = m.tools.find((t) => t.name === d.name && !t.result && !t.denied);
|
||||||
|
if (tc) tc.denied = String(d.reason ?? '已拒绝');
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'approval_request':
|
||||||
|
setApproval({
|
||||||
|
requestId: String(d.request_id ?? ''),
|
||||||
|
name: String(d.name ?? ''),
|
||||||
|
input: d.input,
|
||||||
|
risk: String(d.risk ?? ''),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'result':
|
||||||
|
setUsage({
|
||||||
|
inputTokens: Number(d.input_tokens ?? 0),
|
||||||
|
outputTokens: Number(d.output_tokens ?? 0),
|
||||||
|
totalTokens: Number(d.total_tokens ?? 0),
|
||||||
|
costUsd: Number(d.cost_usd ?? 0),
|
||||||
|
numTurns: Number(d.num_turns ?? 0),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
toast.error(String(d.message ?? '执行出错'));
|
||||||
|
updateAssistant((m) => {
|
||||||
|
m.text += `\n\n[错误: ${String(d.message ?? '')}]`;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[toast, updateAssistant],
|
||||||
|
);
|
||||||
|
|
||||||
|
const send = useCallback(async () => {
|
||||||
|
const prompt = input.trim();
|
||||||
|
if (!prompt || running) return;
|
||||||
|
const token = authToken();
|
||||||
|
if (!token) {
|
||||||
|
toast.error('未登录或会话已过期');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRunning(true);
|
||||||
|
setApproval(null);
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ role: 'user', text: prompt, tools: [] },
|
||||||
|
{ role: 'assistant', text: '', tools: [] },
|
||||||
|
]);
|
||||||
|
setInput('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/agent/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: prompt,
|
||||||
|
session_id: sessionId,
|
||||||
|
permission_mode: mode,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.text().catch(() => '');
|
||||||
|
throw new Error(detail || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
await readSse(response, handleEvent);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`请求失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
updateAssistant((m) => {
|
||||||
|
m.text += '\n\n[连接中断]';
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
setApproval(null);
|
||||||
|
}
|
||||||
|
}, [authToken, handleEvent, input, mode, running, sessionId, toast, updateAssistant]);
|
||||||
|
|
||||||
|
const newSession = useCallback(() => {
|
||||||
|
if (running) return;
|
||||||
|
setMessages([]);
|
||||||
|
setSessionId(null);
|
||||||
|
setUsage(null);
|
||||||
|
setApproval(null);
|
||||||
|
}, [running]);
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 12, padding: 16 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>AI Agent 工作台</h2>
|
||||||
|
<p style={{ margin: '4px 0 0', fontSize: 12, color: 'var(--text-secondary, #888)' }}>
|
||||||
|
服务端内置 agent,可读写文件 / 执行命令(沙箱隔离 + 权限审批)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<label style={{ fontSize: 12, color: 'var(--text-secondary, #888)' }}>权限模式</label>
|
||||||
|
<select
|
||||||
|
value={mode}
|
||||||
|
disabled={running}
|
||||||
|
onChange={(e) => setMode(e.target.value as PermissionMode)}
|
||||||
|
style={{ padding: '4px 8px', borderRadius: 6 }}
|
||||||
|
>
|
||||||
|
{(Object.keys(MODE_LABELS) as PermissionMode[]).map((m) => (
|
||||||
|
<option key={m} value={m}>{MODE_LABELS[m]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button onClick={newSession} disabled={running} style={{ padding: '4px 12px', borderRadius: 6 }}>
|
||||||
|
新会话
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usage bar */}
|
||||||
|
{usage && (
|
||||||
|
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--text-secondary, #888)', flexWrap: 'wrap' }}>
|
||||||
|
<span>会话: <code>{sessionId?.slice(0, 8) ?? '—'}</code></span>
|
||||||
|
<span>轮次: {usage.numTurns}</span>
|
||||||
|
<span>tokens: {usage.totalTokens} (in {usage.inputTokens} / out {usage.outputTokens})</span>
|
||||||
|
<span>成本: {formatCost(usage.costUsd)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
border: '1px solid var(--border-color, #2a2a2a)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 12,
|
||||||
|
minHeight: 240,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div style={{ color: 'var(--text-secondary, #888)', fontSize: 13, textAlign: 'center', marginTop: 40 }}>
|
||||||
|
输入任务,让 agent 在隔离沙箱中读写文件、执行命令。危险操作会请求你的审批。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((m, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: m.role === 'user' ? 'var(--primary, #4a9eff)' : '#22c55e' }}>
|
||||||
|
{m.role === 'user' ? '你' : 'Agent'}
|
||||||
|
</div>
|
||||||
|
{m.text && (
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap', fontSize: 14, lineHeight: 1.6 }}>{m.text}</div>
|
||||||
|
)}
|
||||||
|
{m.tools.map((t) => (
|
||||||
|
<ToolCard key={t.id} tool={t} />
|
||||||
|
))}
|
||||||
|
{m.role === 'assistant' && !m.text && m.tools.length === 0 && running && i === messages.length - 1 && (
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-secondary, #888)' }}>思考中…</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Approval modal */}
|
||||||
|
{approval && (
|
||||||
|
<ApprovalModal
|
||||||
|
prompt={approval}
|
||||||
|
onApprove={() => resolveApproval(approval.requestId, true)}
|
||||||
|
onDeny={() => resolveApproval(approval.requestId, false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Composer */}
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<textarea
|
||||||
|
value={input}
|
||||||
|
disabled={running}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
void send();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="描述任务… (Ctrl/Cmd + Enter 发送)"
|
||||||
|
rows={3}
|
||||||
|
style={{ flex: 1, padding: 10, borderRadius: 8, resize: 'vertical', fontFamily: 'inherit', fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => void send()}
|
||||||
|
disabled={running || !input.trim()}
|
||||||
|
style={{
|
||||||
|
padding: '0 20px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: running ? 'var(--border-color, #333)' : 'var(--primary, #4a9eff)',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
cursor: running ? 'default' : 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{running ? '运行中…' : '发送'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolCard({ tool }: { tool: ToolCall }) {
|
||||||
|
const border = tool.denied
|
||||||
|
? '#f59e0b'
|
||||||
|
: tool.isError
|
||||||
|
? '#ef4444'
|
||||||
|
: tool.result
|
||||||
|
? '#22c55e'
|
||||||
|
: 'var(--border-color, #2a2a2a)';
|
||||||
|
return (
|
||||||
|
<div style={{ border: `1px solid ${border}`, borderRadius: 8, padding: 10, fontSize: 12, background: 'var(--surface, rgba(255,255,255,0.02))' }}>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 4, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span>🔧 {tool.name}</span>
|
||||||
|
{!tool.result && !tool.denied && <span style={{ color: 'var(--text-secondary, #888)' }}>· 运行中</span>}
|
||||||
|
{tool.denied && <span style={{ color: '#f59e0b' }}>· 已拒绝</span>}
|
||||||
|
</div>
|
||||||
|
<pre style={{ margin: '4px 0', whiteSpace: 'pre-wrap', wordBreak: 'break-all', color: 'var(--text-secondary, #aaa)' }}>
|
||||||
|
{typeof tool.input === 'string' ? tool.input : JSON.stringify(tool.input, null, 2)}
|
||||||
|
</pre>
|
||||||
|
{tool.denied && <div style={{ color: '#f59e0b' }}>{tool.denied}</div>}
|
||||||
|
{tool.result && (
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: '6px 0 0',
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'rgba(0,0,0,0.25)',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
maxHeight: 200,
|
||||||
|
overflowY: 'auto',
|
||||||
|
color: tool.isError ? '#ef4444' : 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tool.result}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApprovalModal({
|
||||||
|
prompt,
|
||||||
|
onApprove,
|
||||||
|
onDeny,
|
||||||
|
}: {
|
||||||
|
prompt: ApprovalPrompt;
|
||||||
|
onApprove: () => void;
|
||||||
|
onDeny: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0,0,0,0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg, #1a1a1a)',
|
||||||
|
border: '1px solid var(--border-color, #333)',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 20,
|
||||||
|
width: 'min(520px, 92vw)',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ margin: '0 0 8px', fontSize: 16 }}>需要审批:{prompt.name}</h3>
|
||||||
|
<p style={{ margin: '0 0 12px', fontSize: 13, color: 'var(--text-secondary, #888)' }}>
|
||||||
|
Agent 请求执行一个 <strong>{prompt.risk}</strong> 级别的工具。请确认参数后决定是否允许。
|
||||||
|
</p>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
background: 'rgba(0,0,0,0.3)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
fontSize: 12,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
maxHeight: 280,
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{typeof prompt.input === 'string' ? prompt.input : JSON.stringify(prompt.input, null, 2)}
|
||||||
|
</pre>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
|
||||||
|
<button onClick={onDeny} style={{ padding: '8px 16px', borderRadius: 8 }}>
|
||||||
|
拒绝
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onApprove}
|
||||||
|
style={{ padding: '8px 16px', borderRadius: 8, background: '#22c55e', color: '#fff', border: 'none' }}
|
||||||
|
>
|
||||||
|
允许执行
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
//! Integration tests for the Agent Workbench approval registry.
|
||||||
|
//!
|
||||||
|
//! These run against the library's public API (not the in-crate `#[cfg(test)]`
|
||||||
|
//! modules), so they compile and execute independently of unrelated test
|
||||||
|
//! fixtures elsewhere in the crate. They pin the core safety invariant of the
|
||||||
|
//! atomic approval state machine:
|
||||||
|
//!
|
||||||
|
//! the value `/api/agent/approve` reports back (`resolve` -> bool) and the
|
||||||
|
//! decision the agent loop executes (`wait_for_decision` -> SettledState)
|
||||||
|
//! are derived from the *same* atomic, so they can never disagree.
|
||||||
|
|
||||||
|
use boosapi::services::agent_workbench::approval::{
|
||||||
|
register, resolve, wait_for_decision, ApprovalOutcome, SettledState,
|
||||||
|
};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn approve_then_wait_observes_approved() {
|
||||||
|
let (entry, _guard) = register("it-approve");
|
||||||
|
assert!(resolve("it-approve", ApprovalOutcome::Approve));
|
||||||
|
assert_eq!(
|
||||||
|
wait_for_decision(&entry, Duration::from_secs(5)).await,
|
||||||
|
SettledState::Approved
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn deny_then_wait_observes_denied() {
|
||||||
|
let (entry, _guard) = register("it-deny");
|
||||||
|
assert!(resolve("it-deny", ApprovalOutcome::Deny));
|
||||||
|
assert_eq!(
|
||||||
|
wait_for_decision(&entry, Duration::from_secs(5)).await,
|
||||||
|
SettledState::Denied
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn timeout_denies_and_late_click_is_truthfully_rejected() {
|
||||||
|
let (entry, _guard) = register("it-timeout");
|
||||||
|
// No resolve arrives before the (tiny) timeout.
|
||||||
|
assert_eq!(
|
||||||
|
wait_for_decision(&entry, Duration::from_millis(20)).await,
|
||||||
|
SettledState::TimedOut
|
||||||
|
);
|
||||||
|
// A late click must be told ok:false — the timeout already claimed it.
|
||||||
|
assert!(!resolve("it-timeout", ApprovalOutcome::Approve));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unknown_id_resolves_false() {
|
||||||
|
assert!(!resolve("it-unknown", ApprovalOutcome::Approve));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn guard_drop_cleans_up_registry() {
|
||||||
|
{
|
||||||
|
let (_entry, _guard) = register("it-guard");
|
||||||
|
}
|
||||||
|
assert!(!resolve("it-guard", ApprovalOutcome::Approve));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The invariant under contention: race a `resolve(Approve)` against a
|
||||||
|
/// near-zero timeout thousands of times. The frontend-reported success
|
||||||
|
/// (`resolve` -> true) must hold if and only if the executed decision is
|
||||||
|
/// `Approved`; otherwise the decision must be `TimedOut`. The mixed state
|
||||||
|
/// (frontend told "approved" while the loop denied, or vice versa) must never
|
||||||
|
/// occur — that was the bug the atomic state machine fixes.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn resolve_and_timeout_never_disagree() {
|
||||||
|
for i in 0..5000 {
|
||||||
|
let id = format!("it-race-{i}");
|
||||||
|
let (entry, _guard) = register(&id);
|
||||||
|
let id2 = id.clone();
|
||||||
|
let resolver = tokio::spawn(async move { resolve(&id2, ApprovalOutcome::Approve) });
|
||||||
|
let decided = wait_for_decision(&entry, Duration::from_nanos(1)).await;
|
||||||
|
let frontend_ok = resolver.await.unwrap();
|
||||||
|
match (frontend_ok, decided) {
|
||||||
|
(true, SettledState::Approved) => {}
|
||||||
|
(false, SettledState::TimedOut) => {}
|
||||||
|
other => panic!("inconsistent outcome at i={i}: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
//! Integration tests for OS-level (bubblewrap) confinement of the agent Bash
|
||||||
|
//! tool. These actually invoke `SandboxedBashTool::call` and assert that, in a
|
||||||
|
//! review mode, the kernel boundary blocks filesystem and network escapes while
|
||||||
|
//! legitimate in-sandbox work succeeds.
|
||||||
|
//!
|
||||||
|
//! The escape tests require a working `bwrap`; when it is absent the confined
|
||||||
|
//! path fails closed (asserted separately) and the live-escape tests skip with
|
||||||
|
//! a printed notice rather than producing a false pass.
|
||||||
|
|
||||||
|
#![cfg(unix)]
|
||||||
|
|
||||||
|
use boosapi::services::agent_workbench::bash_sandbox::{build_bwrap_args, SandboxedBashTool};
|
||||||
|
use boosapi::services::agent_workbench::permission::PermissionMode;
|
||||||
|
use open_agent_sdk::types::{Tool, ToolUseContext};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
fn bwrap_available() -> bool {
|
||||||
|
["/usr/bin/bwrap", "/bin/bwrap", "/usr/local/bin/bwrap"]
|
||||||
|
.iter()
|
||||||
|
.any(|p| Path::new(p).is_file())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tmp_session() -> PathBuf {
|
||||||
|
let base =
|
||||||
|
std::env::temp_dir().join(format!("agent_bash_it_{}", uuid::Uuid::new_v4().simple()));
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
base.canonicalize().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(tool: &SandboxedBashTool, command: &str) -> (String, bool) {
|
||||||
|
let ctx = ToolUseContext::new(std::env::temp_dir().to_string_lossy().to_string());
|
||||||
|
let res = tool
|
||||||
|
.call(json!({ "command": command }), &ctx)
|
||||||
|
.await
|
||||||
|
.expect("tool call ok");
|
||||||
|
(res.get_text(), res.is_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_args_pin_session_dir_and_isolation() {
|
||||||
|
let args = build_bwrap_args(Path::new("/srv/sess/abc"), "id");
|
||||||
|
let j = args.join(" ");
|
||||||
|
assert!(j.contains("--bind /srv/sess/abc /srv/sess/abc"));
|
||||||
|
assert!(j.contains("--chdir /srv/sess/abc"));
|
||||||
|
assert!(args.iter().any(|a| a == "--unshare-all"));
|
||||||
|
assert!(!j.contains("/etc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn default_mode_blocks_reading_etc_passwd() {
|
||||||
|
if !bwrap_available() {
|
||||||
|
eprintln!("SKIP: bwrap not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let tool = SandboxedBashTool::new(tmp_session(), PermissionMode::Default);
|
||||||
|
let (out, _is_err) = run(&tool, "cat /etc/passwd").await;
|
||||||
|
// The host's /etc is never mounted, so the read cannot succeed.
|
||||||
|
assert!(
|
||||||
|
!out.contains("root:") && !out.contains(":/bin/"),
|
||||||
|
"should not see host passwd contents, got: {out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn default_mode_blocks_listing_root() {
|
||||||
|
if !bwrap_available() {
|
||||||
|
eprintln!("SKIP: bwrap not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let tool = SandboxedBashTool::new(tmp_session(), PermissionMode::Default);
|
||||||
|
let (out, _) = run(&tool, "ls /").await;
|
||||||
|
// /etc, /root, /home must be absent from the sandbox root.
|
||||||
|
assert!(!out.contains("etc"), "sandbox root must not expose /etc: {out}");
|
||||||
|
assert!(!out.contains("root"), "sandbox root must not expose /root: {out}");
|
||||||
|
assert!(!out.contains("home"), "sandbox root must not expose /home: {out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn default_mode_blocks_writing_outside_sandbox() {
|
||||||
|
if !bwrap_available() {
|
||||||
|
eprintln!("SKIP: bwrap not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let tool = SandboxedBashTool::new(tmp_session(), PermissionMode::Default);
|
||||||
|
let (_out, is_err) = run(&tool, "echo pwned > /etc/cron.d/x").await;
|
||||||
|
assert!(is_err, "writing outside the sandbox must fail");
|
||||||
|
// And the host file must not exist.
|
||||||
|
assert!(!Path::new("/etc/cron.d/x").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn default_mode_blocks_network() {
|
||||||
|
if !bwrap_available() {
|
||||||
|
eprintln!("SKIP: bwrap not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let tool = SandboxedBashTool::new(tmp_session(), PermissionMode::Default);
|
||||||
|
let (out, _) = run(&tool, "getent hosts example.com || echo NET-BLOCKED").await;
|
||||||
|
assert!(out.contains("NET-BLOCKED"), "network should be unshared: {out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn default_mode_allows_work_inside_sandbox() {
|
||||||
|
if !bwrap_available() {
|
||||||
|
eprintln!("SKIP: bwrap not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let sd = tmp_session();
|
||||||
|
let tool = SandboxedBashTool::new(sd.clone(), PermissionMode::Default);
|
||||||
|
let (out, is_err) = run(&tool, "echo hello > note.txt && cat note.txt").await;
|
||||||
|
assert!(!is_err, "in-sandbox work should succeed: {out}");
|
||||||
|
assert!(out.contains("hello"));
|
||||||
|
// The file lands in the real session dir (bound rw).
|
||||||
|
assert!(sd.join("note.txt").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_access_mode_is_unconfined() {
|
||||||
|
// FullAccess is the explicitly-dangerous mode: it should NOT be sandboxed,
|
||||||
|
// so it can read the host (this documents the intended escape hatch).
|
||||||
|
let tool = SandboxedBashTool::new(tmp_session(), PermissionMode::FullAccess);
|
||||||
|
let (out, _) = run(&tool, "test -e /etc && echo HOST-VISIBLE || echo no").await;
|
||||||
|
assert!(out.contains("HOST-VISIBLE"), "full access should see host: {out}");
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
//! Integration test for the dangling-symlink write-escape fix.
|
||||||
|
//!
|
||||||
|
//! Runs against the library's public API so it compiles/executes independently
|
||||||
|
//! of unrelated in-crate test fixtures. Reproduces the concrete escape an
|
||||||
|
//! approved `ln -s` could otherwise open: a symlink whose target does not exist
|
||||||
|
//! yet (so `exists()` is false and `canonicalize()` fails) must still be
|
||||||
|
//! rejected when it points outside the per-session sandbox, instead of being
|
||||||
|
//! treated as a brand-new in-sandbox path that a Write would follow out.
|
||||||
|
|
||||||
|
#![cfg(unix)]
|
||||||
|
|
||||||
|
use boosapi::services::agent_workbench::sandbox::{check_tool_paths, validate_path_within_cwd};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn tmp_sandbox() -> PathBuf {
|
||||||
|
let base = std::env::temp_dir().join(format!("agent_it_sb_{}", uuid::Uuid::new_v4().simple()));
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
base.canonicalize().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dangling_symlink_to_absolute_target_is_rejected() {
|
||||||
|
let sb = tmp_sandbox();
|
||||||
|
symlink("/etc/cron.d/pwn_it", sb.join("evil")).unwrap();
|
||||||
|
let res = validate_path_within_cwd("evil", &sb);
|
||||||
|
assert!(res.is_err(), "expected rejection, got {res:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dangling_relative_symlink_escape_is_rejected() {
|
||||||
|
let sb = tmp_sandbox();
|
||||||
|
symlink("../../escape_it", sb.join("evil")).unwrap();
|
||||||
|
assert!(validate_path_within_cwd("evil", &sb).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_tool_through_dangling_symlink_is_blocked() {
|
||||||
|
let sb = tmp_sandbox();
|
||||||
|
symlink("/etc/cron.d/pwn_it2", sb.join("evil")).unwrap();
|
||||||
|
let input = json!({ "path": "evil", "content": "* * * * * root sh -c id" });
|
||||||
|
assert!(check_tool_paths("write", &input, &sb).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_through_intermediate_dangling_symlink_dir_is_blocked() {
|
||||||
|
let sb = tmp_sandbox();
|
||||||
|
symlink("/etc/nonexistent_it_dir", sb.join("evildir")).unwrap();
|
||||||
|
let input = json!({ "path": "evildir/pwn", "content": "x" });
|
||||||
|
assert!(check_tool_paths("write", &input, &sb).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dangling_symlink_pointing_inside_sandbox_is_allowed() {
|
||||||
|
let sb = tmp_sandbox();
|
||||||
|
symlink(sb.join("future_file"), sb.join("ok_link")).unwrap();
|
||||||
|
assert!(validate_path_within_cwd("ok_link", &sb).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ordinary_new_file_still_allowed() {
|
||||||
|
let sb = tmp_sandbox();
|
||||||
|
assert!(validate_path_within_cwd("notes/today.md", &sb).is_ok());
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
//! TOCTOU analysis evidence for the agent sandbox.
|
||||||
|
//!
|
||||||
|
//! The file tools (Read/Write/Edit) open paths *inside the SDK process*, after
|
||||||
|
//! BoosAPI's `can_use_tool` callback has validated the path. That leaves a
|
||||||
|
//! check-vs-open window which, in the abstract, a concurrent writer could use to
|
||||||
|
//! swap a validated name for a symlink that escapes the sandbox.
|
||||||
|
//!
|
||||||
|
//! Closing that window atomically would require replacing the SDK's in-process
|
||||||
|
//! file tools with `openat2(RESOLVE_BENEATH)` implementations (tracked as
|
||||||
|
//! follow-up). What this test pins down is whether the window is *reachable* at
|
||||||
|
//! all under the current execution model: an exploit needs a second, concurrent
|
||||||
|
//! mutator, and the only agent-controlled way to get concurrency is a
|
||||||
|
//! backgrounded process. Bash now runs under bwrap with `--unshare-all`, which
|
||||||
|
//! includes a PID namespace; the command is PID 1 of that namespace, so when it
|
||||||
|
//! exits the kernel reaps every other process in the namespace — a backgrounded
|
||||||
|
//! racer cannot outlive the single bash call that spawned it. Combined with
|
||||||
|
//! sequential tool execution and per-session bind isolation, there is no
|
||||||
|
//! surviving concurrent mutator to win the race.
|
||||||
|
//!
|
||||||
|
//! This test proves the reaping property empirically (it is the load-bearing
|
||||||
|
//! fact); it does not claim the in-process open is atomic.
|
||||||
|
|
||||||
|
#![cfg(unix)]
|
||||||
|
|
||||||
|
use boosapi::services::agent_workbench::bash_sandbox::SandboxedBashTool;
|
||||||
|
use boosapi::services::agent_workbench::permission::PermissionMode;
|
||||||
|
use open_agent_sdk::types::{Tool, ToolUseContext};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn bwrap_available() -> bool {
|
||||||
|
["/usr/bin/bwrap", "/bin/bwrap", "/usr/local/bin/bwrap"]
|
||||||
|
.iter()
|
||||||
|
.any(|p| Path::new(p).is_file())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tmp_session() -> PathBuf {
|
||||||
|
let base =
|
||||||
|
std::env::temp_dir().join(format!("agent_toctou_{}", uuid::Uuid::new_v4().simple()));
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
base.canonicalize().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A backgrounded process spawned by a sandboxed bash call must not survive the
|
||||||
|
/// call: bwrap's PID namespace reaps it when the shell (PID 1) exits. This is
|
||||||
|
/// what removes the concurrent mutator that a file-tool TOCTOU would need.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn backgrounded_process_does_not_outlive_bash_call() {
|
||||||
|
if !bwrap_available() {
|
||||||
|
eprintln!("SKIP: bwrap not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let sd = tmp_session();
|
||||||
|
let tool = SandboxedBashTool::new(sd.clone(), PermissionMode::Default);
|
||||||
|
let ctx = ToolUseContext::new(std::env::temp_dir().to_string_lossy().to_string());
|
||||||
|
|
||||||
|
// Background a writer that would keep appending for ~2s if it survived,
|
||||||
|
// then let the foreground shell exit immediately.
|
||||||
|
let cmd = "( for i in $(seq 1 200); do echo x >> racer_marker; sleep 0.01; done ) & \
|
||||||
|
echo backgrounded";
|
||||||
|
let res = tool.call(json!({ "command": cmd }), &ctx).await.unwrap();
|
||||||
|
assert!(!res.is_error, "spawn call should succeed: {}", res.get_text());
|
||||||
|
|
||||||
|
let marker = sd.join("racer_marker");
|
||||||
|
let count_now = std::fs::read_to_string(&marker)
|
||||||
|
.map(|s| s.lines().count())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
// Wait well past the racer's would-be lifetime.
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(1200)).await;
|
||||||
|
|
||||||
|
let count_later = std::fs::read_to_string(&marker)
|
||||||
|
.map(|s| s.lines().count())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
count_now, count_later,
|
||||||
|
"backgrounded racer kept writing after the bash call returned \
|
||||||
|
(count {count_now} -> {count_later}); the PID namespace did not reap it, \
|
||||||
|
so a concurrent mutator could exist"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user