Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3293e007f | |||
| a1b7b60ad4 |
@@ -22,12 +22,3 @@
|
|||||||
# 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"
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -31,12 +31,3 @@ boosapi.db-shm
|
|||||||
boosapi.db-wal
|
boosapi.db-wal
|
||||||
.tours/
|
.tours/
|
||||||
bun.lock
|
bun.lock
|
||||||
|
|
||||||
# Embedded desktop subproject (BoosAPI Desktop) — generated artifacts only.
|
|
||||||
# Source lives in desktop/; these are build outputs that must never be tracked.
|
|
||||||
desktop/node_modules/
|
|
||||||
desktop/dist/
|
|
||||||
desktop/target/
|
|
||||||
desktop/src-tauri/target/
|
|
||||||
desktop/src-tauri/gen/schemas/
|
|
||||||
desktop/website/node_modules/
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
# Developer identity (local only)
|
|
||||||
.developer
|
|
||||||
|
|
||||||
# Current task pointer (each dev works on different task)
|
|
||||||
.current-task
|
|
||||||
|
|
||||||
# Session/window scoped runtime state
|
|
||||||
.runtime/
|
|
||||||
|
|
||||||
# Ralph Loop state file
|
|
||||||
.ralph-state.json
|
|
||||||
|
|
||||||
# Agent runtime files
|
|
||||||
.agents/
|
|
||||||
.agent-log
|
|
||||||
.session-id
|
|
||||||
|
|
||||||
# Task directory runtime files
|
|
||||||
.plan-log
|
|
||||||
|
|
||||||
# Atomic update temp files
|
|
||||||
*.tmp
|
|
||||||
|
|
||||||
# Update backup directories
|
|
||||||
.backup-*
|
|
||||||
|
|
||||||
# Conflict resolution temp files
|
|
||||||
*.new
|
|
||||||
|
|
||||||
# Python cache
|
|
||||||
**/__pycache__/
|
|
||||||
**/*.pyc
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
{
|
|
||||||
"__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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
0.6.3
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
```
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
---
|
|
||||||
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>
|
|
||||||
```
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
# Trellis Configuration
|
|
||||||
# Project-level settings for the Trellis workflow system
|
|
||||||
#
|
|
||||||
# All values have sensible defaults. Only override what you need.
|
|
||||||
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
# Session Recording
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Commit message used when auto-committing journal/index changes
|
|
||||||
# after running add_session.py
|
|
||||||
session_commit_message: "chore: record journal"
|
|
||||||
|
|
||||||
# Maximum lines per journal file before rotating to a new one
|
|
||||||
max_journal_lines: 2000
|
|
||||||
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
# Session Auto-Commit
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Auto-commit behavior for session journal + task archive operations.
|
|
||||||
# - true (default): scripts auto-stage and auto-commit journal / task changes
|
|
||||||
# after add_session.py / task.py archive runs.
|
|
||||||
# - false: scripts do not touch git. Files (journal-*.md, task archive moves)
|
|
||||||
# are still written to disk; you decide whether to git add / commit.
|
|
||||||
#
|
|
||||||
# Use `false` if your project's .gitignore intentionally excludes `.trellis/`
|
|
||||||
# and you want session data kept local-only, or if you prefer to review
|
|
||||||
# staged changes manually before each commit.
|
|
||||||
#
|
|
||||||
# Accepts: true / false / yes / no / 1 / 0 / on / off (case-insensitive).
|
|
||||||
#
|
|
||||||
# session_auto_commit: true
|
|
||||||
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
# Task Lifecycle Hooks
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Shell commands to run after task lifecycle events.
|
|
||||||
# Each hook receives TASK_JSON_PATH environment variable pointing to task.json.
|
|
||||||
# Hook failures print a warning but do not block the main operation.
|
|
||||||
#
|
|
||||||
# hooks:
|
|
||||||
# after_create:
|
|
||||||
# - "echo 'Task created'"
|
|
||||||
# after_start:
|
|
||||||
# - "echo 'Task started'"
|
|
||||||
# after_finish:
|
|
||||||
# - "echo 'Task finished'"
|
|
||||||
# after_archive:
|
|
||||||
# - "echo 'Task archived'"
|
|
||||||
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
# Monorepo / Packages
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Declare packages for monorepo projects.
|
|
||||||
# Trellis auto-detects workspaces during `trellis init`, but you can also
|
|
||||||
# configure them manually here.
|
|
||||||
#
|
|
||||||
# packages:
|
|
||||||
# frontend:
|
|
||||||
# path: packages/frontend
|
|
||||||
# backend:
|
|
||||||
# path: packages/backend
|
|
||||||
# docs:
|
|
||||||
# path: docs-site
|
|
||||||
# type: submodule
|
|
||||||
# # For polyrepo / meta-repo layouts (independent .git in each subdir),
|
|
||||||
# # mark the package with `git: true`. The runtime treats it as an
|
|
||||||
# # independent repository for things like git-context display.
|
|
||||||
# webapp:
|
|
||||||
# path: ./webapp
|
|
||||||
# git: true
|
|
||||||
|
|
||||||
# Default package used when --package is not specified.
|
|
||||||
# default_package: frontend
|
|
||||||
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
# 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
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
"""
|
|
||||||
Trellis Python Scripts
|
|
||||||
|
|
||||||
This module provides Python implementations of Trellis workflow scripts.
|
|
||||||
"""
|
|
||||||
@@ -1,567 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
"""
|
|
||||||
Common utilities for Trellis workflow scripts.
|
|
||||||
|
|
||||||
This module provides shared functionality used by other Trellis scripts.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import io
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Windows Encoding Fix (MUST be at top, before any other output)
|
|
||||||
# =============================================================================
|
|
||||||
# On Windows, stdout defaults to the system code page (often GBK/CP936).
|
|
||||||
# This causes UnicodeEncodeError when printing non-ASCII characters.
|
|
||||||
#
|
|
||||||
# Any script that imports from common will automatically get this fix.
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def _configure_stream(stream: object) -> object:
|
|
||||||
"""Configure a stream for UTF-8 encoding on Windows."""
|
|
||||||
# Try reconfigure() first (Python 3.7+, more reliable)
|
|
||||||
if hasattr(stream, "reconfigure"):
|
|
||||||
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
|
||||||
return stream
|
|
||||||
# Fallback: detach and rewrap with TextIOWrapper
|
|
||||||
elif hasattr(stream, "detach"):
|
|
||||||
return io.TextIOWrapper(
|
|
||||||
stream.detach(), # type: ignore[union-attr]
|
|
||||||
encoding="utf-8",
|
|
||||||
errors="replace",
|
|
||||||
)
|
|
||||||
return stream
|
|
||||||
|
|
||||||
|
|
||||||
if sys.platform == "win32":
|
|
||||||
sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment]
|
|
||||||
sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment]
|
|
||||||
sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment]
|
|
||||||
|
|
||||||
|
|
||||||
def configure_encoding() -> None:
|
|
||||||
"""
|
|
||||||
Configure stdout/stderr/stdin for UTF-8 encoding on Windows.
|
|
||||||
|
|
||||||
This is automatically called when importing from common,
|
|
||||||
but can be called manually for scripts that don't import common.
|
|
||||||
|
|
||||||
Safe to call multiple times.
|
|
||||||
"""
|
|
||||||
global sys
|
|
||||||
if sys.platform == "win32":
|
|
||||||
sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment]
|
|
||||||
sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment]
|
|
||||||
sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment]
|
|
||||||
|
|
||||||
|
|
||||||
from .paths import (
|
|
||||||
DIR_WORKFLOW,
|
|
||||||
DIR_WORKSPACE,
|
|
||||||
DIR_TASKS,
|
|
||||||
DIR_ARCHIVE,
|
|
||||||
DIR_SPEC,
|
|
||||||
DIR_SCRIPTS,
|
|
||||||
FILE_DEVELOPER,
|
|
||||||
FILE_CURRENT_TASK,
|
|
||||||
FILE_TASK_JSON,
|
|
||||||
FILE_JOURNAL_PREFIX,
|
|
||||||
get_repo_root,
|
|
||||||
get_developer,
|
|
||||||
check_developer,
|
|
||||||
get_tasks_dir,
|
|
||||||
get_workspace_dir,
|
|
||||||
get_active_journal_file,
|
|
||||||
count_lines,
|
|
||||||
get_current_task,
|
|
||||||
get_current_task_abs,
|
|
||||||
normalize_task_ref,
|
|
||||||
resolve_task_ref,
|
|
||||||
set_current_task,
|
|
||||||
clear_current_task,
|
|
||||||
has_current_task,
|
|
||||||
generate_task_date_prefix,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .active_task import (
|
|
||||||
ActiveTask,
|
|
||||||
clear_active_task,
|
|
||||||
resolve_active_task,
|
|
||||||
resolve_context_key,
|
|
||||||
set_active_task,
|
|
||||||
)
|
|
||||||
@@ -1,626 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Session-scoped active task resolution.
|
|
||||||
|
|
||||||
The user-facing concept is a single "active task". Trellis stores that pointer
|
|
||||||
per AI session/window under `.trellis/.runtime/sessions/`; without a stable
|
|
||||||
session key there is no active task.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
DIR_WORKFLOW = ".trellis"
|
|
||||||
DIR_TASKS = "tasks"
|
|
||||||
DIR_RUNTIME = ".runtime"
|
|
||||||
DIR_SESSIONS = "sessions"
|
|
||||||
DIR_CURSOR_SHELL = "cursor-shell"
|
|
||||||
CURSOR_SHELL_TICKET_TTL_SECONDS = 30
|
|
||||||
TASK_SESSION_COMMANDS = {"start", "current", "finish"}
|
|
||||||
|
|
||||||
_SESSION_KEYS = ("session_id", "sessionId", "sessionID")
|
|
||||||
_CONVERSATION_KEYS = ("conversation_id", "conversationId", "conversationID")
|
|
||||||
_TRANSCRIPT_KEYS = ("transcript_path", "transcriptPath", "transcript")
|
|
||||||
_NESTED_KEYS = ("input", "properties", "event", "hook_input", "hookInput")
|
|
||||||
_KNOWN_PLATFORMS = {
|
|
||||||
"claude",
|
|
||||||
"codex",
|
|
||||||
"cursor",
|
|
||||||
"opencode",
|
|
||||||
"gemini",
|
|
||||||
"droid",
|
|
||||||
"qoder",
|
|
||||||
"codebuddy",
|
|
||||||
"kiro",
|
|
||||||
"copilot",
|
|
||||||
"pi",
|
|
||||||
}
|
|
||||||
|
|
||||||
_ENV_SESSION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
||||||
("claude", ("CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID")),
|
|
||||||
("codex", ("CODEX_SESSION_ID", "CODEX_THREAD_ID")),
|
|
||||||
("cursor", ("CURSOR_SESSION_ID",)),
|
|
||||||
("opencode", ("OPENCODE_SESSION_ID", "OPENCODE_SESSIONID", "OPENCODE_RUN_ID")),
|
|
||||||
("gemini", ("GEMINI_SESSION_ID",)),
|
|
||||||
("droid", ("FACTORY_SESSION_ID", "DROID_SESSION_ID")),
|
|
||||||
("qoder", ("QODER_SESSION_ID",)),
|
|
||||||
("codebuddy", ("CODEBUDDY_SESSION_ID",)),
|
|
||||||
("kiro", ("KIRO_SESSION_ID",)),
|
|
||||||
("copilot", ("COPILOT_SESSION_ID", "COPILOT_SESSIONID")),
|
|
||||||
("pi", ("PI_SESSION_ID", "PI_SESSIONID")),
|
|
||||||
)
|
|
||||||
_ENV_CONVERSATION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
||||||
("cursor", ("CURSOR_CONVERSATION_ID", "CURSOR_CONVERSATIONID")),
|
|
||||||
)
|
|
||||||
_ENV_TRANSCRIPT_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
||||||
("claude", ("CLAUDE_TRANSCRIPT_PATH",)),
|
|
||||||
("codex", ("CODEX_TRANSCRIPT_PATH",)),
|
|
||||||
("cursor", ("CURSOR_TRANSCRIPT_PATH",)),
|
|
||||||
("gemini", ("GEMINI_TRANSCRIPT_PATH",)),
|
|
||||||
("droid", ("FACTORY_TRANSCRIPT_PATH", "DROID_TRANSCRIPT_PATH")),
|
|
||||||
("qoder", ("QODER_TRANSCRIPT_PATH",)),
|
|
||||||
("codebuddy", ("CODEBUDDY_TRANSCRIPT_PATH",)),
|
|
||||||
)
|
|
||||||
_ENV_PLATFORM_ALIASES = {
|
|
||||||
"claude-code": "claude",
|
|
||||||
"factory": "droid",
|
|
||||||
"factory-ai": "droid",
|
|
||||||
"github-copilot": "copilot",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ActiveTask:
|
|
||||||
"""Resolved active task state."""
|
|
||||||
|
|
||||||
task_path: str | None
|
|
||||||
source_type: str
|
|
||||||
context_key: str | None = None
|
|
||||||
stale: bool = False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def source(self) -> str:
|
|
||||||
"""Human-readable source label."""
|
|
||||||
if self.source_type == "session" and self.context_key:
|
|
||||||
return f"session:{self.context_key}"
|
|
||||||
if self.source_type == "session-fallback" and self.context_key:
|
|
||||||
return f"session-fallback:{self.context_key}"
|
|
||||||
return self.source_type
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_task_ref(task_ref: str) -> str:
|
|
||||||
"""Normalize a task ref for stable storage and comparison."""
|
|
||||||
normalized = task_ref.strip()
|
|
||||||
if not normalized:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
path_obj = Path(normalized)
|
|
||||||
if path_obj.is_absolute():
|
|
||||||
return str(path_obj)
|
|
||||||
|
|
||||||
normalized = normalized.replace("\\", "/")
|
|
||||||
while normalized.startswith("./"):
|
|
||||||
normalized = normalized[2:]
|
|
||||||
|
|
||||||
if normalized.startswith(f"{DIR_TASKS}/"):
|
|
||||||
return f"{DIR_WORKFLOW}/{normalized}"
|
|
||||||
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_task_ref(task_ref: str, repo_root: Path) -> Path | None:
|
|
||||||
"""Resolve a task ref to an absolute task directory."""
|
|
||||||
normalized = normalize_task_ref(task_ref)
|
|
||||||
if not normalized:
|
|
||||||
return None
|
|
||||||
|
|
||||||
path_obj = Path(normalized)
|
|
||||||
if path_obj.is_absolute():
|
|
||||||
return path_obj
|
|
||||||
|
|
||||||
if normalized.startswith(f"{DIR_WORKFLOW}/"):
|
|
||||||
return repo_root / path_obj
|
|
||||||
|
|
||||||
return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj
|
|
||||||
|
|
||||||
|
|
||||||
def _runtime_sessions_dir(repo_root: Path) -> Path:
|
|
||||||
return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_SESSIONS
|
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_key(raw: str) -> str:
|
|
||||||
safe = re.sub(r"[^A-Za-z0-9._-]+", "_", raw.strip())
|
|
||||||
safe = safe.strip("._-")
|
|
||||||
return safe[:160] if safe else ""
|
|
||||||
|
|
||||||
|
|
||||||
def _hash_value(raw: str) -> str:
|
|
||||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24]
|
|
||||||
|
|
||||||
|
|
||||||
def _as_dict(value: Any) -> dict[str, Any] | None:
|
|
||||||
return value if isinstance(value, dict) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _string_value(value: Any) -> str | None:
|
|
||||||
if isinstance(value, str):
|
|
||||||
stripped = value.strip()
|
|
||||||
return stripped or None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _lookup_string(data: dict[str, Any], keys: tuple[str, ...]) -> str | None:
|
|
||||||
for key in keys:
|
|
||||||
value = _string_value(data.get(key))
|
|
||||||
if value:
|
|
||||||
return value
|
|
||||||
|
|
||||||
for nested_key in _NESTED_KEYS:
|
|
||||||
nested = _as_dict(data.get(nested_key))
|
|
||||||
if not nested:
|
|
||||||
continue
|
|
||||||
value = _lookup_string(nested, keys)
|
|
||||||
if value:
|
|
||||||
return value
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_platform(platform_input: dict[str, Any] | None, platform: str | None) -> str:
|
|
||||||
if platform:
|
|
||||||
return _sanitize_key(platform) or "session"
|
|
||||||
if platform_input:
|
|
||||||
for key in ("_trellis_platform", "trellis_platform", "platform", "source"):
|
|
||||||
value = _string_value(platform_input.get(key))
|
|
||||||
if value:
|
|
||||||
return _sanitize_key(value) or "session"
|
|
||||||
if _string_value(platform_input.get("cursor_version")):
|
|
||||||
return "cursor"
|
|
||||||
return "session"
|
|
||||||
|
|
||||||
|
|
||||||
def _context_key(platform_name: str, kind: str, value: str) -> str:
|
|
||||||
if kind == "transcript":
|
|
||||||
return f"{platform_name}_transcript_{_hash_value(value)}"
|
|
||||||
safe_value = _sanitize_key(value)
|
|
||||||
if safe_value:
|
|
||||||
return f"{platform_name}_{safe_value}"
|
|
||||||
return f"{platform_name}_{_hash_value(value)}"
|
|
||||||
|
|
||||||
|
|
||||||
def _iter_env_keys(
|
|
||||||
env_keys: tuple[tuple[str, tuple[str, ...]], ...],
|
|
||||||
platform_name: str | None,
|
|
||||||
) -> tuple[tuple[str, tuple[str, ...]], ...]:
|
|
||||||
if not platform_name:
|
|
||||||
return env_keys
|
|
||||||
matched = tuple((name, keys) for name, keys in env_keys if name == platform_name)
|
|
||||||
return matched
|
|
||||||
|
|
||||||
|
|
||||||
def _env_platform_name(platform_name: str | None) -> str | None:
|
|
||||||
if not platform_name or platform_name == "session":
|
|
||||||
return None
|
|
||||||
return _ENV_PLATFORM_ALIASES.get(platform_name, platform_name)
|
|
||||||
|
|
||||||
|
|
||||||
def _lookup_env_context_key(platform_name: str | None) -> str | None:
|
|
||||||
"""Resolve a context key from platform-provided environment variables.
|
|
||||||
|
|
||||||
Hooks pass `TRELLIS_CONTEXT_ID` to subprocesses they launch, but an AI-run
|
|
||||||
shell command can only see session identity if the host platform exports it
|
|
||||||
in the command environment. These names are best-effort adapters; if none
|
|
||||||
are present, there is no session-scoped active task.
|
|
||||||
"""
|
|
||||||
env_platform_name = _env_platform_name(platform_name)
|
|
||||||
|
|
||||||
for name, keys in _iter_env_keys(_ENV_SESSION_KEYS, env_platform_name):
|
|
||||||
for key in keys:
|
|
||||||
value = _string_value(os.environ.get(key))
|
|
||||||
if value:
|
|
||||||
return _context_key(name, "session", value)
|
|
||||||
|
|
||||||
for name, keys in _iter_env_keys(_ENV_CONVERSATION_KEYS, env_platform_name):
|
|
||||||
for key in keys:
|
|
||||||
value = _string_value(os.environ.get(key))
|
|
||||||
if value:
|
|
||||||
return _context_key(name, "conversation", value)
|
|
||||||
|
|
||||||
for name, keys in _iter_env_keys(_ENV_TRANSCRIPT_KEYS, env_platform_name):
|
|
||||||
for key in keys:
|
|
||||||
value = _string_value(os.environ.get(key))
|
|
||||||
if value:
|
|
||||||
return _context_key(name, "transcript", value)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _find_repo_root_from_cwd() -> Path | None:
|
|
||||||
current = Path.cwd().resolve()
|
|
||||||
while True:
|
|
||||||
if (current / DIR_WORKFLOW).is_dir():
|
|
||||||
return current
|
|
||||||
if current == current.parent:
|
|
||||||
return None
|
|
||||||
current = current.parent
|
|
||||||
|
|
||||||
|
|
||||||
def _cursor_shell_ticket_dir(repo_root: Path) -> Path:
|
|
||||||
return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_CURSOR_SHELL
|
|
||||||
|
|
||||||
|
|
||||||
def _remove_file(path: Path) -> bool:
|
|
||||||
try:
|
|
||||||
path.unlink()
|
|
||||||
return True
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _task_refs_match(left: str | None, right: str | None, repo_root: Path) -> bool:
|
|
||||||
if not left or not right:
|
|
||||||
return False
|
|
||||||
left_path = resolve_task_ref(left, repo_root)
|
|
||||||
right_path = resolve_task_ref(right, repo_root)
|
|
||||||
if left_path is not None and right_path is not None:
|
|
||||||
return left_path == right_path
|
|
||||||
return normalize_task_ref(left) == normalize_task_ref(right)
|
|
||||||
|
|
||||||
|
|
||||||
def _pending_ticket_matches_args(ticket: dict[str, Any], repo_root: Path) -> bool:
|
|
||||||
if Path(sys.argv[0]).name != "task.py":
|
|
||||||
return False
|
|
||||||
args = tuple(sys.argv[1:])
|
|
||||||
if not args:
|
|
||||||
return False
|
|
||||||
|
|
||||||
command_name = args[0]
|
|
||||||
if command_name not in TASK_SESSION_COMMANDS:
|
|
||||||
return False
|
|
||||||
|
|
||||||
subcommands = ticket.get("subcommands")
|
|
||||||
if not isinstance(subcommands, list):
|
|
||||||
return False
|
|
||||||
|
|
||||||
for subcommand in subcommands:
|
|
||||||
if not isinstance(subcommand, dict):
|
|
||||||
continue
|
|
||||||
if _string_value(subcommand.get("name")) != command_name:
|
|
||||||
continue
|
|
||||||
if command_name != "start":
|
|
||||||
return True
|
|
||||||
task_ref = args[1] if len(args) > 1 else None
|
|
||||||
if _task_refs_match(_string_value(subcommand.get("task_ref")), task_ref, repo_root):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _ticket_is_fresh(ticket: dict[str, Any], ticket_path: Path, now: float) -> bool:
|
|
||||||
expires_at = ticket.get("expires_at_epoch")
|
|
||||||
if isinstance(expires_at, (int, float)) and expires_at < now:
|
|
||||||
_remove_file(ticket_path)
|
|
||||||
return False
|
|
||||||
|
|
||||||
created_at = ticket.get("created_at_epoch")
|
|
||||||
if isinstance(created_at, (int, float)):
|
|
||||||
if now - created_at <= CURSOR_SHELL_TICKET_TTL_SECONDS:
|
|
||||||
return True
|
|
||||||
_remove_file(ticket_path)
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _ticket_cwd_matches_repo(ticket: dict[str, Any], repo_root: Path) -> bool:
|
|
||||||
cwd = _string_value(ticket.get("cwd"))
|
|
||||||
if not cwd:
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
Path(cwd).resolve().relative_to(repo_root)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _matching_cursor_ticket_context_key(
|
|
||||||
ticket_path: Path,
|
|
||||||
repo_root: Path,
|
|
||||||
now: float,
|
|
||||||
) -> str | None:
|
|
||||||
ticket = _read_json(ticket_path)
|
|
||||||
if ticket is None or ticket.get("platform") != "cursor":
|
|
||||||
return None
|
|
||||||
if not _ticket_is_fresh(ticket, ticket_path, now):
|
|
||||||
return None
|
|
||||||
if not _ticket_cwd_matches_repo(ticket, repo_root):
|
|
||||||
return None
|
|
||||||
if not _pending_ticket_matches_args(ticket, repo_root):
|
|
||||||
return None
|
|
||||||
return _string_value(ticket.get("context_key"))
|
|
||||||
|
|
||||||
|
|
||||||
def _lookup_cursor_shell_ticket_context_key() -> str | None:
|
|
||||||
"""Resolve Cursor conversation identity from a short-lived shell ticket.
|
|
||||||
|
|
||||||
Cursor exposes `conversation_id` to `beforeShellExecution`, but does not
|
|
||||||
export it into the shell command environment. The Cursor hook writes a
|
|
||||||
short-lived ticket just before `task.py` runs. We accept a ticket only when
|
|
||||||
the current `task.py` subcommand matches and exactly one fresh context key
|
|
||||||
matches, which avoids cross-window pointer contamination.
|
|
||||||
"""
|
|
||||||
repo_root = _find_repo_root_from_cwd()
|
|
||||||
if repo_root is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
ticket_dir = _cursor_shell_ticket_dir(repo_root)
|
|
||||||
if not ticket_dir.is_dir():
|
|
||||||
return None
|
|
||||||
|
|
||||||
now = time.time()
|
|
||||||
candidates: set[str] = set()
|
|
||||||
for ticket_path in ticket_dir.glob("*.json"):
|
|
||||||
context_key = _matching_cursor_ticket_context_key(ticket_path, repo_root, now)
|
|
||||||
if context_key:
|
|
||||||
candidates.add(context_key)
|
|
||||||
|
|
||||||
if len(candidates) == 1:
|
|
||||||
return next(iter(candidates))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_context_key(
|
|
||||||
platform_input: dict[str, Any] | None = None,
|
|
||||||
platform: str | None = None,
|
|
||||||
) -> str | None:
|
|
||||||
"""Resolve a stable session/window context key, if one is available.
|
|
||||||
|
|
||||||
`TRELLIS_CONTEXT_ID` is an explicit context-key override used by CLI
|
|
||||||
scripts and subprocesses. It does not store the task itself.
|
|
||||||
"""
|
|
||||||
override = _string_value(os.environ.get("TRELLIS_CONTEXT_ID"))
|
|
||||||
if override:
|
|
||||||
return _sanitize_key(override) or _hash_value(override)
|
|
||||||
|
|
||||||
data = _as_dict(platform_input)
|
|
||||||
platform_name = _detect_platform(data, platform) if data or platform else None
|
|
||||||
|
|
||||||
if data:
|
|
||||||
session_id = _lookup_string(data, _SESSION_KEYS)
|
|
||||||
if session_id:
|
|
||||||
return _context_key(platform_name or "session", "session", session_id)
|
|
||||||
|
|
||||||
conversation_id = _lookup_string(data, _CONVERSATION_KEYS)
|
|
||||||
if conversation_id:
|
|
||||||
return _context_key(platform_name or "session", "conversation", conversation_id)
|
|
||||||
|
|
||||||
transcript_path = _lookup_string(data, _TRANSCRIPT_KEYS)
|
|
||||||
if transcript_path:
|
|
||||||
return _context_key(platform_name or "session", "transcript", transcript_path)
|
|
||||||
|
|
||||||
env_context_key = _lookup_env_context_key(platform_name)
|
|
||||||
if env_context_key:
|
|
||||||
return env_context_key
|
|
||||||
|
|
||||||
if platform_name in (None, "session", "cursor"):
|
|
||||||
return _lookup_cursor_shell_ticket_context_key()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _read_json(path: Path) -> dict[str, Any] | None:
|
|
||||||
try:
|
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
||||||
return None
|
|
||||||
return data if isinstance(data, dict) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _write_json(path: Path, data: dict[str, Any]) -> bool:
|
|
||||||
try:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text(
|
|
||||||
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical_task_ref(task_path: str, repo_root: Path) -> str | None:
|
|
||||||
normalized = normalize_task_ref(task_path)
|
|
||||||
if not normalized:
|
|
||||||
return None
|
|
||||||
full_path = resolve_task_ref(normalized, repo_root)
|
|
||||||
if full_path is None or not full_path.is_dir():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return full_path.relative_to(repo_root).as_posix()
|
|
||||||
except ValueError:
|
|
||||||
return str(full_path)
|
|
||||||
|
|
||||||
|
|
||||||
def _active_from_ref(
|
|
||||||
task_ref: str | None,
|
|
||||||
repo_root: Path,
|
|
||||||
source_type: str,
|
|
||||||
context_key: str | None = None,
|
|
||||||
) -> ActiveTask | None:
|
|
||||||
if not task_ref:
|
|
||||||
return None
|
|
||||||
resolved = resolve_task_ref(task_ref, repo_root)
|
|
||||||
stale = resolved is None or not resolved.is_dir()
|
|
||||||
return ActiveTask(task_ref, source_type, context_key, stale)
|
|
||||||
|
|
||||||
|
|
||||||
def _context_path(repo_root: Path, context_key: str) -> Path:
|
|
||||||
return _runtime_sessions_dir(repo_root) / f"{context_key}.json"
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_active_task(
|
|
||||||
repo_root: Path,
|
|
||||||
platform_input: dict[str, Any] | None = None,
|
|
||||||
platform: str | None = None,
|
|
||||||
) -> ActiveTask:
|
|
||||||
"""Resolve the active task from session runtime state only.
|
|
||||||
|
|
||||||
A stale session task is returned as stale. Missing context identity or a
|
|
||||||
missing/empty session context falls back to single-session inference: if
|
|
||||||
exactly one session file exists in the runtime, return its task with
|
|
||||||
source_type="session-fallback" — covers class-2 platform sub-agents (codex,
|
|
||||||
copilot, gemini, qoder) that don't inherit the parent's session id. ≥2
|
|
||||||
files or 0 files yield ActiveTask(None) — refuses to guess across windows.
|
|
||||||
"""
|
|
||||||
context_key = resolve_context_key(platform_input, platform)
|
|
||||||
if context_key:
|
|
||||||
context = _read_json(_context_path(repo_root, context_key)) or {}
|
|
||||||
task_ref = _string_value(context.get("current_task"))
|
|
||||||
active = _active_from_ref(task_ref, repo_root, "session", context_key)
|
|
||||||
if active:
|
|
||||||
return active
|
|
||||||
|
|
||||||
fallback = _resolve_single_session_fallback(repo_root)
|
|
||||||
if fallback is not None:
|
|
||||||
return fallback
|
|
||||||
|
|
||||||
return ActiveTask(None, "none", context_key)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_single_session_fallback(repo_root: Path) -> ActiveTask | None:
|
|
||||||
"""Return the task pointed at by the sole session file, if exactly one exists.
|
|
||||||
|
|
||||||
Used when context-key resolution fails (typical for class-2 platform
|
|
||||||
sub-agents). Returns None if 0 or ≥2 session files are present — refuses
|
|
||||||
to pick across windows so 04-21's multi-session isolation contract holds.
|
|
||||||
"""
|
|
||||||
sessions_dir = _runtime_sessions_dir(repo_root)
|
|
||||||
if not sessions_dir.is_dir():
|
|
||||||
return None
|
|
||||||
|
|
||||||
session_files = sorted(sessions_dir.glob("*.json"))
|
|
||||||
if len(session_files) != 1:
|
|
||||||
return None
|
|
||||||
|
|
||||||
session_file = session_files[0]
|
|
||||||
context = _read_json(session_file) or {}
|
|
||||||
task_ref = _string_value(context.get("current_task"))
|
|
||||||
if not task_ref:
|
|
||||||
return None
|
|
||||||
|
|
||||||
fallback_key = session_file.stem
|
|
||||||
return _active_from_ref(task_ref, repo_root, "session-fallback", fallback_key)
|
|
||||||
|
|
||||||
|
|
||||||
def _utc_now() -> str:
|
|
||||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
||||||
|
|
||||||
|
|
||||||
def _context_metadata(
|
|
||||||
platform_input: dict[str, Any] | None,
|
|
||||||
platform: str | None,
|
|
||||||
context_key: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
data = _as_dict(platform_input) or {}
|
|
||||||
platform_name = _detect_platform(data, platform)
|
|
||||||
if platform_name == "session" and context_key:
|
|
||||||
prefix = context_key.split("_", 1)[0]
|
|
||||||
if prefix in _KNOWN_PLATFORMS:
|
|
||||||
platform_name = prefix
|
|
||||||
metadata: dict[str, Any] = {
|
|
||||||
"platform": platform_name,
|
|
||||||
"last_seen_at": _utc_now(),
|
|
||||||
}
|
|
||||||
for key in (*_SESSION_KEYS, *_CONVERSATION_KEYS, *_TRANSCRIPT_KEYS):
|
|
||||||
value = _lookup_string(data, (key,))
|
|
||||||
if value:
|
|
||||||
metadata[key] = value
|
|
||||||
return metadata
|
|
||||||
|
|
||||||
|
|
||||||
def set_active_task(
|
|
||||||
task_path: str,
|
|
||||||
repo_root: Path,
|
|
||||||
platform_input: dict[str, Any] | None = None,
|
|
||||||
platform: str | None = None,
|
|
||||||
) -> ActiveTask | None:
|
|
||||||
"""Set the active task in session scope.
|
|
||||||
|
|
||||||
Returns None when no context key is available; callers should surface a
|
|
||||||
user-facing error that explains how to provide session identity.
|
|
||||||
"""
|
|
||||||
canonical = _canonical_task_ref(task_path, repo_root)
|
|
||||||
if canonical is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
context_key = resolve_context_key(platform_input, platform)
|
|
||||||
if not context_key:
|
|
||||||
return None
|
|
||||||
|
|
||||||
context_path = _context_path(repo_root, context_key)
|
|
||||||
context = _read_json(context_path) or {}
|
|
||||||
context.update(_context_metadata(platform_input, platform, context_key))
|
|
||||||
context["current_task"] = canonical
|
|
||||||
context.setdefault("current_run", None)
|
|
||||||
if not _write_json(context_path, context):
|
|
||||||
return None
|
|
||||||
return ActiveTask(canonical, "session", context_key)
|
|
||||||
|
|
||||||
|
|
||||||
def clear_active_task(
|
|
||||||
repo_root: Path,
|
|
||||||
platform_input: dict[str, Any] | None = None,
|
|
||||||
platform: str | None = None,
|
|
||||||
) -> ActiveTask:
|
|
||||||
"""Clear the active task by deleting the current session context file."""
|
|
||||||
context_key = resolve_context_key(platform_input, platform)
|
|
||||||
if not context_key:
|
|
||||||
return ActiveTask(None, "none")
|
|
||||||
|
|
||||||
previous = resolve_active_task(repo_root, platform_input, platform)
|
|
||||||
context_path = _context_path(repo_root, context_key)
|
|
||||||
if context_path.is_file():
|
|
||||||
_remove_file(context_path)
|
|
||||||
return previous
|
|
||||||
|
|
||||||
|
|
||||||
def clear_task_from_sessions(task_path: str, repo_root: Path) -> int:
|
|
||||||
"""Delete all session runtime files that point at a task."""
|
|
||||||
target = _canonical_task_ref(task_path, repo_root) or normalize_task_ref(task_path)
|
|
||||||
if not target:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
cleared = 0
|
|
||||||
sessions_dir = _runtime_sessions_dir(repo_root)
|
|
||||||
if not sessions_dir.is_dir():
|
|
||||||
return cleared
|
|
||||||
|
|
||||||
for session_path in sessions_dir.glob("*.json"):
|
|
||||||
context = _read_json(session_path) or {}
|
|
||||||
current = _string_value(context.get("current_task"))
|
|
||||||
if not current:
|
|
||||||
continue
|
|
||||||
current_ref = _canonical_task_ref(current, repo_root) or normalize_task_ref(current)
|
|
||||||
if current_ref != target:
|
|
||||||
continue
|
|
||||||
if session_path.is_file() and _remove_file(session_path):
|
|
||||||
cleared += 1
|
|
||||||
|
|
||||||
return cleared
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_task_source(
|
|
||||||
repo_root: Path,
|
|
||||||
platform_input: dict[str, Any] | None = None,
|
|
||||||
platform: str | None = None,
|
|
||||||
) -> tuple[str, str | None, str | None]:
|
|
||||||
"""Return (`source_type`, `context_key`, `task_path`) for compatibility."""
|
|
||||||
active = resolve_active_task(repo_root, platform_input, platform)
|
|
||||||
return active.source_type, active.context_key, active.task_path
|
|
||||||
@@ -1,825 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@@ -1,445 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Trellis configuration reader.
|
|
||||||
|
|
||||||
Reads settings from .trellis/config.yaml with sensible defaults.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .paths import DIR_WORKFLOW, get_repo_root
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# YAML Simple Parser (no dependencies)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def _unquote(s: str) -> str:
|
|
||||||
"""Remove exactly one layer of matching surrounding quotes.
|
|
||||||
|
|
||||||
Unlike str.strip('"'), this only removes the outermost pair,
|
|
||||||
preserving any nested quotes inside the value.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
_unquote('"hello"') -> 'hello'
|
|
||||||
_unquote("'hello'") -> 'hello'
|
|
||||||
_unquote('"echo \\'hi\\'"') -> "echo 'hi'"
|
|
||||||
_unquote('hello') -> 'hello'
|
|
||||||
_unquote('"hello\\'') -> '"hello\\'' (mismatched, unchanged)
|
|
||||||
"""
|
|
||||||
if len(s) >= 2 and s[0] == s[-1] and s[0] in ('"', "'"):
|
|
||||||
return s[1:-1]
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_inline_comment(value: str) -> str:
|
|
||||||
"""Strip ` # …` inline comments while preserving `#` inside quoted strings.
|
|
||||||
|
|
||||||
YAML treats ` #` (space-hash) as a comment opener; bare `#` inside a token
|
|
||||||
is part of the value. Quoted strings are immune.
|
|
||||||
|
|
||||||
Mirrors :func:`common.trellis_config._strip_inline_comment` so both
|
|
||||||
parsers handle ``key: value # comment`` identically.
|
|
||||||
"""
|
|
||||||
in_quote: str | None = None
|
|
||||||
for idx, ch in enumerate(value):
|
|
||||||
if in_quote:
|
|
||||||
if ch == in_quote:
|
|
||||||
in_quote = None
|
|
||||||
continue
|
|
||||||
if ch in ('"', "'"):
|
|
||||||
in_quote = ch
|
|
||||||
continue
|
|
||||||
if ch == "#" and (idx == 0 or value[idx - 1].isspace()):
|
|
||||||
return value[:idx]
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def parse_simple_yaml(content: str) -> dict:
|
|
||||||
"""Parse simple YAML with nested dict support (no dependencies).
|
|
||||||
|
|
||||||
Supports:
|
|
||||||
- key: value (string)
|
|
||||||
- key: (followed by list items)
|
|
||||||
- item1
|
|
||||||
- item2
|
|
||||||
- key: (followed by nested dict)
|
|
||||||
nested_key: value
|
|
||||||
nested_key2:
|
|
||||||
- item
|
|
||||||
|
|
||||||
Uses indentation to detect nesting (2+ spaces deeper = child).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content: YAML content string.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Parsed dict (values can be str, list[str], or dict).
|
|
||||||
"""
|
|
||||||
lines = content.splitlines()
|
|
||||||
result: dict = {}
|
|
||||||
_parse_yaml_block(lines, 0, 0, result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_yaml_block(
|
|
||||||
lines: list[str], start: int, min_indent: int, target: dict
|
|
||||||
) -> int:
|
|
||||||
"""Parse a YAML block into target dict, returning next line index."""
|
|
||||||
i = start
|
|
||||||
current_list: list | None = None
|
|
||||||
|
|
||||||
while i < len(lines):
|
|
||||||
line = lines[i]
|
|
||||||
stripped = line.strip()
|
|
||||||
|
|
||||||
# Skip empty lines and comments
|
|
||||||
if not stripped or stripped.startswith("#"):
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Calculate indentation
|
|
||||||
indent = len(line) - len(line.lstrip())
|
|
||||||
|
|
||||||
# If dedented past our block, we're done
|
|
||||||
if indent < min_indent:
|
|
||||||
break
|
|
||||||
|
|
||||||
if stripped.startswith("- "):
|
|
||||||
if current_list is not None:
|
|
||||||
current_list.append(_unquote(stripped[2:].strip()))
|
|
||||||
i += 1
|
|
||||||
elif ":" in stripped:
|
|
||||||
key, _, value = stripped.partition(":")
|
|
||||||
key = key.strip()
|
|
||||||
value = _strip_inline_comment(value).strip()
|
|
||||||
value = _unquote(value)
|
|
||||||
current_list = None
|
|
||||||
|
|
||||||
if value:
|
|
||||||
# key: value
|
|
||||||
target[key] = value
|
|
||||||
i += 1
|
|
||||||
else:
|
|
||||||
# key: (no value) — peek ahead to determine list vs nested dict
|
|
||||||
next_i, next_line = _next_content_line(lines, i + 1)
|
|
||||||
if next_i >= len(lines):
|
|
||||||
target[key] = {}
|
|
||||||
i = next_i
|
|
||||||
elif next_line.strip().startswith("- "):
|
|
||||||
# It's a list
|
|
||||||
current_list = []
|
|
||||||
target[key] = current_list
|
|
||||||
i += 1
|
|
||||||
else:
|
|
||||||
next_indent = len(next_line) - len(next_line.lstrip())
|
|
||||||
if next_indent > indent:
|
|
||||||
# It's a nested dict
|
|
||||||
nested: dict = {}
|
|
||||||
target[key] = nested
|
|
||||||
i = _parse_yaml_block(lines, i + 1, next_indent, nested)
|
|
||||||
else:
|
|
||||||
# Empty value, same or less indent follows
|
|
||||||
target[key] = {}
|
|
||||||
i += 1
|
|
||||||
else:
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
return i
|
|
||||||
|
|
||||||
|
|
||||||
def _next_content_line(lines: list[str], start: int) -> tuple[int, str]:
|
|
||||||
"""Find the next non-empty, non-comment line."""
|
|
||||||
i = start
|
|
||||||
while i < len(lines):
|
|
||||||
stripped = lines[i].strip()
|
|
||||||
if stripped and not stripped.startswith("#"):
|
|
||||||
return i, lines[i]
|
|
||||||
i += 1
|
|
||||||
return i, ""
|
|
||||||
|
|
||||||
|
|
||||||
# Defaults
|
|
||||||
DEFAULT_SESSION_COMMIT_MESSAGE = "chore: record journal"
|
|
||||||
DEFAULT_MAX_JOURNAL_LINES = 2000
|
|
||||||
DEFAULT_SESSION_AUTO_COMMIT = True
|
|
||||||
|
|
||||||
CONFIG_FILE = "config.yaml"
|
|
||||||
|
|
||||||
|
|
||||||
def _is_true_config_value(value: object) -> bool:
|
|
||||||
"""Return True when a config value represents an enabled flag."""
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return value
|
|
||||||
if isinstance(value, str):
|
|
||||||
return value.strip().lower() == "true"
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _get_config_path(repo_root: Path | None = None) -> Path:
|
|
||||||
"""Get path to config.yaml."""
|
|
||||||
root = repo_root or get_repo_root()
|
|
||||||
return root / DIR_WORKFLOW / CONFIG_FILE
|
|
||||||
|
|
||||||
|
|
||||||
def _load_config(repo_root: Path | None = None) -> dict:
|
|
||||||
"""Load and parse config.yaml. Returns empty dict on any error."""
|
|
||||||
config_file = _get_config_path(repo_root)
|
|
||||||
try:
|
|
||||||
content = config_file.read_text(encoding="utf-8")
|
|
||||||
return parse_simple_yaml(content)
|
|
||||||
except (OSError, IOError):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def get_session_commit_message(repo_root: Path | None = None) -> str:
|
|
||||||
"""Get the commit message for auto-committing session records."""
|
|
||||||
config = _load_config(repo_root)
|
|
||||||
return config.get("session_commit_message", DEFAULT_SESSION_COMMIT_MESSAGE)
|
|
||||||
|
|
||||||
|
|
||||||
def get_max_journal_lines(repo_root: Path | None = None) -> int:
|
|
||||||
"""Get the maximum lines per journal file."""
|
|
||||||
config = _load_config(repo_root)
|
|
||||||
value = config.get("max_journal_lines", DEFAULT_MAX_JOURNAL_LINES)
|
|
||||||
try:
|
|
||||||
return int(value)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return DEFAULT_MAX_JOURNAL_LINES
|
|
||||||
|
|
||||||
|
|
||||||
def get_session_auto_commit(repo_root: Path | None = None) -> bool:
|
|
||||||
"""Whether scripts should auto-stage + auto-commit session/task changes.
|
|
||||||
|
|
||||||
Governs both ``add_session.py:_auto_commit_workspace`` and
|
|
||||||
``task_store.py:_auto_commit_archive``.
|
|
||||||
|
|
||||||
Default: ``True`` (existing behavior — auto-stage + auto-commit).
|
|
||||||
Set ``session_auto_commit: false`` in ``.trellis/config.yaml`` to skip
|
|
||||||
auto-staging entirely; the journal/archive files are still written to
|
|
||||||
disk, but the user manages ``git add`` / ``git commit`` themselves.
|
|
||||||
|
|
||||||
Accepts native YAML booleans (``true`` / ``false``) and the string
|
|
||||||
aliases ``true / false / yes / no / 1 / 0 / on / off`` (case-insensitive).
|
|
||||||
Invalid values fall back to ``True`` with a stderr warning.
|
|
||||||
"""
|
|
||||||
config = _load_config(repo_root)
|
|
||||||
raw = config.get("session_auto_commit", DEFAULT_SESSION_AUTO_COMMIT)
|
|
||||||
if isinstance(raw, bool):
|
|
||||||
return raw
|
|
||||||
s = str(raw).strip().lower()
|
|
||||||
if s in ("true", "yes", "1", "on"):
|
|
||||||
return True
|
|
||||||
if s in ("false", "no", "0", "off"):
|
|
||||||
return False
|
|
||||||
print(
|
|
||||||
f"[WARN] invalid session_auto_commit value: {raw!r}; using true (default)",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return DEFAULT_SESSION_AUTO_COMMIT
|
|
||||||
|
|
||||||
|
|
||||||
def get_hooks(event: str, repo_root: Path | None = None) -> list[str]:
|
|
||||||
"""Get hook commands for a lifecycle event.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event: Event name (e.g. "after_create", "after_archive").
|
|
||||||
repo_root: Repository root path.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of shell commands to execute, empty if none configured.
|
|
||||||
"""
|
|
||||||
config = _load_config(repo_root)
|
|
||||||
hooks = config.get("hooks")
|
|
||||||
if not isinstance(hooks, dict):
|
|
||||||
return []
|
|
||||||
commands = hooks.get(event)
|
|
||||||
if isinstance(commands, list):
|
|
||||||
return [str(c) for c in commands]
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Monorepo / Packages
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def get_packages(repo_root: Path | None = None) -> dict[str, dict] | None:
|
|
||||||
"""Get monorepo package declarations.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict mapping package name to its config (path, type, etc.),
|
|
||||||
or None if not configured (single-repo mode).
|
|
||||||
|
|
||||||
Example return:
|
|
||||||
{"cli": {"path": "packages/cli"}, "docs-site": {"path": "docs-site", "type": "submodule"}}
|
|
||||||
"""
|
|
||||||
config = _load_config(repo_root)
|
|
||||||
packages = config.get("packages")
|
|
||||||
if not isinstance(packages, dict):
|
|
||||||
return None
|
|
||||||
# Ensure each value is a dict (filter out scalar entries)
|
|
||||||
filtered = {k: v for k, v in packages.items() if isinstance(v, dict)}
|
|
||||||
if not filtered:
|
|
||||||
return None
|
|
||||||
return filtered
|
|
||||||
|
|
||||||
|
|
||||||
def get_default_package(repo_root: Path | None = None) -> str | None:
|
|
||||||
"""Get the default package name from config.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Package name string, or None if not configured.
|
|
||||||
"""
|
|
||||||
config = _load_config(repo_root)
|
|
||||||
value = config.get("default_package")
|
|
||||||
return str(value) if value else None
|
|
||||||
|
|
||||||
|
|
||||||
def get_submodule_packages(repo_root: Path | None = None) -> dict[str, str]:
|
|
||||||
"""Get packages that are git submodules.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict mapping package name to its path for submodule-type packages.
|
|
||||||
Empty dict if none configured.
|
|
||||||
|
|
||||||
Example return:
|
|
||||||
{"docs-site": "docs-site"}
|
|
||||||
"""
|
|
||||||
packages = get_packages(repo_root)
|
|
||||||
if packages is None:
|
|
||||||
return {}
|
|
||||||
return {
|
|
||||||
name: cfg.get("path", name)
|
|
||||||
for name, cfg in packages.items()
|
|
||||||
if cfg.get("type") == "submodule"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_git_packages(repo_root: Path | None = None) -> dict[str, str]:
|
|
||||||
"""Get packages that have their own independent git repository.
|
|
||||||
|
|
||||||
These are sub-directories with their own .git (not submodules),
|
|
||||||
marked with ``git: true`` in config.yaml.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict mapping package name to its path for git-repo packages.
|
|
||||||
Empty dict if none configured.
|
|
||||||
|
|
||||||
Example config::
|
|
||||||
|
|
||||||
packages:
|
|
||||||
backend:
|
|
||||||
path: iqs
|
|
||||||
git: true
|
|
||||||
|
|
||||||
Example return::
|
|
||||||
|
|
||||||
{"backend": "iqs"}
|
|
||||||
"""
|
|
||||||
packages = get_packages(repo_root)
|
|
||||||
if packages is None:
|
|
||||||
return {}
|
|
||||||
return {
|
|
||||||
name: cfg.get("path", name)
|
|
||||||
for name, cfg in packages.items()
|
|
||||||
if _is_true_config_value(cfg.get("git"))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def is_monorepo(repo_root: Path | None = None) -> bool:
|
|
||||||
"""Check if the project is configured as a monorepo (has packages in config)."""
|
|
||||||
return get_packages(repo_root) is not None
|
|
||||||
|
|
||||||
|
|
||||||
def get_spec_base(package: str | None = None, repo_root: Path | None = None) -> str:
|
|
||||||
"""Get the spec directory base path relative to .trellis/.
|
|
||||||
|
|
||||||
Single-repo: returns "spec"
|
|
||||||
Monorepo with package: returns "spec/<package>"
|
|
||||||
Monorepo without package: returns "spec" (caller should specify package)
|
|
||||||
"""
|
|
||||||
if package and is_monorepo(repo_root):
|
|
||||||
return f"spec/{package}"
|
|
||||||
return "spec"
|
|
||||||
|
|
||||||
|
|
||||||
def validate_package(package: str, repo_root: Path | None = None) -> bool:
|
|
||||||
"""Check if a package name is valid in this project.
|
|
||||||
|
|
||||||
Single-repo (no packages configured): always returns True.
|
|
||||||
Monorepo: returns True only if package exists in config.yaml packages.
|
|
||||||
"""
|
|
||||||
packages = get_packages(repo_root)
|
|
||||||
if packages is None:
|
|
||||||
return True # Single-repo, no validation needed
|
|
||||||
return package in packages
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_package(
|
|
||||||
task_package: str | None = None,
|
|
||||||
repo_root: Path | None = None,
|
|
||||||
) -> str | None:
|
|
||||||
"""Resolve package from inferred sources with validation.
|
|
||||||
|
|
||||||
Checks in order: task_package → default_package.
|
|
||||||
Invalid inferred values print a warning to stderr and are skipped.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Resolved package name, or None if no valid package found.
|
|
||||||
|
|
||||||
Note:
|
|
||||||
CLI --package should be validated separately by the caller
|
|
||||||
(fail-fast with available packages list on error).
|
|
||||||
"""
|
|
||||||
packages = get_packages(repo_root)
|
|
||||||
if packages is None:
|
|
||||||
return None # Single-repo, no package needed
|
|
||||||
|
|
||||||
# Try task_package (guard against non-string values from malformed JSON)
|
|
||||||
if task_package and isinstance(task_package, str):
|
|
||||||
if task_package in packages:
|
|
||||||
return task_package
|
|
||||||
print(
|
|
||||||
f"Warning: task.json package '{task_package}' not found in config, skipping",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try default_package
|
|
||||||
default = get_default_package(repo_root)
|
|
||||||
if default:
|
|
||||||
if default in packages:
|
|
||||||
return default
|
|
||||||
print(
|
|
||||||
f"Warning: default_package '{default}' not found in config, skipping",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_spec_scope(repo_root: Path | None = None) -> list[str] | str | None:
|
|
||||||
"""Get session.spec_scope configuration.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[str]: Package names to include in spec scanning.
|
|
||||||
str: "active_task" to use current task's package.
|
|
||||||
None: No scope configured (scan all packages).
|
|
||||||
"""
|
|
||||||
config = _load_config(repo_root)
|
|
||||||
session = config.get("session")
|
|
||||||
if not isinstance(session, dict):
|
|
||||||
return None
|
|
||||||
|
|
||||||
scope = session.get("spec_scope")
|
|
||||||
if scope is None:
|
|
||||||
return None
|
|
||||||
if isinstance(scope, str):
|
|
||||||
return scope # e.g. "active_task"
|
|
||||||
if isinstance(scope, list):
|
|
||||||
return [str(s) for s in scope]
|
|
||||||
return None
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Developer management utilities.
|
|
||||||
|
|
||||||
Provides:
|
|
||||||
init_developer - Initialize developer
|
|
||||||
ensure_developer - Ensure developer is initialized (exit if not)
|
|
||||||
show_developer_info - Show developer information
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .paths import (
|
|
||||||
DIR_WORKFLOW,
|
|
||||||
DIR_WORKSPACE,
|
|
||||||
DIR_TASKS,
|
|
||||||
FILE_DEVELOPER,
|
|
||||||
FILE_JOURNAL_PREFIX,
|
|
||||||
get_repo_root,
|
|
||||||
get_developer,
|
|
||||||
check_developer,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Developer Initialization
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def init_developer(name: str, repo_root: Path | None = None) -> bool:
|
|
||||||
"""Initialize developer.
|
|
||||||
|
|
||||||
Creates:
|
|
||||||
- .trellis/.developer file with developer info
|
|
||||||
- .trellis/workspace/<name>/ directory structure
|
|
||||||
- Initial journal file and index.md
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Developer name.
|
|
||||||
repo_root: Repository root path. Defaults to auto-detected.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True on success, False on error.
|
|
||||||
"""
|
|
||||||
if not name:
|
|
||||||
print("Error: developer name is required", file=sys.stderr)
|
|
||||||
return False
|
|
||||||
|
|
||||||
if repo_root is None:
|
|
||||||
repo_root = get_repo_root()
|
|
||||||
|
|
||||||
dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
|
|
||||||
workspace_dir = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / name
|
|
||||||
|
|
||||||
# Create .developer file
|
|
||||||
initialized_at = datetime.now().isoformat()
|
|
||||||
try:
|
|
||||||
dev_file.write_text(
|
|
||||||
f"name={name}\ninitialized_at={initialized_at}\n",
|
|
||||||
encoding="utf-8"
|
|
||||||
)
|
|
||||||
except (OSError, IOError) as e:
|
|
||||||
print(f"Error: Failed to create .developer file: {e}", file=sys.stderr)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Create workspace directory structure
|
|
||||||
try:
|
|
||||||
workspace_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
except (OSError, IOError) as e:
|
|
||||||
print(f"Error: Failed to create workspace directory: {e}", file=sys.stderr)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Create initial journal file
|
|
||||||
journal_file = workspace_dir / f"{FILE_JOURNAL_PREFIX}1.md"
|
|
||||||
if not journal_file.exists():
|
|
||||||
today = datetime.now().strftime("%Y-%m-%d")
|
|
||||||
journal_content = f"""# Journal - {name} (Part 1)
|
|
||||||
|
|
||||||
> AI development session journal
|
|
||||||
> Started: {today}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
journal_file.write_text(journal_content, encoding="utf-8")
|
|
||||||
except (OSError, IOError) as e:
|
|
||||||
print(f"Error: Failed to create journal file: {e}", file=sys.stderr)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Create index.md with markers for auto-update
|
|
||||||
index_file = workspace_dir / "index.md"
|
|
||||||
if not index_file.exists():
|
|
||||||
index_content = f"""# Workspace Index - {name}
|
|
||||||
|
|
||||||
> Journal tracking for AI development sessions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Current Status
|
|
||||||
|
|
||||||
<!-- @@@auto:current-status -->
|
|
||||||
- **Active File**: `journal-1.md`
|
|
||||||
- **Total Sessions**: 0
|
|
||||||
- **Last Active**: -
|
|
||||||
<!-- @@@/auto:current-status -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Active Documents
|
|
||||||
|
|
||||||
<!-- @@@auto:active-documents -->
|
|
||||||
| File | Lines | Status |
|
|
||||||
|------|-------|--------|
|
|
||||||
| `journal-1.md` | ~0 | Active |
|
|
||||||
<!-- @@@/auto:active-documents -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Session History
|
|
||||||
|
|
||||||
<!-- @@@auto:session-history -->
|
|
||||||
| # | Date | Title | Commits | Branch |
|
|
||||||
|---|------|-------|---------|--------|
|
|
||||||
<!-- @@@/auto:session-history -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Sessions are appended to journal files
|
|
||||||
- New journal file created when current exceeds 2000 lines
|
|
||||||
- Use `add_session.py` to record sessions
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
index_file.write_text(index_content, encoding="utf-8")
|
|
||||||
except (OSError, IOError) as e:
|
|
||||||
print(f"Error: Failed to create index.md: {e}", file=sys.stderr)
|
|
||||||
return False
|
|
||||||
|
|
||||||
print(f"Developer initialized: {name}")
|
|
||||||
print(f" .developer file: {dev_file}")
|
|
||||||
print(f" Workspace dir: {workspace_dir}")
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_developer(repo_root: Path | None = None) -> None:
|
|
||||||
"""Ensure developer is initialized, exit if not.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
repo_root: Repository root path. Defaults to auto-detected.
|
|
||||||
"""
|
|
||||||
if repo_root is None:
|
|
||||||
repo_root = get_repo_root()
|
|
||||||
|
|
||||||
if not check_developer(repo_root):
|
|
||||||
print("Error: Developer not initialized.", file=sys.stderr)
|
|
||||||
print(f"Run: 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()
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"""
|
|
||||||
Git command execution utility.
|
|
||||||
|
|
||||||
Single source of truth for running git commands across all Trellis scripts.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def run_git(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]:
|
|
||||||
"""Run a git command and return (returncode, stdout, stderr).
|
|
||||||
|
|
||||||
Uses UTF-8 encoding with -c i18n.logOutputEncoding=UTF-8 to ensure
|
|
||||||
consistent output across all platforms (Windows, macOS, Linux).
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
git_args = ["git", "-c", "i18n.logOutputEncoding=UTF-8"] + args
|
|
||||||
result = subprocess.run(
|
|
||||||
git_args,
|
|
||||||
cwd=cwd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
encoding="utf-8",
|
|
||||||
errors="replace",
|
|
||||||
)
|
|
||||||
return result.returncode, result.stdout, result.stderr
|
|
||||||
except Exception as e:
|
|
||||||
return 1, "", str(e)
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Git and Session Context utilities.
|
|
||||||
|
|
||||||
Entry shim — delegates to session_context and packages_context.
|
|
||||||
|
|
||||||
Provides:
|
|
||||||
output_json - Output context in JSON format
|
|
||||||
output_text - Output context in text format
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
from .git import run_git
|
|
||||||
from .session_context import (
|
|
||||||
get_context_json,
|
|
||||||
get_context_text,
|
|
||||||
get_context_record_json,
|
|
||||||
get_context_text_record,
|
|
||||||
output_json,
|
|
||||||
output_text,
|
|
||||||
)
|
|
||||||
from .packages_context import (
|
|
||||||
get_context_packages_text,
|
|
||||||
get_context_packages_json,
|
|
||||||
)
|
|
||||||
from .trellis_config import read_trellis_config
|
|
||||||
from .workflow_phase import (
|
|
||||||
filter_platform,
|
|
||||||
get_phase_index,
|
|
||||||
get_step,
|
|
||||||
resolve_effective_platform,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Backward-compatible alias — external modules import this name
|
|
||||||
_run_git_command = run_git
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Main Entry
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
"""CLI entry point."""
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Get Session Context for AI Agent")
|
|
||||||
parser.add_argument(
|
|
||||||
"--json",
|
|
||||||
"-j",
|
|
||||||
action="store_true",
|
|
||||||
help="Output in JSON format (works with any --mode)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--mode",
|
|
||||||
"-m",
|
|
||||||
choices=["default", "record", "packages", "phase"],
|
|
||||||
default="default",
|
|
||||||
help="Output mode: default (full context), record (for record-session), packages (package info only), phase (workflow step extraction)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--step",
|
|
||||||
help="Step id for --mode phase, e.g. 1.1, 2.2. Omit to get the Phase Index.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--platform",
|
|
||||||
help="Platform name for --mode phase, e.g. cursor, claude-code. Filters platform-tagged blocks.",
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.mode == "record":
|
|
||||||
if args.json:
|
|
||||||
print(json.dumps(get_context_record_json(), indent=2, ensure_ascii=False))
|
|
||||||
else:
|
|
||||||
print(get_context_text_record())
|
|
||||||
elif args.mode == "packages":
|
|
||||||
if args.json:
|
|
||||||
print(json.dumps(get_context_packages_json(), indent=2, ensure_ascii=False))
|
|
||||||
else:
|
|
||||||
print(get_context_packages_text())
|
|
||||||
elif args.mode == "phase":
|
|
||||||
content = get_step(args.step) if args.step else get_phase_index()
|
|
||||||
if not content.strip():
|
|
||||||
if args.step:
|
|
||||||
parser.exit(2, f"Step not found: {args.step}\n")
|
|
||||||
else:
|
|
||||||
parser.exit(2, "Phase Index section not found in workflow.md\n")
|
|
||||||
if args.platform:
|
|
||||||
effective = resolve_effective_platform(
|
|
||||||
args.platform, read_trellis_config()
|
|
||||||
)
|
|
||||||
content = filter_platform(content, effective)
|
|
||||||
print(content, end="")
|
|
||||||
else:
|
|
||||||
if args.json:
|
|
||||||
output_json()
|
|
||||||
else:
|
|
||||||
output_text()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
"""
|
|
||||||
JSON file I/O utilities.
|
|
||||||
|
|
||||||
Provides read_json and write_json as the single source of truth
|
|
||||||
for JSON file operations across all Trellis scripts.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def read_json(path: Path) -> dict | None:
|
|
||||||
"""Read and parse a JSON file.
|
|
||||||
|
|
||||||
Returns None if the file doesn't exist, is invalid JSON, or can't be read.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def write_json(path: Path, data: dict) -> bool:
|
|
||||||
"""Write dict to JSON file with pretty formatting.
|
|
||||||
|
|
||||||
Returns True on success, False on error.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
path.write_text(
|
|
||||||
json.dumps(data, indent=2, ensure_ascii=False),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
except (OSError, IOError):
|
|
||||||
return False
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
"""
|
|
||||||
Terminal output utilities: colors and structured logging.
|
|
||||||
|
|
||||||
Single source of truth for Colors and log_* functions
|
|
||||||
used across all Trellis scripts.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
class Colors:
|
|
||||||
"""ANSI color codes for terminal output."""
|
|
||||||
|
|
||||||
RED = "\033[0;31m"
|
|
||||||
GREEN = "\033[0;32m"
|
|
||||||
YELLOW = "\033[1;33m"
|
|
||||||
BLUE = "\033[0;34m"
|
|
||||||
CYAN = "\033[0;36m"
|
|
||||||
DIM = "\033[2m"
|
|
||||||
NC = "\033[0m" # No Color / Reset
|
|
||||||
|
|
||||||
|
|
||||||
def colored(text: str, color: str) -> str:
|
|
||||||
"""Apply ANSI color to text."""
|
|
||||||
return f"{color}{text}{Colors.NC}"
|
|
||||||
|
|
||||||
|
|
||||||
def log_info(msg: str) -> None:
|
|
||||||
"""Print info-level message with [INFO] prefix."""
|
|
||||||
print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}")
|
|
||||||
|
|
||||||
|
|
||||||
def log_success(msg: str) -> None:
|
|
||||||
"""Print success message with [SUCCESS] prefix."""
|
|
||||||
print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}")
|
|
||||||
|
|
||||||
|
|
||||||
def log_warn(msg: str) -> None:
|
|
||||||
"""Print warning message with [WARN] prefix."""
|
|
||||||
print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}")
|
|
||||||
|
|
||||||
|
|
||||||
def log_error(msg: str) -> None:
|
|
||||||
"""Print error message with [ERROR] prefix."""
|
|
||||||
print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}")
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Package discovery and context output.
|
|
||||||
|
|
||||||
Provides:
|
|
||||||
get_packages_info - Get structured package info
|
|
||||||
get_packages_section - Build PACKAGES text section
|
|
||||||
get_context_packages_text - Full packages text output (--mode packages)
|
|
||||||
get_context_packages_json - Full packages JSON output (--mode packages --json)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .config import _is_true_config_value, get_default_package, get_packages, get_spec_scope
|
|
||||||
from .paths import (
|
|
||||||
DIR_SPEC,
|
|
||||||
DIR_WORKFLOW,
|
|
||||||
get_current_task,
|
|
||||||
get_repo_root,
|
|
||||||
)
|
|
||||||
from .tasks import load_task
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Internal Helpers
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def _scan_spec_layers(spec_dir: Path, package: str | None = None) -> list[str]:
|
|
||||||
"""Scan spec directory for available layers (subdirectories).
|
|
||||||
|
|
||||||
For monorepo: scans spec/<package>/
|
|
||||||
For single-repo: scans spec/
|
|
||||||
"""
|
|
||||||
target = spec_dir / package if package else spec_dir
|
|
||||||
if not target.is_dir():
|
|
||||||
return []
|
|
||||||
return sorted(
|
|
||||||
d.name for d in target.iterdir() if d.is_dir() and d.name != "guides"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_active_task_package(repo_root: Path) -> str | None:
|
|
||||||
"""Get the package field from the active task's task.json."""
|
|
||||||
current = get_current_task(repo_root)
|
|
||||||
if not current:
|
|
||||||
return None
|
|
||||||
ct = load_task(repo_root / current)
|
|
||||||
return ct.package if ct and ct.package else None
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_scope_set(
|
|
||||||
packages: dict,
|
|
||||||
spec_scope,
|
|
||||||
task_pkg: str | None,
|
|
||||||
default_pkg: str | None,
|
|
||||||
) -> set | None:
|
|
||||||
"""Resolve spec_scope to a set of allowed package names, or None for full scan."""
|
|
||||||
if not packages:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if spec_scope is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if isinstance(spec_scope, str) and spec_scope == "active_task":
|
|
||||||
if task_pkg and task_pkg in packages:
|
|
||||||
return {task_pkg}
|
|
||||||
if default_pkg and default_pkg in packages:
|
|
||||||
return {default_pkg}
|
|
||||||
return None
|
|
||||||
|
|
||||||
if isinstance(spec_scope, list):
|
|
||||||
valid = {e for e in spec_scope if e in packages}
|
|
||||||
if valid:
|
|
||||||
return valid
|
|
||||||
# All invalid: fallback
|
|
||||||
if task_pkg and task_pkg in packages:
|
|
||||||
return {task_pkg}
|
|
||||||
if default_pkg and default_pkg in packages:
|
|
||||||
return {default_pkg}
|
|
||||||
return None
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Public Functions
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def get_packages_info(repo_root: Path) -> list[dict]:
|
|
||||||
"""Get structured package info for monorepo projects.
|
|
||||||
|
|
||||||
Returns list of dicts with keys: name, path, type, default, specLayers,
|
|
||||||
isSubmodule, isGitRepo.
|
|
||||||
Returns empty list for single-repo projects.
|
|
||||||
"""
|
|
||||||
packages = get_packages(repo_root)
|
|
||||||
if not packages:
|
|
||||||
return []
|
|
||||||
|
|
||||||
default_pkg = get_default_package(repo_root)
|
|
||||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
|
||||||
result = []
|
|
||||||
|
|
||||||
for pkg_name, pkg_config in packages.items():
|
|
||||||
pkg_path = pkg_config.get("path", pkg_name) if isinstance(pkg_config, dict) else str(pkg_config)
|
|
||||||
pkg_type = pkg_config.get("type", "local") if isinstance(pkg_config, dict) else "local"
|
|
||||||
pkg_git = pkg_config.get("git", False) if isinstance(pkg_config, dict) else False
|
|
||||||
layers = _scan_spec_layers(spec_dir, pkg_name)
|
|
||||||
|
|
||||||
result.append({
|
|
||||||
"name": pkg_name,
|
|
||||||
"path": pkg_path,
|
|
||||||
"type": pkg_type,
|
|
||||||
"default": pkg_name == default_pkg,
|
|
||||||
"specLayers": layers,
|
|
||||||
"isSubmodule": pkg_type == "submodule",
|
|
||||||
"isGitRepo": _is_true_config_value(pkg_git),
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def get_packages_section(repo_root: Path) -> str:
|
|
||||||
"""Build the PACKAGES section for text output."""
|
|
||||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
|
||||||
pkg_info = get_packages_info(repo_root)
|
|
||||||
|
|
||||||
lines: list[str] = []
|
|
||||||
lines.append("## PACKAGES")
|
|
||||||
|
|
||||||
if not pkg_info:
|
|
||||||
lines.append("(single-repo mode)")
|
|
||||||
layers = _scan_spec_layers(spec_dir)
|
|
||||||
if layers:
|
|
||||||
lines.append(f"Spec layers: {', '.join(layers)}")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
default_pkg = get_default_package(repo_root)
|
|
||||||
|
|
||||||
for pkg in pkg_info:
|
|
||||||
layers_str = f" [{', '.join(pkg['specLayers'])}]" if pkg["specLayers"] else ""
|
|
||||||
submodule_tag = " (submodule)" if pkg["isSubmodule"] else ""
|
|
||||||
git_repo_tag = " (git repo)" if pkg["isGitRepo"] else ""
|
|
||||||
default_tag = " *" if pkg["default"] else ""
|
|
||||||
lines.append(
|
|
||||||
f"- {pkg['name']:<16} {pkg['path']:<20}{layers_str}{submodule_tag}{git_repo_tag}{default_tag}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if default_pkg:
|
|
||||||
lines.append(f"Default package: {default_pkg}")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def get_context_packages_text(repo_root: Path | None = None) -> str:
|
|
||||||
"""Get packages context as formatted text (for --mode packages)."""
|
|
||||||
if repo_root is None:
|
|
||||||
repo_root = get_repo_root()
|
|
||||||
|
|
||||||
pkg_info = get_packages_info(repo_root)
|
|
||||||
lines: list[str] = []
|
|
||||||
|
|
||||||
if not pkg_info:
|
|
||||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
|
||||||
lines.append("Single-repo project (no packages configured)")
|
|
||||||
lines.append("")
|
|
||||||
layers = _scan_spec_layers(spec_dir)
|
|
||||||
if layers:
|
|
||||||
lines.append(f"Spec layers: {', '.join(layers)}")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
# Resolve scope for annotations
|
|
||||||
packages_dict = get_packages(repo_root) or {}
|
|
||||||
default_pkg = get_default_package(repo_root)
|
|
||||||
spec_scope = get_spec_scope(repo_root)
|
|
||||||
task_pkg = _get_active_task_package(repo_root)
|
|
||||||
scope_set = _resolve_scope_set(packages_dict, spec_scope, task_pkg, default_pkg)
|
|
||||||
|
|
||||||
lines.append("## PACKAGES")
|
|
||||||
lines.append("")
|
|
||||||
for pkg in pkg_info:
|
|
||||||
default_tag = " (default)" if pkg["default"] else ""
|
|
||||||
type_tag = f" [{pkg['type']}]" if pkg["type"] != "local" else ""
|
|
||||||
git_tag = " [git repo]" if pkg["isGitRepo"] else ""
|
|
||||||
|
|
||||||
# Scope annotation
|
|
||||||
scope_tag = ""
|
|
||||||
if scope_set is not None and pkg["name"] not in scope_set:
|
|
||||||
scope_tag = " (out of scope)"
|
|
||||||
|
|
||||||
lines.append(f"### {pkg['name']}{default_tag}{type_tag}{git_tag}{scope_tag}")
|
|
||||||
lines.append(f"Path: {pkg['path']}")
|
|
||||||
if pkg["specLayers"]:
|
|
||||||
lines.append(f"Spec layers: {', '.join(pkg['specLayers'])}")
|
|
||||||
for layer in pkg["specLayers"]:
|
|
||||||
lines.append(f" - .trellis/spec/{pkg['name']}/{layer}/index.md")
|
|
||||||
else:
|
|
||||||
lines.append("Spec: not configured")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
# Also show shared guides
|
|
||||||
guides_dir = repo_root / DIR_WORKFLOW / DIR_SPEC / "guides"
|
|
||||||
if guides_dir.is_dir():
|
|
||||||
lines.append("### Shared Guides (always included)")
|
|
||||||
lines.append("Path: .trellis/spec/guides/index.md")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def get_context_packages_json(repo_root: Path | None = None) -> dict:
|
|
||||||
"""Get packages context as a dictionary (for --mode packages --json)."""
|
|
||||||
if repo_root is None:
|
|
||||||
repo_root = get_repo_root()
|
|
||||||
|
|
||||||
pkg_info = get_packages_info(repo_root)
|
|
||||||
|
|
||||||
if not pkg_info:
|
|
||||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
|
||||||
layers = _scan_spec_layers(spec_dir)
|
|
||||||
return {
|
|
||||||
"mode": "single-repo",
|
|
||||||
"specLayers": layers,
|
|
||||||
}
|
|
||||||
|
|
||||||
default_pkg = get_default_package(repo_root)
|
|
||||||
spec_scope = get_spec_scope(repo_root)
|
|
||||||
task_pkg = _get_active_task_package(repo_root)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"mode": "monorepo",
|
|
||||||
"packages": pkg_info,
|
|
||||||
"defaultPackage": default_pkg,
|
|
||||||
"specScope": spec_scope,
|
|
||||||
"activeTaskPackage": task_pkg,
|
|
||||||
}
|
|
||||||
@@ -1,447 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Common path utilities for Trellis workflow.
|
|
||||||
|
|
||||||
Provides:
|
|
||||||
get_repo_root - Get repository root directory
|
|
||||||
get_developer - Get developer name
|
|
||||||
get_workspace_dir - Get developer workspace directory
|
|
||||||
get_tasks_dir - Get tasks directory
|
|
||||||
get_active_journal_file - Get current journal file
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Path Constants (change here to rename directories)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# Directory names
|
|
||||||
DIR_WORKFLOW = ".trellis"
|
|
||||||
DIR_WORKSPACE = "workspace"
|
|
||||||
DIR_TASKS = "tasks"
|
|
||||||
DIR_ARCHIVE = "archive"
|
|
||||||
DIR_SPEC = "spec"
|
|
||||||
DIR_SCRIPTS = "scripts"
|
|
||||||
|
|
||||||
# File names
|
|
||||||
FILE_DEVELOPER = ".developer"
|
|
||||||
FILE_CURRENT_TASK = ".current-task"
|
|
||||||
FILE_TASK_JSON = "task.json"
|
|
||||||
FILE_JOURNAL_PREFIX = "journal-"
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Repository Root
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def get_repo_root(start_path: Path | None = None) -> Path:
|
|
||||||
"""Find the nearest directory containing .trellis/ folder.
|
|
||||||
|
|
||||||
This handles nested git repos correctly (e.g., test project inside another repo).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
start_path: Starting directory to search from. Defaults to current directory.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to repository root, or current directory if no .trellis/ found.
|
|
||||||
"""
|
|
||||||
current = (start_path or Path.cwd()).resolve()
|
|
||||||
|
|
||||||
while current != current.parent:
|
|
||||||
if (current / DIR_WORKFLOW).is_dir():
|
|
||||||
return current
|
|
||||||
current = current.parent
|
|
||||||
|
|
||||||
# Fallback to current directory if no .trellis/ found
|
|
||||||
return Path.cwd().resolve()
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Developer
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def get_developer(repo_root: Path | None = None) -> str | None:
|
|
||||||
"""Get developer name from .developer file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
repo_root: Repository root path. Defaults to auto-detected.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Developer name or None if not initialized.
|
|
||||||
"""
|
|
||||||
if repo_root is None:
|
|
||||||
repo_root = get_repo_root()
|
|
||||||
|
|
||||||
dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
|
|
||||||
|
|
||||||
if not dev_file.is_file():
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
content = dev_file.read_text(encoding="utf-8")
|
|
||||||
for line in content.splitlines():
|
|
||||||
if line.startswith("name="):
|
|
||||||
return line.split("=", 1)[1].strip()
|
|
||||||
except (OSError, IOError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def check_developer(repo_root: Path | None = None) -> bool:
|
|
||||||
"""Check if developer is initialized.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
repo_root: Repository root path. Defaults to auto-detected.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if developer is initialized.
|
|
||||||
"""
|
|
||||||
return get_developer(repo_root) is not None
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Tasks Directory
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def get_tasks_dir(repo_root: Path | None = None) -> Path:
|
|
||||||
"""Get tasks directory path.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
repo_root: Repository root path. Defaults to auto-detected.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to tasks directory.
|
|
||||||
"""
|
|
||||||
if repo_root is None:
|
|
||||||
repo_root = get_repo_root()
|
|
||||||
return repo_root / DIR_WORKFLOW / DIR_TASKS
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Workspace Directory
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def get_workspace_dir(repo_root: Path | None = None) -> Path | None:
|
|
||||||
"""Get developer workspace directory.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
repo_root: Repository root path. Defaults to auto-detected.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to workspace directory or None if developer not set.
|
|
||||||
"""
|
|
||||||
if repo_root is None:
|
|
||||||
repo_root = get_repo_root()
|
|
||||||
|
|
||||||
developer = get_developer(repo_root)
|
|
||||||
if developer:
|
|
||||||
return repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Journal File
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def get_active_journal_file(repo_root: Path | None = None) -> Path | None:
|
|
||||||
"""Get the current active journal file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
repo_root: Repository root path. Defaults to auto-detected.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to active journal file or None if not found.
|
|
||||||
"""
|
|
||||||
if repo_root is None:
|
|
||||||
repo_root = get_repo_root()
|
|
||||||
|
|
||||||
workspace_dir = get_workspace_dir(repo_root)
|
|
||||||
if workspace_dir is None or not workspace_dir.is_dir():
|
|
||||||
return None
|
|
||||||
|
|
||||||
latest: Path | None = None
|
|
||||||
highest = 0
|
|
||||||
|
|
||||||
for f in workspace_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"):
|
|
||||||
if not f.is_file():
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Extract number from filename
|
|
||||||
name = f.stem # e.g., "journal-1"
|
|
||||||
match = re.search(r"(\d+)$", name)
|
|
||||||
if match:
|
|
||||||
num = int(match.group(1))
|
|
||||||
if num > highest:
|
|
||||||
highest = num
|
|
||||||
latest = f
|
|
||||||
|
|
||||||
return latest
|
|
||||||
|
|
||||||
|
|
||||||
def count_lines(file_path: Path) -> int:
|
|
||||||
"""Count lines in a file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file_path: Path to file.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of lines, or 0 if file doesn't exist.
|
|
||||||
"""
|
|
||||||
if not file_path.is_file():
|
|
||||||
return 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
return len(file_path.read_text(encoding="utf-8").splitlines())
|
|
||||||
except (OSError, IOError):
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Current Task Management
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def normalize_task_ref(task_ref: str) -> str:
|
|
||||||
"""Normalize a task ref for stable runtime storage.
|
|
||||||
|
|
||||||
Stored refs should prefer repo-relative POSIX paths like
|
|
||||||
`.trellis/tasks/03-27-my-task`, even on Windows. Absolute paths are preserved
|
|
||||||
unless they can later be converted back to repo-relative form by callers.
|
|
||||||
"""
|
|
||||||
normalized = task_ref.strip()
|
|
||||||
if not normalized:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
path_obj = Path(normalized)
|
|
||||||
if path_obj.is_absolute():
|
|
||||||
return str(path_obj)
|
|
||||||
|
|
||||||
normalized = normalized.replace("\\", "/")
|
|
||||||
while normalized.startswith("./"):
|
|
||||||
normalized = normalized[2:]
|
|
||||||
|
|
||||||
if normalized.startswith(f"{DIR_TASKS}/"):
|
|
||||||
return f"{DIR_WORKFLOW}/{normalized}"
|
|
||||||
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_task_ref(task_ref: str, repo_root: Path | None = None) -> Path | None:
|
|
||||||
"""Resolve a task ref to an absolute task directory path."""
|
|
||||||
if repo_root is None:
|
|
||||||
repo_root = get_repo_root()
|
|
||||||
|
|
||||||
normalized = normalize_task_ref(task_ref)
|
|
||||||
if not normalized:
|
|
||||||
return None
|
|
||||||
|
|
||||||
path_obj = Path(normalized)
|
|
||||||
if path_obj.is_absolute():
|
|
||||||
return path_obj
|
|
||||||
|
|
||||||
if normalized.startswith(f"{DIR_WORKFLOW}/"):
|
|
||||||
return repo_root / path_obj
|
|
||||||
|
|
||||||
return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_task(
|
|
||||||
repo_root: Path | None = None,
|
|
||||||
platform_input: dict | None = None,
|
|
||||||
platform: str | None = None,
|
|
||||||
) -> str | None:
|
|
||||||
"""Get current task directory path (relative to repo_root).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
repo_root: Repository root path. Defaults to auto-detected.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Relative path to current task directory or None.
|
|
||||||
"""
|
|
||||||
if repo_root is None:
|
|
||||||
repo_root = get_repo_root()
|
|
||||||
|
|
||||||
from .active_task import resolve_active_task
|
|
||||||
|
|
||||||
return resolve_active_task(repo_root, platform_input, platform).task_path
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_task_abs(
|
|
||||||
repo_root: Path | None = None,
|
|
||||||
platform_input: dict | None = None,
|
|
||||||
platform: str | None = None,
|
|
||||||
) -> Path | None:
|
|
||||||
"""Get current task directory absolute path.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
repo_root: Repository root path. Defaults to auto-detected.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Absolute path to current task directory or None.
|
|
||||||
"""
|
|
||||||
if repo_root is None:
|
|
||||||
repo_root = get_repo_root()
|
|
||||||
|
|
||||||
relative = get_current_task(repo_root, platform_input, platform)
|
|
||||||
if relative:
|
|
||||||
return resolve_task_ref(relative, repo_root)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_task_source(
|
|
||||||
repo_root: Path | None = None,
|
|
||||||
platform_input: dict | None = None,
|
|
||||||
platform: str | None = None,
|
|
||||||
) -> tuple[str, str | None, str | None]:
|
|
||||||
"""Get active task source as (`source`, `context_key`, `task_path`)."""
|
|
||||||
if repo_root is None:
|
|
||||||
repo_root = get_repo_root()
|
|
||||||
|
|
||||||
from .active_task import get_current_task_source as _get_source
|
|
||||||
|
|
||||||
return _get_source(repo_root, platform_input, platform)
|
|
||||||
|
|
||||||
|
|
||||||
def set_current_task(
|
|
||||||
task_path: str,
|
|
||||||
repo_root: Path | None = None,
|
|
||||||
platform_input: dict | None = None,
|
|
||||||
platform: str | None = None,
|
|
||||||
) -> bool:
|
|
||||||
"""Set current task in session scope.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task_path: Task directory path (relative to repo_root).
|
|
||||||
repo_root: Repository root path. Defaults to auto-detected.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True on success, False on error.
|
|
||||||
"""
|
|
||||||
if repo_root is None:
|
|
||||||
repo_root = get_repo_root()
|
|
||||||
|
|
||||||
from .active_task import set_active_task
|
|
||||||
|
|
||||||
return set_active_task(
|
|
||||||
task_path,
|
|
||||||
repo_root,
|
|
||||||
platform_input=platform_input,
|
|
||||||
platform=platform,
|
|
||||||
) is not None
|
|
||||||
|
|
||||||
|
|
||||||
def clear_current_task(
|
|
||||||
repo_root: Path | None = None,
|
|
||||||
platform_input: dict | None = None,
|
|
||||||
platform: str | None = None,
|
|
||||||
) -> bool:
|
|
||||||
"""Clear current task in session scope.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
repo_root: Repository root path. Defaults to auto-detected.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True on success.
|
|
||||||
"""
|
|
||||||
if repo_root is None:
|
|
||||||
repo_root = get_repo_root()
|
|
||||||
|
|
||||||
from .active_task import clear_active_task
|
|
||||||
|
|
||||||
clear_active_task(
|
|
||||||
repo_root,
|
|
||||||
platform_input=platform_input,
|
|
||||||
platform=platform,
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def has_current_task(repo_root: Path | None = None) -> bool:
|
|
||||||
"""Check if has current task.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
repo_root: Repository root path. Defaults to auto-detected.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if current task is set.
|
|
||||||
"""
|
|
||||||
return get_current_task(repo_root) is not None
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Task ID Generation
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def generate_task_date_prefix() -> str:
|
|
||||||
"""Generate task ID based on date (MM-DD format).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Date prefix string (e.g., "01-21").
|
|
||||||
"""
|
|
||||||
return datetime.now().strftime("%m-%d")
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Monorepo / Package Paths
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def get_spec_dir(package: str | None = None, repo_root: Path | None = None) -> Path:
|
|
||||||
"""Get the spec directory path.
|
|
||||||
|
|
||||||
Single-repo: .trellis/spec
|
|
||||||
Monorepo with package: .trellis/spec/<package>
|
|
||||||
|
|
||||||
Uses lazy import to avoid circular dependency with config.py.
|
|
||||||
"""
|
|
||||||
if repo_root is None:
|
|
||||||
repo_root = get_repo_root()
|
|
||||||
|
|
||||||
from .config import get_spec_base
|
|
||||||
|
|
||||||
base = get_spec_base(package, repo_root)
|
|
||||||
return repo_root / DIR_WORKFLOW / base
|
|
||||||
|
|
||||||
|
|
||||||
def get_package_path(package: str, repo_root: Path | None = None) -> Path | None:
|
|
||||||
"""Get a package's source directory absolute path from config.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Absolute path to the package directory, or None if not found.
|
|
||||||
"""
|
|
||||||
if repo_root is None:
|
|
||||||
repo_root = get_repo_root()
|
|
||||||
|
|
||||||
from .config import get_packages
|
|
||||||
|
|
||||||
packages = get_packages(repo_root)
|
|
||||||
if not packages or package not in packages:
|
|
||||||
return None
|
|
||||||
|
|
||||||
info = packages[package]
|
|
||||||
if isinstance(info, dict):
|
|
||||||
rel_path = info.get("path", package)
|
|
||||||
else:
|
|
||||||
rel_path = str(info)
|
|
||||||
|
|
||||||
return repo_root / rel_path
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Main Entry (for testing)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
repo = get_repo_root()
|
|
||||||
print(f"Repository root: {repo}")
|
|
||||||
print(f"Developer: {get_developer(repo)}")
|
|
||||||
print(f"Tasks dir: {get_tasks_dir(repo)}")
|
|
||||||
print(f"Workspace dir: {get_workspace_dir(repo)}")
|
|
||||||
print(f"Journal file: {get_active_journal_file(repo)}")
|
|
||||||
print(f"Current task: {get_current_task(repo)}")
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
"""
|
|
||||||
Safe git-add helpers for Trellis-owned paths.
|
|
||||||
|
|
||||||
Why this module exists
|
|
||||||
----------------------
|
|
||||||
A real user incident: a project's `.gitignore` listed `.trellis/` (company-wide
|
|
||||||
template / personal habit). When `add_session.py` and `task.py archive` ran
|
|
||||||
their auto-commit and `git add` failed with `ignored by .gitignore`, the AI
|
|
||||||
agent driving the workflow "fixed" it by retrying with
|
|
||||||
`git add -f .trellis/` — which fan-out-included every ignored subtree
|
|
||||||
(`.trellis/.backup-*/`, `.trellis/worktrees/`, `.trellis/.template-hashes.json`,
|
|
||||||
`.trellis/.runtime/`), committing 548 files / 83474 lines of caches/backups.
|
|
||||||
|
|
||||||
Design
|
|
||||||
------
|
|
||||||
- Scripts only stage SPECIFIC product paths (journal files, index.md, the
|
|
||||||
current task dir, the archive dir). Never the whole `.trellis/` tree.
|
|
||||||
- If plain `git add <specific>` fails with "ignored by", DO NOT retry with
|
|
||||||
``-f``. The presence of `.trellis/` in `.gitignore` is treated as user
|
|
||||||
intent ("keep .trellis/ local-only"). The script warns and skips the
|
|
||||||
auto-commit; users who want auto-staging can either fix their `.gitignore`
|
|
||||||
or set ``session_auto_commit: false`` and manage git themselves.
|
|
||||||
- The warning includes a negative example: ``Do NOT use `git add -f .trellis/` ...``
|
|
||||||
so any AI rereading the log doesn't reinvent the bug.
|
|
||||||
|
|
||||||
History note: 0.5.10 introduced an automatic ``git add -f`` retry on the
|
|
||||||
specific paths. That was reverted in 0.5.11 — auto-forcing into a tree the
|
|
||||||
user had gitignored violates user intent even when the path list is narrow.
|
|
||||||
The wider-grain forbidden command stays forbidden, and the narrow-grain auto
|
|
||||||
``-f`` is gone too.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .git import run_git
|
|
||||||
from .paths import (
|
|
||||||
DIR_ARCHIVE,
|
|
||||||
DIR_TASKS,
|
|
||||||
DIR_WORKFLOW,
|
|
||||||
DIR_WORKSPACE,
|
|
||||||
FILE_JOURNAL_PREFIX,
|
|
||||||
get_developer,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Paths under .trellis/ that must NEVER be auto-staged. Listed here so the
|
|
||||||
# warning to the user can show concrete subpaths to ignore individually
|
|
||||||
# instead of ignoring the whole `.trellis/` tree.
|
|
||||||
TRELLIS_IGNORED_SUBPATHS = (
|
|
||||||
".trellis/.backup-*",
|
|
||||||
".trellis/worktrees/",
|
|
||||||
".trellis/.template-hashes.json",
|
|
||||||
".trellis/.runtime/",
|
|
||||||
".trellis/.cache/",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def safe_trellis_paths_to_add(
|
|
||||||
repo_root: Path,
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
@@ -1,821 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Session context generation (default + record modes).
|
|
||||||
|
|
||||||
Provides:
|
|
||||||
get_context_json - JSON output for default mode
|
|
||||||
get_context_text - Text output for default mode
|
|
||||||
get_context_record_json - JSON for record mode
|
|
||||||
get_context_text_record - Text for record mode
|
|
||||||
output_json - Print JSON
|
|
||||||
output_text - Print text
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .active_task import resolve_context_key
|
|
||||||
from .config import get_git_packages
|
|
||||||
from .git import run_git
|
|
||||||
from .packages_context import get_packages_section
|
|
||||||
from .tasks import iter_active_tasks, load_task, get_all_statuses, children_progress
|
|
||||||
from .paths import (
|
|
||||||
DIR_SCRIPTS,
|
|
||||||
DIR_SPEC,
|
|
||||||
DIR_TASKS,
|
|
||||||
DIR_WORKFLOW,
|
|
||||||
DIR_WORKSPACE,
|
|
||||||
count_lines,
|
|
||||||
get_active_journal_file,
|
|
||||||
get_current_task,
|
|
||||||
get_current_task_source,
|
|
||||||
get_developer,
|
|
||||||
get_repo_root,
|
|
||||||
get_tasks_dir,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Helpers
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
_PACKAGE_NAME = "@mindfoldhq/trellis"
|
|
||||||
_UPDATE_CHECK_TIMEOUT_SECONDS = 1.0
|
|
||||||
_VERSION_RE = re.compile(
|
|
||||||
r"^\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?\s*$"
|
|
||||||
)
|
|
||||||
_VERSION_TOKEN_RE = re.compile(r"\b\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?\b")
|
|
||||||
_POLYREPO_IGNORED_DIRS = {
|
|
||||||
"node_modules",
|
|
||||||
"target",
|
|
||||||
"dist",
|
|
||||||
"build",
|
|
||||||
"out",
|
|
||||||
"bin",
|
|
||||||
"obj",
|
|
||||||
"vendor",
|
|
||||||
"coverage",
|
|
||||||
"tmp",
|
|
||||||
"__pycache__",
|
|
||||||
}
|
|
||||||
_POLYREPO_SCAN_MAX_DEPTH = 2
|
|
||||||
|
|
||||||
|
|
||||||
def _is_git_worktree(path: Path) -> bool:
|
|
||||||
"""Return True when path is inside a Git worktree."""
|
|
||||||
rc, out, _ = run_git(["rev-parse", "--is-inside-work-tree"], cwd=path)
|
|
||||||
return rc == 0 and out.strip().lower() == "true"
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_recent_commits(log_output: str) -> list[dict]:
|
|
||||||
"""Parse `git log --oneline` output into structured commit entries."""
|
|
||||||
commits = []
|
|
||||||
for line in log_output.splitlines():
|
|
||||||
if not line.strip():
|
|
||||||
continue
|
|
||||||
parts = line.split(" ", 1)
|
|
||||||
if len(parts) >= 2:
|
|
||||||
commits.append({"hash": parts[0], "message": parts[1]})
|
|
||||||
elif len(parts) == 1:
|
|
||||||
commits.append({"hash": parts[0], "message": ""})
|
|
||||||
return commits
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_git_repo_info(name: str, rel_path: str, repo_dir: Path) -> dict | None:
|
|
||||||
"""Collect Git status for one known repository directory."""
|
|
||||||
if not (repo_dir / ".git").exists():
|
|
||||||
return None
|
|
||||||
|
|
||||||
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_dir)
|
|
||||||
branch = branch_out.strip() or "unknown"
|
|
||||||
|
|
||||||
_, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_dir)
|
|
||||||
changes = len([l for l in status_out.splitlines() if l.strip()])
|
|
||||||
|
|
||||||
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_dir)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"name": name,
|
|
||||||
"path": rel_path,
|
|
||||||
"branch": branch,
|
|
||||||
"isClean": changes == 0,
|
|
||||||
"uncommittedChanges": changes,
|
|
||||||
"recentCommits": _parse_recent_commits(log_out),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_root_git_info(repo_root: Path) -> dict:
|
|
||||||
"""Collect root Git info without pretending a non-Git root is clean."""
|
|
||||||
if not _is_git_worktree(repo_root):
|
|
||||||
return {
|
|
||||||
"isRepo": False,
|
|
||||||
"branch": "",
|
|
||||||
"isClean": False,
|
|
||||||
"uncommittedChanges": 0,
|
|
||||||
"recentCommits": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
|
|
||||||
branch = branch_out.strip() or "unknown"
|
|
||||||
|
|
||||||
_, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root)
|
|
||||||
status_lines = [line for line in status_out.splitlines() if line.strip()]
|
|
||||||
|
|
||||||
_, short_out, _ = run_git(["status", "--short"], cwd=repo_root)
|
|
||||||
|
|
||||||
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"isRepo": True,
|
|
||||||
"branch": branch,
|
|
||||||
"isClean": len(status_lines) == 0,
|
|
||||||
"uncommittedChanges": len(status_lines),
|
|
||||||
"statusShort": short_out.splitlines(),
|
|
||||||
"recentCommits": _parse_recent_commits(log_out),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _discover_child_git_repos(repo_root: Path) -> list[tuple[str, str]]:
|
|
||||||
"""Discover child Git repositories using the init-time polyrepo heuristic."""
|
|
||||||
found: list[str] = []
|
|
||||||
|
|
||||||
def is_candidate_dir(path: Path) -> bool:
|
|
||||||
name = path.name
|
|
||||||
return not name.startswith(".") and name not in _POLYREPO_IGNORED_DIRS
|
|
||||||
|
|
||||||
def scan(rel_dir: Path, depth: int) -> None:
|
|
||||||
if depth >= _POLYREPO_SCAN_MAX_DEPTH:
|
|
||||||
return
|
|
||||||
abs_dir = repo_root / rel_dir
|
|
||||||
try:
|
|
||||||
children = sorted(abs_dir.iterdir(), key=lambda p: p.name)
|
|
||||||
except OSError:
|
|
||||||
return
|
|
||||||
|
|
||||||
for child in children:
|
|
||||||
if not child.is_dir() or not is_candidate_dir(child):
|
|
||||||
continue
|
|
||||||
|
|
||||||
child_rel = (
|
|
||||||
rel_dir / child.name if rel_dir != Path(".") else Path(child.name)
|
|
||||||
)
|
|
||||||
if (child / ".git").exists():
|
|
||||||
found.append(child_rel.as_posix())
|
|
||||||
continue
|
|
||||||
scan(child_rel, depth + 1)
|
|
||||||
|
|
||||||
scan(Path("."), 0)
|
|
||||||
if len(found) < 2:
|
|
||||||
return []
|
|
||||||
return [(path.replace("/", "_"), path) for path in sorted(found)]
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_package_git_info(
|
|
||||||
repo_root: Path,
|
|
||||||
discover_unconfigured: bool = False,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Collect Git status for independent package repositories.
|
|
||||||
|
|
||||||
Packages marked with ``git: true`` in config.yaml are authoritative.
|
|
||||||
When the Trellis root is not a Git repo and no configured package repos are
|
|
||||||
available, optionally fall back to the bounded polyrepo child scan.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of dicts with keys: name, path, branch, isClean,
|
|
||||||
uncommittedChanges, recentCommits.
|
|
||||||
Empty list if no git-repo packages are configured.
|
|
||||||
"""
|
|
||||||
git_pkgs = get_git_packages(repo_root)
|
|
||||||
result = []
|
|
||||||
for pkg_name, pkg_path in git_pkgs.items():
|
|
||||||
pkg_dir = repo_root / pkg_path
|
|
||||||
info = _collect_git_repo_info(pkg_name, pkg_path, pkg_dir)
|
|
||||||
if info is not None:
|
|
||||||
result.append(info)
|
|
||||||
|
|
||||||
if result or not discover_unconfigured:
|
|
||||||
return result
|
|
||||||
|
|
||||||
discovered = []
|
|
||||||
for pkg_name, pkg_path in _discover_child_git_repos(repo_root):
|
|
||||||
info = _collect_git_repo_info(pkg_name, pkg_path, repo_root / pkg_path)
|
|
||||||
if info is not None:
|
|
||||||
discovered.append(info)
|
|
||||||
return discovered
|
|
||||||
|
|
||||||
|
|
||||||
def _append_root_git_context(lines: list[str], root_git_info: dict) -> None:
|
|
||||||
"""Append root Git status without misleading non-Git roots."""
|
|
||||||
lines.append("## GIT STATUS")
|
|
||||||
if not root_git_info["isRepo"]:
|
|
||||||
lines.append("Root is not a Git repository.")
|
|
||||||
lines.append("Run Git commands from the package repository paths listed below.")
|
|
||||||
else:
|
|
||||||
lines.append(f"Branch: {root_git_info['branch']}")
|
|
||||||
if root_git_info["isClean"]:
|
|
||||||
lines.append("Working directory: Clean")
|
|
||||||
else:
|
|
||||||
lines.append(
|
|
||||||
f"Working directory: {root_git_info['uncommittedChanges']} "
|
|
||||||
"uncommitted change(s)"
|
|
||||||
)
|
|
||||||
lines.append("")
|
|
||||||
lines.append("Changes:")
|
|
||||||
for line in root_git_info.get("statusShort", [])[:10]:
|
|
||||||
lines.append(line)
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
lines.append("## RECENT COMMITS")
|
|
||||||
if not root_git_info["isRepo"]:
|
|
||||||
lines.append(
|
|
||||||
"Root has no Git commit history because it is not a Git repository."
|
|
||||||
)
|
|
||||||
elif root_git_info["recentCommits"]:
|
|
||||||
for commit in root_git_info["recentCommits"]:
|
|
||||||
lines.append(f"{commit['hash']} {commit['message']}")
|
|
||||||
else:
|
|
||||||
lines.append("(no commits)")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
|
|
||||||
def _append_package_git_context(lines: list[str], package_git_info: list[dict]) -> None:
|
|
||||||
"""Append Git status and recent commits for package repositories."""
|
|
||||||
for pkg in package_git_info:
|
|
||||||
lines.append(f"## GIT STATUS ({pkg['name']}: {pkg['path']})")
|
|
||||||
lines.append(f"Branch: {pkg['branch']}")
|
|
||||||
if pkg["isClean"]:
|
|
||||||
lines.append("Working directory: Clean")
|
|
||||||
else:
|
|
||||||
lines.append(
|
|
||||||
f"Working directory: {pkg['uncommittedChanges']} uncommitted change(s)"
|
|
||||||
)
|
|
||||||
lines.append("")
|
|
||||||
lines.append(f"## RECENT COMMITS ({pkg['name']}: {pkg['path']})")
|
|
||||||
if pkg["recentCommits"]:
|
|
||||||
for commit in pkg["recentCommits"]:
|
|
||||||
lines.append(f"{commit['hash']} {commit['message']}")
|
|
||||||
else:
|
|
||||||
lines.append("(no commits)")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
|
|
||||||
def _read_project_version(repo_root: Path) -> str | None:
|
|
||||||
try:
|
|
||||||
version = (repo_root / DIR_WORKFLOW / ".version").read_text(
|
|
||||||
encoding="utf-8"
|
|
||||||
).strip()
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
return version or None
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_trellis_version_output() -> str | None:
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["trellis", "--version"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
encoding="utf-8",
|
|
||||||
errors="replace",
|
|
||||||
timeout=_UPDATE_CHECK_TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
except (OSError, subprocess.SubprocessError, TimeoutError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
return None
|
|
||||||
output = f"{result.stdout}\n{result.stderr}".strip()
|
|
||||||
return output or None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_available_update_version(output: str) -> str | None:
|
|
||||||
update_match = re.search(
|
|
||||||
r"Trellis update available:\s*"
|
|
||||||
r"(?P<current>\S+)\s*(?:→|->)\s*(?P<latest>\S+)",
|
|
||||||
output,
|
|
||||||
)
|
|
||||||
if update_match:
|
|
||||||
return update_match.group("latest").strip()
|
|
||||||
candidates = _VERSION_TOKEN_RE.findall(output)
|
|
||||||
return candidates[-1] if candidates else None
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_available_update_version() -> str | None:
|
|
||||||
output = _fetch_trellis_version_output()
|
|
||||||
if not output:
|
|
||||||
return None
|
|
||||||
return _extract_available_update_version(output)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_version(version: str) -> tuple[tuple[int, int, int], tuple[str, ...] | None] | None:
|
|
||||||
match = _VERSION_RE.match(version)
|
|
||||||
if not match:
|
|
||||||
return None
|
|
||||||
major, minor, patch, prerelease = match.groups()
|
|
||||||
numbers = (int(major), int(minor or "0"), int(patch or "0"))
|
|
||||||
prerelease_parts = tuple(prerelease.split(".")) if prerelease else None
|
|
||||||
return numbers, prerelease_parts
|
|
||||||
|
|
||||||
|
|
||||||
def _compare_prerelease(
|
|
||||||
left: tuple[str, ...] | None,
|
|
||||||
right: tuple[str, ...] | None,
|
|
||||||
) -> int:
|
|
||||||
if left is None and right is None:
|
|
||||||
return 0
|
|
||||||
if left is None:
|
|
||||||
return 1
|
|
||||||
if right is None:
|
|
||||||
return -1
|
|
||||||
|
|
||||||
for left_part, right_part in zip(left, right):
|
|
||||||
if left_part == right_part:
|
|
||||||
continue
|
|
||||||
left_numeric = left_part.isdigit()
|
|
||||||
right_numeric = right_part.isdigit()
|
|
||||||
if left_numeric and right_numeric:
|
|
||||||
left_int = int(left_part)
|
|
||||||
right_int = int(right_part)
|
|
||||||
return (left_int > right_int) - (left_int < right_int)
|
|
||||||
if left_numeric:
|
|
||||||
return -1
|
|
||||||
if right_numeric:
|
|
||||||
return 1
|
|
||||||
return (left_part > right_part) - (left_part < right_part)
|
|
||||||
|
|
||||||
return (len(left) > len(right)) - (len(left) < len(right))
|
|
||||||
|
|
||||||
|
|
||||||
def _compare_versions(left: str, right: str) -> int | None:
|
|
||||||
parsed_left = _parse_version(left)
|
|
||||||
parsed_right = _parse_version(right)
|
|
||||||
if parsed_left is None or parsed_right is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
left_numbers, left_prerelease = parsed_left
|
|
||||||
right_numbers, right_prerelease = parsed_right
|
|
||||||
if left_numbers != right_numbers:
|
|
||||||
return (left_numbers > right_numbers) - (left_numbers < right_numbers)
|
|
||||||
return _compare_prerelease(left_prerelease, right_prerelease)
|
|
||||||
|
|
||||||
|
|
||||||
def _update_marker_path(repo_root: Path) -> Path:
|
|
||||||
context_key = resolve_context_key()
|
|
||||||
if not context_key:
|
|
||||||
terminal_key = os.environ.get("TERM_SESSION_ID", "").strip()
|
|
||||||
context_key = terminal_key or f"ppid-{os.getppid()}"
|
|
||||||
safe_key = re.sub(r"[^A-Za-z0-9._-]+", "_", context_key).strip("._-")
|
|
||||||
if not safe_key:
|
|
||||||
safe_key = "session"
|
|
||||||
return (
|
|
||||||
repo_root
|
|
||||||
/ DIR_WORKFLOW
|
|
||||||
/ ".runtime"
|
|
||||||
/ f"update-check-{safe_key[:160]}.marker"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _mark_update_check_attempted(repo_root: Path) -> bool:
|
|
||||||
marker_path = _update_marker_path(repo_root)
|
|
||||||
if marker_path.exists():
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
marker_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
marker_path.write_text("checked\n", encoding="utf-8")
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _get_update_hint(repo_root: Path) -> str | None:
|
|
||||||
marker_path = _update_marker_path(repo_root)
|
|
||||||
if marker_path.exists():
|
|
||||||
return None
|
|
||||||
|
|
||||||
current_version = _read_project_version(repo_root)
|
|
||||||
if not current_version:
|
|
||||||
return None
|
|
||||||
|
|
||||||
latest_version = _resolve_available_update_version()
|
|
||||||
if not latest_version:
|
|
||||||
return None
|
|
||||||
|
|
||||||
_mark_update_check_attempted(repo_root)
|
|
||||||
comparison = _compare_versions(current_version, latest_version)
|
|
||||||
if comparison is None or comparison >= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return (
|
|
||||||
f"Trellis update available: {current_version} -> {latest_version}, "
|
|
||||||
"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))
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Task JSONL context management.
|
|
||||||
|
|
||||||
Provides:
|
|
||||||
cmd_add_context - Add entry to JSONL context file
|
|
||||||
cmd_validate - Validate JSONL context files
|
|
||||||
cmd_list_context - List JSONL context entries
|
|
||||||
|
|
||||||
Note:
|
|
||||||
``cmd_init_context`` was removed in v0.5.0-beta.12. JSONL context files
|
|
||||||
are now seeded at ``task.py create`` time with a self-describing
|
|
||||||
``_example`` line; the AI agent curates real entries during 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
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Task queue utility functions.
|
|
||||||
|
|
||||||
Provides:
|
|
||||||
list_tasks_by_status - List tasks by status
|
|
||||||
list_pending_tasks - List tasks with pending status
|
|
||||||
list_tasks_by_assignee - List tasks by assignee
|
|
||||||
list_my_tasks - List tasks assigned to current developer
|
|
||||||
get_task_stats - Get P0/P1/P2/P3 counts
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .paths import (
|
|
||||||
get_repo_root,
|
|
||||||
get_developer,
|
|
||||||
get_tasks_dir,
|
|
||||||
)
|
|
||||||
from .tasks import iter_active_tasks
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Internal helper
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def _task_to_dict(t) -> dict:
|
|
||||||
"""Convert TaskInfo to the dict format callers expect."""
|
|
||||||
return {
|
|
||||||
"priority": t.priority,
|
|
||||||
"id": t.raw.get("id", ""),
|
|
||||||
"title": t.title,
|
|
||||||
"status": t.status,
|
|
||||||
"assignee": t.assignee or "-",
|
|
||||||
"dir": t.dir_name,
|
|
||||||
"children": list(t.children),
|
|
||||||
"parent": t.parent,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Public Functions
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def list_tasks_by_status(
|
|
||||||
filter_status: str | None = None,
|
|
||||||
repo_root: Path | None = None
|
|
||||||
) -> list[dict]:
|
|
||||||
"""List tasks by status.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filter_status: Optional status filter.
|
|
||||||
repo_root: Repository root path. Defaults to auto-detected.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of task info dicts with keys: priority, id, title, status, assignee.
|
|
||||||
"""
|
|
||||||
if repo_root is None:
|
|
||||||
repo_root = get_repo_root()
|
|
||||||
|
|
||||||
tasks_dir = get_tasks_dir(repo_root)
|
|
||||||
results = []
|
|
||||||
|
|
||||||
for t in iter_active_tasks(tasks_dir):
|
|
||||||
if filter_status and t.status != filter_status:
|
|
||||||
continue
|
|
||||||
results.append(_task_to_dict(t))
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def list_pending_tasks(repo_root: Path | None = None) -> list[dict]:
|
|
||||||
"""List pending tasks.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
repo_root: Repository root path. Defaults to auto-detected.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of task info dicts.
|
|
||||||
"""
|
|
||||||
return list_tasks_by_status("planning", repo_root)
|
|
||||||
|
|
||||||
|
|
||||||
def list_tasks_by_assignee(
|
|
||||||
assignee: str,
|
|
||||||
filter_status: str | None = None,
|
|
||||||
repo_root: Path | None = None
|
|
||||||
) -> list[dict]:
|
|
||||||
"""List tasks assigned to a specific developer.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
assignee: Developer name.
|
|
||||||
filter_status: Optional status filter.
|
|
||||||
repo_root: Repository root path. Defaults to auto-detected.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of task info dicts.
|
|
||||||
"""
|
|
||||||
if repo_root is None:
|
|
||||||
repo_root = get_repo_root()
|
|
||||||
|
|
||||||
tasks_dir = get_tasks_dir(repo_root)
|
|
||||||
results = []
|
|
||||||
|
|
||||||
for t in iter_active_tasks(tasks_dir):
|
|
||||||
if (t.assignee or "-") != assignee:
|
|
||||||
continue
|
|
||||||
if filter_status and t.status != filter_status:
|
|
||||||
continue
|
|
||||||
results.append(_task_to_dict(t))
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def list_my_tasks(
|
|
||||||
filter_status: str | None = None,
|
|
||||||
repo_root: Path | None = None
|
|
||||||
) -> list[dict]:
|
|
||||||
"""List tasks assigned to current developer.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filter_status: Optional status filter.
|
|
||||||
repo_root: Repository root path. Defaults to auto-detected.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of task info dicts.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If developer not set.
|
|
||||||
"""
|
|
||||||
if repo_root is None:
|
|
||||||
repo_root = get_repo_root()
|
|
||||||
|
|
||||||
developer = get_developer(repo_root)
|
|
||||||
if not developer:
|
|
||||||
raise ValueError("Developer not set")
|
|
||||||
|
|
||||||
return list_tasks_by_assignee(developer, filter_status, repo_root)
|
|
||||||
|
|
||||||
|
|
||||||
def get_task_stats(repo_root: Path | None = None) -> dict[str, int]:
|
|
||||||
"""Get task statistics.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
repo_root: Repository root path. Defaults to auto-detected.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with keys: P0, P1, P2, P3, Total.
|
|
||||||
"""
|
|
||||||
if repo_root is None:
|
|
||||||
repo_root = get_repo_root()
|
|
||||||
|
|
||||||
tasks_dir = get_tasks_dir(repo_root)
|
|
||||||
stats = {"P0": 0, "P1": 0, "P2": 0, "P3": 0, "Total": 0}
|
|
||||||
|
|
||||||
for t in iter_active_tasks(tasks_dir):
|
|
||||||
if t.priority in stats:
|
|
||||||
stats[t.priority] += 1
|
|
||||||
stats["Total"] += 1
|
|
||||||
|
|
||||||
return stats
|
|
||||||
|
|
||||||
|
|
||||||
def format_task_stats(stats: dict[str, int]) -> str:
|
|
||||||
"""Format task stats as string.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
stats: Stats dict from get_task_stats.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Formatted string like "P0:0 P1:1 P2:2 P3:0 Total:3".
|
|
||||||
"""
|
|
||||||
return f"P0:{stats['P0']} P1:{stats['P1']} P2:{stats['P2']} P3:{stats['P3']} Total:{stats['Total']}"
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Main Entry (for testing)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
stats = get_task_stats()
|
|
||||||
print(format_task_stats(stats))
|
|
||||||
print()
|
|
||||||
print("Pending tasks:")
|
|
||||||
for task in list_pending_tasks():
|
|
||||||
print(f" {task['priority']}|{task['id']}|{task['title']}|{task['status']}|{task['assignee']}")
|
|
||||||
@@ -1,746 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Task CRUD operations.
|
|
||||||
|
|
||||||
Provides:
|
|
||||||
ensure_tasks_dir - Ensure tasks directory exists
|
|
||||||
cmd_create - Create a new task
|
|
||||||
cmd_archive - Archive completed task
|
|
||||||
cmd_set_branch - Set git branch for task
|
|
||||||
cmd_set_base_branch - Set PR target branch
|
|
||||||
cmd_set_scope - Set scope for PR title
|
|
||||||
cmd_add_subtask - Link child task to parent
|
|
||||||
cmd_remove_subtask - Unlink child task from parent
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .config import (
|
|
||||||
get_packages,
|
|
||||||
get_session_auto_commit,
|
|
||||||
is_monorepo,
|
|
||||||
resolve_package,
|
|
||||||
validate_package,
|
|
||||||
)
|
|
||||||
from .git import run_git
|
|
||||||
from .io import read_json, write_json
|
|
||||||
from .log import Colors, colored
|
|
||||||
from .paths import (
|
|
||||||
DIR_ARCHIVE,
|
|
||||||
DIR_TASKS,
|
|
||||||
DIR_WORKFLOW,
|
|
||||||
FILE_TASK_JSON,
|
|
||||||
generate_task_date_prefix,
|
|
||||||
get_developer,
|
|
||||||
get_repo_root,
|
|
||||||
get_tasks_dir,
|
|
||||||
)
|
|
||||||
from .safe_commit import (
|
|
||||||
print_gitignore_warning,
|
|
||||||
safe_archive_paths_to_add,
|
|
||||||
safe_git_add,
|
|
||||||
)
|
|
||||||
from .task_utils import (
|
|
||||||
archive_task_complete,
|
|
||||||
find_task_by_name,
|
|
||||||
resolve_task_dir,
|
|
||||||
run_task_hooks,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Helper Functions
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def _slugify(title: str) -> str:
|
|
||||||
"""Convert title to slug (only works with ASCII)."""
|
|
||||||
result = title.lower()
|
|
||||||
result = re.sub(r"[^a-z0-9]", "-", result)
|
|
||||||
result = re.sub(r"-+", "-", result)
|
|
||||||
result = result.strip("-")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_tasks_dir(repo_root: Path) -> Path:
|
|
||||||
"""Ensure tasks directory exists."""
|
|
||||||
tasks_dir = get_tasks_dir(repo_root)
|
|
||||||
archive_dir = tasks_dir / "archive"
|
|
||||||
|
|
||||||
if not tasks_dir.exists():
|
|
||||||
tasks_dir.mkdir(parents=True)
|
|
||||||
print(colored(f"Created tasks directory: {tasks_dir}", Colors.GREEN), file=sys.stderr)
|
|
||||||
|
|
||||||
if not archive_dir.exists():
|
|
||||||
archive_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
return tasks_dir
|
|
||||||
|
|
||||||
|
|
||||||
def _find_archived_task_by_dir_name(tasks_dir: Path, dir_name: str) -> Path | None:
|
|
||||||
"""Find an archived task directory with the exact active-task dir name."""
|
|
||||||
archive_dir = tasks_dir / DIR_ARCHIVE
|
|
||||||
if not archive_dir.is_dir():
|
|
||||||
return None
|
|
||||||
|
|
||||||
for month_dir in sorted(archive_dir.iterdir()):
|
|
||||||
if not month_dir.is_dir():
|
|
||||||
continue
|
|
||||||
candidate = month_dir / dir_name
|
|
||||||
if candidate.is_dir():
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _repo_relative_path(path: Path, repo_root: Path) -> str:
|
|
||||||
"""Format a path relative to the repo root when possible."""
|
|
||||||
try:
|
|
||||||
return path.relative_to(repo_root).as_posix()
|
|
||||||
except ValueError:
|
|
||||||
return str(path)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Sub-agent platform detection + JSONL seeding
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# Config directories of platforms that consume implement.jsonl / check.jsonl.
|
|
||||||
# Keep in sync with src/types/ai-tools.ts AI_TOOLS entries — these are the
|
|
||||||
# platforms listed in workflow.md's "agent-capable" Skill Routing block
|
|
||||||
# (Class-1 hook-inject + Class-2 pull-based preludes). Kilo / Antigravity /
|
|
||||||
# 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
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Task utility functions.
|
|
||||||
|
|
||||||
Provides:
|
|
||||||
is_safe_task_path - Validate task path is safe to operate on
|
|
||||||
find_task_by_name - Find task directory by name
|
|
||||||
resolve_task_dir - Resolve task directory from name, relative, or absolute path
|
|
||||||
archive_task_dir - Archive task to monthly directory
|
|
||||||
run_task_hooks - Run lifecycle hooks for task events
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .paths import get_repo_root, get_tasks_dir
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Path Safety
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def is_safe_task_path(task_path: str, repo_root: Path | None = None) -> bool:
|
|
||||||
"""Check if a relative task path is safe to operate on.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task_path: Task path (relative to repo_root).
|
|
||||||
repo_root: Repository root path. Defaults to auto-detected.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if safe, False if dangerous.
|
|
||||||
"""
|
|
||||||
if repo_root is None:
|
|
||||||
repo_root = get_repo_root()
|
|
||||||
|
|
||||||
normalized = task_path.replace("\\", "/")
|
|
||||||
|
|
||||||
# Check empty or null
|
|
||||||
if not normalized or normalized == "null":
|
|
||||||
print("Error: empty or null task path", file=sys.stderr)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Reject absolute paths
|
|
||||||
if Path(task_path).is_absolute():
|
|
||||||
print(f"Error: absolute path not allowed: {task_path}", file=sys.stderr)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Reject ".", "..", paths starting with "./" or "../", or containing ".."
|
|
||||||
if normalized in (".", "..") or normalized.startswith("./") or normalized.startswith("../") or ".." in normalized:
|
|
||||||
print(f"Error: path traversal not allowed: {task_path}", file=sys.stderr)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Final check: ensure resolved path is not the repo root
|
|
||||||
abs_path = repo_root / Path(normalized)
|
|
||||||
if abs_path.exists():
|
|
||||||
try:
|
|
||||||
resolved = abs_path.resolve()
|
|
||||||
root_resolved = repo_root.resolve()
|
|
||||||
if resolved == root_resolved:
|
|
||||||
print(f"Error: path resolves to repo root: {task_path}", file=sys.stderr)
|
|
||||||
return False
|
|
||||||
except (OSError, IOError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Task Lookup
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None:
|
|
||||||
"""Find task directory by name (exact or suffix match).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task_name: Task name to find.
|
|
||||||
tasks_dir: Tasks directory path.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Absolute path to task directory, or None if not found.
|
|
||||||
"""
|
|
||||||
if not task_name or not tasks_dir or not tasks_dir.is_dir():
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Try exact match first
|
|
||||||
exact_match = tasks_dir / task_name
|
|
||||||
if exact_match.is_dir():
|
|
||||||
return exact_match
|
|
||||||
|
|
||||||
# Try suffix match (e.g., "my-task" matches "01-21-my-task")
|
|
||||||
for d in tasks_dir.iterdir():
|
|
||||||
if d.is_dir() and d.name.endswith(f"-{task_name}"):
|
|
||||||
return d
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Archive Operations
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path | None:
|
|
||||||
"""Archive a task directory to archive/{YYYY-MM}/.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task_dir_abs: Absolute path to task directory.
|
|
||||||
repo_root: Repository root path. Defaults to auto-detected.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to archived directory, or None on error.
|
|
||||||
"""
|
|
||||||
if not task_dir_abs.is_dir():
|
|
||||||
print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Get tasks directory (parent of the task)
|
|
||||||
tasks_dir = task_dir_abs.parent
|
|
||||||
archive_dir = tasks_dir / "archive"
|
|
||||||
year_month = datetime.now().strftime("%Y-%m")
|
|
||||||
month_dir = archive_dir / year_month
|
|
||||||
|
|
||||||
# Create archive directory
|
|
||||||
try:
|
|
||||||
month_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
except (OSError, IOError) as e:
|
|
||||||
print(f"Error: Failed to create archive directory: {e}", file=sys.stderr)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Move task to archive
|
|
||||||
task_name = task_dir_abs.name
|
|
||||||
dest = month_dir / task_name
|
|
||||||
|
|
||||||
try:
|
|
||||||
shutil.move(str(task_dir_abs), str(dest))
|
|
||||||
except (OSError, IOError, shutil.Error) as e:
|
|
||||||
print(f"Error: Failed to move task to archive: {e}", file=sys.stderr)
|
|
||||||
return None
|
|
||||||
|
|
||||||
return dest
|
|
||||||
|
|
||||||
|
|
||||||
def archive_task_complete(
|
|
||||||
task_dir_abs: Path,
|
|
||||||
repo_root: Path | None = None
|
|
||||||
) -> dict[str, str]:
|
|
||||||
"""Complete archive workflow: archive directory.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task_dir_abs: Absolute path to task directory.
|
|
||||||
repo_root: Repository root path. Defaults to auto-detected.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with archive result info.
|
|
||||||
"""
|
|
||||||
if not task_dir_abs.is_dir():
|
|
||||||
print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
archive_dest = archive_task_dir(task_dir_abs, repo_root)
|
|
||||||
if archive_dest:
|
|
||||||
return {"archived_to": str(archive_dest)}
|
|
||||||
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Task Directory Resolution
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def resolve_task_dir(target_dir: str, repo_root: Path) -> Path:
|
|
||||||
"""Resolve task directory to absolute path.
|
|
||||||
|
|
||||||
Supports:
|
|
||||||
- Absolute path: /path/to/task
|
|
||||||
- Relative path: .trellis/tasks/01-31-my-task
|
|
||||||
- Task name: my-task (uses find_task_by_name for lookup)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
target_dir: Task directory specification.
|
|
||||||
repo_root: Repository root path.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Resolved absolute path.
|
|
||||||
"""
|
|
||||||
if not target_dir:
|
|
||||||
return Path()
|
|
||||||
|
|
||||||
normalized = target_dir.replace("\\", "/")
|
|
||||||
while normalized.startswith("./"):
|
|
||||||
normalized = normalized[2:]
|
|
||||||
|
|
||||||
# Absolute path
|
|
||||||
if Path(target_dir).is_absolute():
|
|
||||||
return Path(target_dir)
|
|
||||||
|
|
||||||
# Relative path (contains path separator or starts with .trellis)
|
|
||||||
if "/" in normalized or normalized.startswith(".trellis"):
|
|
||||||
return repo_root / Path(normalized)
|
|
||||||
|
|
||||||
# Task name - try to find in tasks directory
|
|
||||||
tasks_dir = get_tasks_dir(repo_root)
|
|
||||||
found = find_task_by_name(target_dir, tasks_dir)
|
|
||||||
if found:
|
|
||||||
return found
|
|
||||||
|
|
||||||
# Fallback to treating as relative path
|
|
||||||
return repo_root / Path(normalized)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Lifecycle Hooks
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def run_task_hooks(event: str, task_json_path: Path, repo_root: Path) -> None:
|
|
||||||
"""Run lifecycle hooks for a task event.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event: Event name (e.g. "after_create").
|
|
||||||
task_json_path: Absolute path to the task's task.json.
|
|
||||||
repo_root: Repository root for cwd and config lookup.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from .config import get_hooks
|
|
||||||
from .log import Colors, colored
|
|
||||||
|
|
||||||
commands = get_hooks(event, repo_root)
|
|
||||||
if not commands:
|
|
||||||
return
|
|
||||||
|
|
||||||
env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)}
|
|
||||||
|
|
||||||
for cmd in commands:
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
shell=True,
|
|
||||||
cwd=repo_root,
|
|
||||||
env=env,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
encoding="utf-8",
|
|
||||||
errors="replace",
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
print(
|
|
||||||
colored(f"[WARN] Hook failed ({event}): {cmd}", Colors.YELLOW),
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
if result.stderr.strip():
|
|
||||||
print(f" {result.stderr.strip()}", file=sys.stderr)
|
|
||||||
except Exception as e:
|
|
||||||
print(
|
|
||||||
colored(f"[WARN] Hook error ({event}): {cmd} — {e}", Colors.YELLOW),
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Main Entry (for testing)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
repo = get_repo_root()
|
|
||||||
tasks = get_tasks_dir(repo)
|
|
||||||
|
|
||||||
print(f"Tasks dir: {tasks}")
|
|
||||||
print(f"is_safe_task_path('.trellis/tasks/test'): {is_safe_task_path('.trellis/tasks/test', repo)}")
|
|
||||||
print(f"is_safe_task_path('../test'): {is_safe_task_path('../test', repo)}")
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
"""
|
|
||||||
Task data access layer.
|
|
||||||
|
|
||||||
Single source of truth for loading and iterating task directories.
|
|
||||||
Replaces scattered task.json parsing across 9+ files.
|
|
||||||
|
|
||||||
Provides:
|
|
||||||
load_task — Load a single task by directory path
|
|
||||||
iter_active_tasks — Iterate all non-archived tasks (sorted)
|
|
||||||
get_all_statuses — Get {dir_name: status} map for children progress
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Iterator
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .io import read_json
|
|
||||||
from .paths import FILE_TASK_JSON
|
|
||||||
from .types import TaskInfo
|
|
||||||
|
|
||||||
|
|
||||||
def load_task(task_dir: Path) -> TaskInfo | None:
|
|
||||||
"""Load task from a directory containing task.json.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task_dir: Absolute path to the task directory.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TaskInfo if task.json exists and is valid, None otherwise.
|
|
||||||
"""
|
|
||||||
task_json = task_dir / FILE_TASK_JSON
|
|
||||||
if not task_json.is_file():
|
|
||||||
return None
|
|
||||||
|
|
||||||
data = read_json(task_json)
|
|
||||||
if not data:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return TaskInfo(
|
|
||||||
dir_name=task_dir.name,
|
|
||||||
directory=task_dir,
|
|
||||||
title=data.get("title") or data.get("name") or "unknown",
|
|
||||||
status=data.get("status", "unknown"),
|
|
||||||
assignee=data.get("assignee", ""),
|
|
||||||
priority=data.get("priority", "P2"),
|
|
||||||
children=tuple(data.get("children", [])),
|
|
||||||
parent=data.get("parent"),
|
|
||||||
package=data.get("package"),
|
|
||||||
raw=data,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def iter_active_tasks(tasks_dir: Path) -> Iterator[TaskInfo]:
|
|
||||||
"""Iterate all active (non-archived) tasks, sorted by directory name.
|
|
||||||
|
|
||||||
Skips the "archive" directory and directories without valid task.json.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tasks_dir: Path to the tasks directory.
|
|
||||||
|
|
||||||
Yields:
|
|
||||||
TaskInfo for each valid task.
|
|
||||||
"""
|
|
||||||
if not tasks_dir.is_dir():
|
|
||||||
return
|
|
||||||
|
|
||||||
for d in sorted(tasks_dir.iterdir()):
|
|
||||||
if not d.is_dir() or d.name == "archive":
|
|
||||||
continue
|
|
||||||
info = load_task(d)
|
|
||||||
if info is not None:
|
|
||||||
yield info
|
|
||||||
|
|
||||||
|
|
||||||
def get_all_statuses(tasks_dir: Path) -> dict[str, str]:
|
|
||||||
"""Get a {dir_name: status} mapping for all active tasks.
|
|
||||||
|
|
||||||
Useful for computing children progress without loading full TaskInfo.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tasks_dir: Path to the tasks directory.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict mapping directory names to status strings.
|
|
||||||
"""
|
|
||||||
return {t.dir_name: t.status for t in iter_active_tasks(tasks_dir)}
|
|
||||||
|
|
||||||
|
|
||||||
def children_progress(
|
|
||||||
children: tuple[str, ...] | list[str],
|
|
||||||
all_statuses: dict[str, str],
|
|
||||||
) -> str:
|
|
||||||
"""Format children progress string like " [2/3 done]".
|
|
||||||
|
|
||||||
Args:
|
|
||||||
children: List of child directory names.
|
|
||||||
all_statuses: Status map from get_all_statuses().
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Formatted string, or "" if no children.
|
|
||||||
"""
|
|
||||||
if not children:
|
|
||||||
return ""
|
|
||||||
# A child missing from active statuses has been archived (cmd_archive
|
|
||||||
# sets status=completed before moving the dir). Count it as done so
|
|
||||||
# parent progress doesn't regress when children are archived.
|
|
||||||
done = sum(
|
|
||||||
1 for c in children
|
|
||||||
if c not in all_statuses or all_statuses.get(c) in ("completed", "done")
|
|
||||||
)
|
|
||||||
return f" [{done}/{len(children)} done]"
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Standalone reader for .trellis/config.yaml.
|
|
||||||
|
|
||||||
Mirrors a minimal subset of common.config so callers (hooks, workflow_phase)
|
|
||||||
can read configuration without importing the full task/repo helpers. Returns
|
|
||||||
an empty dict on missing/malformed files so callers stay simple.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
CONFIG_REL_PATH = ".trellis/config.yaml"
|
|
||||||
|
|
||||||
|
|
||||||
def _unquote(value: str) -> str:
|
|
||||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
|
|
||||||
return value[1:-1]
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_inline_comment(value: str) -> str:
|
|
||||||
"""Strip ` # …` inline comments while preserving `#` inside quoted strings.
|
|
||||||
|
|
||||||
YAML treats ` #` (space-hash) as a comment opener; bare `#` inside a token
|
|
||||||
is part of the value. Quoted strings are immune.
|
|
||||||
"""
|
|
||||||
in_quote: str | None = None
|
|
||||||
for idx, ch in enumerate(value):
|
|
||||||
if in_quote:
|
|
||||||
if ch == in_quote:
|
|
||||||
in_quote = None
|
|
||||||
continue
|
|
||||||
if ch in ('"', "'"):
|
|
||||||
in_quote = ch
|
|
||||||
continue
|
|
||||||
if ch == "#" and (idx == 0 or value[idx - 1].isspace()):
|
|
||||||
return value[:idx]
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _next_content_line(lines: list[str], start: int) -> tuple[int, str]:
|
|
||||||
i = start
|
|
||||||
while i < len(lines):
|
|
||||||
stripped = lines[i].strip()
|
|
||||||
if stripped and not stripped.startswith("#"):
|
|
||||||
return i, lines[i]
|
|
||||||
i += 1
|
|
||||||
return i, ""
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_yaml_block(
|
|
||||||
lines: list[str], start: int, min_indent: int, target: dict
|
|
||||||
) -> int:
|
|
||||||
i = start
|
|
||||||
current_list: list | None = None
|
|
||||||
|
|
||||||
while i < len(lines):
|
|
||||||
line = lines[i]
|
|
||||||
stripped = line.strip()
|
|
||||||
|
|
||||||
if not stripped or stripped.startswith("#"):
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
indent = len(line) - len(line.lstrip())
|
|
||||||
if indent < min_indent:
|
|
||||||
break
|
|
||||||
|
|
||||||
if stripped.startswith("- "):
|
|
||||||
if current_list is not None:
|
|
||||||
current_list.append(_unquote(stripped[2:].strip()))
|
|
||||||
i += 1
|
|
||||||
elif ":" in stripped:
|
|
||||||
key, _, value = stripped.partition(":")
|
|
||||||
key = key.strip()
|
|
||||||
value = _strip_inline_comment(value).strip()
|
|
||||||
value = _unquote(value)
|
|
||||||
current_list = None
|
|
||||||
|
|
||||||
if value:
|
|
||||||
target[key] = value
|
|
||||||
i += 1
|
|
||||||
else:
|
|
||||||
next_i, next_line = _next_content_line(lines, i + 1)
|
|
||||||
if next_i >= len(lines):
|
|
||||||
target[key] = {}
|
|
||||||
i = next_i
|
|
||||||
elif next_line.strip().startswith("- "):
|
|
||||||
current_list = []
|
|
||||||
target[key] = current_list
|
|
||||||
i += 1
|
|
||||||
else:
|
|
||||||
next_indent = len(next_line) - len(next_line.lstrip())
|
|
||||||
if next_indent > indent:
|
|
||||||
nested: dict = {}
|
|
||||||
target[key] = nested
|
|
||||||
i = _parse_yaml_block(lines, i + 1, next_indent, nested)
|
|
||||||
else:
|
|
||||||
target[key] = {}
|
|
||||||
i += 1
|
|
||||||
else:
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
return i
|
|
||||||
|
|
||||||
|
|
||||||
def parse_simple_yaml(content: str) -> dict:
|
|
||||||
"""Parse a small subset of YAML. See common.config for full doc."""
|
|
||||||
lines = content.splitlines()
|
|
||||||
result: dict = {}
|
|
||||||
_parse_yaml_block(lines, 0, 0, result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def read_trellis_config(repo_root: Optional[Path] = None) -> dict:
|
|
||||||
"""Read .trellis/config.yaml. Returns {} on missing or malformed file."""
|
|
||||||
root = repo_root or Path.cwd()
|
|
||||||
config_file = root / CONFIG_REL_PATH
|
|
||||||
try:
|
|
||||||
content = config_file.read_text(encoding="utf-8")
|
|
||||||
except (FileNotFoundError, OSError):
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
parsed = parse_simple_yaml(content)
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
return parsed if isinstance(parsed, dict) else {}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
"""
|
|
||||||
Core type definitions for Trellis task data.
|
|
||||||
|
|
||||||
Provides:
|
|
||||||
TaskData — TypedDict for task.json shape (read-path type hints only)
|
|
||||||
TaskInfo — Frozen dataclass for loaded task (the public API type)
|
|
||||||
AgentRecord — TypedDict for registry.json agent entries
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TypedDict
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# task.json shape (TypedDict — used only for read-path type hints)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
class TaskData(TypedDict, total=False):
|
|
||||||
"""Shape of task.json on disk.
|
|
||||||
|
|
||||||
Used only for type annotations when reading task.json.
|
|
||||||
Writes must use the original dict to avoid losing unknown fields.
|
|
||||||
"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
title: str
|
|
||||||
description: str
|
|
||||||
status: str
|
|
||||||
dev_type: str
|
|
||||||
scope: str | None
|
|
||||||
package: str | None
|
|
||||||
priority: str
|
|
||||||
creator: str
|
|
||||||
assignee: str
|
|
||||||
createdAt: str
|
|
||||||
completedAt: str | None
|
|
||||||
branch: str | None
|
|
||||||
base_branch: str | None
|
|
||||||
worktree_path: str | None
|
|
||||||
commit: str | None
|
|
||||||
pr_url: str | None
|
|
||||||
subtasks: list[str]
|
|
||||||
children: list[str]
|
|
||||||
parent: str | None
|
|
||||||
relatedFiles: list[str]
|
|
||||||
notes: str
|
|
||||||
meta: dict
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Loaded task object (frozen dataclass — the public API type)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TaskInfo:
|
|
||||||
"""Immutable view of a loaded task.
|
|
||||||
|
|
||||||
Created by load_task() / iter_active_tasks().
|
|
||||||
Contains the commonly accessed fields; the original dict
|
|
||||||
is preserved in `raw` for write-back and uncommon field access.
|
|
||||||
"""
|
|
||||||
|
|
||||||
dir_name: str
|
|
||||||
directory: Path
|
|
||||||
title: str
|
|
||||||
status: str
|
|
||||||
assignee: str
|
|
||||||
priority: str
|
|
||||||
children: tuple[str, ...]
|
|
||||||
parent: str | None
|
|
||||||
package: str | None
|
|
||||||
raw: dict # original dict — use for writes and uncommon fields
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
"""Task name (id or name field)."""
|
|
||||||
return self.raw.get("name") or self.raw.get("id") or self.dir_name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return self.raw.get("description", "")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def branch(self) -> str | None:
|
|
||||||
return self.raw.get("branch")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def meta(self) -> dict:
|
|
||||||
return self.raw.get("meta", {})
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# registry.json agent entry
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
class AgentRecord(TypedDict, total=False):
|
|
||||||
"""Shape of an agent entry in registry.json."""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
pid: int
|
|
||||||
task_dir: str
|
|
||||||
worktree_path: str
|
|
||||||
branch: str
|
|
||||||
platform: str
|
|
||||||
started_at: str
|
|
||||||
status: str
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Workflow Phase Extraction.
|
|
||||||
|
|
||||||
Extracts step-level content from .trellis/workflow.md and optionally filters
|
|
||||||
platform-specific blocks.
|
|
||||||
|
|
||||||
Platform marker syntax in workflow.md:
|
|
||||||
|
|
||||||
[Claude Code, Cursor, ...]
|
|
||||||
agent-capable content
|
|
||||||
[/Claude Code, Cursor, ...]
|
|
||||||
|
|
||||||
Provides:
|
|
||||||
get_phase_index - Extract the Phase Index section (no --step)
|
|
||||||
get_step - Extract a single step (#### X.X) section
|
|
||||||
filter_platform - Strip platform blocks that don't include the given name
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
from .paths import DIR_WORKFLOW, get_repo_root
|
|
||||||
|
|
||||||
|
|
||||||
def _workflow_md_path():
|
|
||||||
return get_repo_root() / DIR_WORKFLOW / "workflow.md"
|
|
||||||
|
|
||||||
# Match a line that *is* a platform marker: "[A, B, C]" or "[/A, B, C]"
|
|
||||||
_MARKER_RE = re.compile(r"^\[(/?)([A-Za-z][^\[\]]*)\]\s*$")
|
|
||||||
|
|
||||||
# Step heading: "#### 1.0 Title" or "#### 1.0 ..."
|
|
||||||
_STEP_HEADING_RE = re.compile(r"^####\s+(\d+\.\d+)\b.*$")
|
|
||||||
|
|
||||||
# Phase Index starts here; Phase 1/2/3 step bodies follow; ends at Breadcrumbs.
|
|
||||||
_PHASE_INDEX_HEADING = "## Phase Index"
|
|
||||||
|
|
||||||
|
|
||||||
def _read_workflow() -> str:
|
|
||||||
path = _workflow_md_path()
|
|
||||||
if not path.exists():
|
|
||||||
raise FileNotFoundError(f"workflow.md not found: {path}")
|
|
||||||
return path.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_marker(line: str) -> tuple[bool, list[str]] | None:
|
|
||||||
"""Parse a platform marker line.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(is_closing, [platform_names]) if line is a marker, else None.
|
|
||||||
"""
|
|
||||||
m = _MARKER_RE.match(line)
|
|
||||||
if not m:
|
|
||||||
return None
|
|
||||||
is_closing = m.group(1) == "/"
|
|
||||||
names = [p.strip() for p in m.group(2).split(",") if p.strip()]
|
|
||||||
return is_closing, names
|
|
||||||
|
|
||||||
|
|
||||||
def get_phase_index() -> str:
|
|
||||||
"""Return 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"
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Get current developer name.
|
|
||||||
|
|
||||||
This is a wrapper that uses common/paths.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from common.paths import get_developer
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
"""CLI entry point."""
|
|
||||||
developer = get_developer()
|
|
||||||
if developer:
|
|
||||||
print(developer)
|
|
||||||
else:
|
|
||||||
print("Developer not initialized", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Linear sync hook for Trellis task lifecycle.
|
|
||||||
|
|
||||||
Syncs task events to Linear via the `linearis` CLI.
|
|
||||||
|
|
||||||
Usage (called automatically by task.py hooks):
|
|
||||||
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)
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -1,500 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
# Code Reuse Thinking Guide
|
|
||||||
|
|
||||||
> **Purpose**: Stop and think before creating new code - does it already exist?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The Problem
|
|
||||||
|
|
||||||
**Duplicated code is the #1 source of inconsistency bugs.**
|
|
||||||
|
|
||||||
When you copy-paste or rewrite existing logic:
|
|
||||||
- Bug fixes don't propagate
|
|
||||||
- Behavior diverges over time
|
|
||||||
- Codebase becomes harder to understand
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Before Writing New Code
|
|
||||||
|
|
||||||
### Step 1: Search First
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Search for similar function names
|
|
||||||
grep -r "functionName" .
|
|
||||||
|
|
||||||
# Search for similar logic
|
|
||||||
grep -r "keyword" .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Ask These Questions
|
|
||||||
|
|
||||||
| Question | If Yes... |
|
|
||||||
|----------|-----------|
|
|
||||||
| Does a similar function exist? | Use or extend it |
|
|
||||||
| Is this pattern used elsewhere? | Follow the existing pattern |
|
|
||||||
| Could this be a shared utility? | Create it in the right place |
|
|
||||||
| Am I copying code from another file? | **STOP** - extract to shared |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Duplication Patterns
|
|
||||||
|
|
||||||
### Pattern 1: Copy-Paste Functions
|
|
||||||
|
|
||||||
**Bad**: Copying a validation function to another file
|
|
||||||
|
|
||||||
**Good**: Extract to shared utilities, import where needed
|
|
||||||
|
|
||||||
### Pattern 2: Similar Components
|
|
||||||
|
|
||||||
**Bad**: Creating a new component that's 80% similar to existing
|
|
||||||
|
|
||||||
**Good**: Extend existing component with props/variants
|
|
||||||
|
|
||||||
### Pattern 3: Repeated Constants
|
|
||||||
|
|
||||||
**Bad**: Defining the same constant in multiple files
|
|
||||||
|
|
||||||
**Good**: Single source of truth, import everywhere
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
# Cross-Layer Thinking Guide
|
|
||||||
|
|
||||||
> **Purpose**: Think through data flow across layers before implementing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The Problem
|
|
||||||
|
|
||||||
**Most bugs happen at layer boundaries**, not within layers.
|
|
||||||
|
|
||||||
Common cross-layer bugs:
|
|
||||||
|
|
||||||
- API returns format A, frontend expects format B
|
|
||||||
- Database stores X, service transforms to Y, but loses data
|
|
||||||
- Multiple layers implement the same logic differently
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Before Implementing Cross-Layer Features
|
|
||||||
|
|
||||||
### Step 1: Map the Data Flow
|
|
||||||
|
|
||||||
Draw out how data moves:
|
|
||||||
|
|
||||||
```
|
|
||||||
Source → Transform → Store → Retrieve → Transform → Display
|
|
||||||
```
|
|
||||||
|
|
||||||
For each arrow, ask:
|
|
||||||
|
|
||||||
- What format is the data in?
|
|
||||||
- What could go wrong?
|
|
||||||
- Who is responsible for validation?
|
|
||||||
|
|
||||||
### Step 2: Identify Boundaries
|
|
||||||
|
|
||||||
| Boundary | Common Issues |
|
|
||||||
| --------------------- | --------------------------------- |
|
|
||||||
| API ↔ Service | Type mismatches, missing fields |
|
|
||||||
| Service ↔ Database | Format conversions, null handling |
|
|
||||||
| Backend ↔ Frontend | Serialization, date formats |
|
|
||||||
| Component ↔ Component | Props shape changes |
|
|
||||||
|
|
||||||
### Step 3: Define Contracts
|
|
||||||
|
|
||||||
For each boundary:
|
|
||||||
|
|
||||||
- What is the exact input format?
|
|
||||||
- What is the exact output format?
|
|
||||||
- What errors can occur?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Cross-Layer Mistakes
|
|
||||||
|
|
||||||
### Mistake 1: Implicit Format Assumptions
|
|
||||||
|
|
||||||
**Bad**: Assuming date format without checking
|
|
||||||
|
|
||||||
**Good**: Explicit format conversion at boundaries
|
|
||||||
|
|
||||||
### Mistake 2: Scattered Validation
|
|
||||||
|
|
||||||
**Bad**: Validating the same thing in multiple layers
|
|
||||||
|
|
||||||
**Good**: Validate once at the entry point
|
|
||||||
|
|
||||||
### Mistake 3: Leaky Abstractions
|
|
||||||
|
|
||||||
**Bad**: Component knows about database schema
|
|
||||||
|
|
||||||
**Good**: Each layer only knows its neighbors
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
# Thinking Guides
|
|
||||||
|
|
||||||
> **Purpose**: Expand your thinking to catch things you might not have considered.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why Thinking Guides?
|
|
||||||
|
|
||||||
**Most bugs and tech debt come from "didn't think of that"**, not from lack of skill:
|
|
||||||
|
|
||||||
- Didn't think about what happens at layer boundaries → cross-layer bugs
|
|
||||||
- Didn't think about code patterns repeating → duplicated code everywhere
|
|
||||||
- Didn't think about edge cases → runtime errors
|
|
||||||
- Didn't think about future maintainers → unreadable code
|
|
||||||
|
|
||||||
These guides help you **ask the right questions before coding**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Available Guides
|
|
||||||
|
|
||||||
| Guide | Purpose | When to Use |
|
|
||||||
|-------|---------|-------------|
|
|
||||||
| [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md) | Identify patterns and reduce duplication | When you notice repeated patterns |
|
|
||||||
| [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) | Think through data flow across layers | Features spanning multiple layers |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Reference: Thinking Triggers
|
|
||||||
|
|
||||||
### When to Think About Cross-Layer Issues
|
|
||||||
|
|
||||||
- [ ] Feature touches 3+ layers (API, Service, Component, Database)
|
|
||||||
- [ ] Data format changes between layers
|
|
||||||
- [ ] Multiple consumers need the same data
|
|
||||||
- [ ] You're not sure where to put some logic
|
|
||||||
- [ ] 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.
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# Component Guidelines
|
|
||||||
|
|
||||||
> How components are built in this project.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Document your project's component conventions here.
|
|
||||||
|
|
||||||
Questions to answer:
|
|
||||||
- What component patterns do you use?
|
|
||||||
- How are props defined?
|
|
||||||
- How do you handle composition?
|
|
||||||
- What accessibility standards apply?
|
|
||||||
-->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Component Structure
|
|
||||||
|
|
||||||
<!-- Standard structure of a component file -->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Props Conventions
|
|
||||||
|
|
||||||
<!-- How props should be defined and typed -->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Styling Patterns
|
|
||||||
|
|
||||||
<!-- How styles are applied (CSS modules, styled-components, Tailwind, etc.) -->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Accessibility
|
|
||||||
|
|
||||||
<!-- A11y requirements and patterns -->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Mistakes
|
|
||||||
|
|
||||||
<!-- Component-related mistakes your team has made -->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# Directory Structure
|
|
||||||
|
|
||||||
> How frontend code is organized in this project.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Document your project's frontend directory structure here.
|
|
||||||
|
|
||||||
Questions to answer:
|
|
||||||
- Where do components live?
|
|
||||||
- How are features/modules organized?
|
|
||||||
- Where are shared utilities?
|
|
||||||
- How are assets organized?
|
|
||||||
-->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Directory Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
<!-- Replace with your actual structure -->
|
|
||||||
src/
|
|
||||||
├── ...
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Module Organization
|
|
||||||
|
|
||||||
<!-- How should new features be organized? -->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Naming Conventions
|
|
||||||
|
|
||||||
<!-- File and folder naming rules -->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
<!-- Link to well-organized modules as examples -->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# Hook Guidelines
|
|
||||||
|
|
||||||
> How hooks are used in this project.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Document your project's hook conventions here.
|
|
||||||
|
|
||||||
Questions to answer:
|
|
||||||
- What custom hooks do you have?
|
|
||||||
- How do you handle data fetching?
|
|
||||||
- What are the naming conventions?
|
|
||||||
- How do you share stateful logic?
|
|
||||||
-->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Custom Hook Patterns
|
|
||||||
|
|
||||||
<!-- How to create and structure custom hooks -->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Fetching
|
|
||||||
|
|
||||||
<!-- How data fetching is handled (React Query, SWR, etc.) -->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Naming Conventions
|
|
||||||
|
|
||||||
<!-- Hook naming rules (use*, etc.) -->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Mistakes
|
|
||||||
|
|
||||||
<!-- Hook-related mistakes your team has made -->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Frontend Development Guidelines
|
|
||||||
|
|
||||||
> Best practices for frontend development in this project.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This directory contains guidelines for frontend development. Fill in each file with your project's specific conventions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Guidelines Index
|
|
||||||
|
|
||||||
| Guide | Description | Status |
|
|
||||||
|-------|-------------|--------|
|
|
||||||
| [Directory Structure](./directory-structure.md) | Module organization and file layout | To fill |
|
|
||||||
| [Component Guidelines](./component-guidelines.md) | Component patterns, props, composition | To fill |
|
|
||||||
| [Hook Guidelines](./hook-guidelines.md) | Custom hooks, data fetching patterns | To fill |
|
|
||||||
| [State Management](./state-management.md) | Local state, global state, server state | 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**.
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# Quality Guidelines
|
|
||||||
|
|
||||||
> Code quality standards for frontend development.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Document your project's quality standards here.
|
|
||||||
|
|
||||||
Questions to answer:
|
|
||||||
- What patterns are forbidden?
|
|
||||||
- What linting rules do you enforce?
|
|
||||||
- What are your testing requirements?
|
|
||||||
- What code review standards apply?
|
|
||||||
-->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Forbidden Patterns
|
|
||||||
|
|
||||||
<!-- Patterns that should never be used and why -->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Required Patterns
|
|
||||||
|
|
||||||
<!-- Patterns that must always be used -->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Requirements
|
|
||||||
|
|
||||||
<!-- What level of testing is expected -->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Code Review Checklist
|
|
||||||
|
|
||||||
<!-- What reviewers should check -->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# 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)
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# Type Safety
|
|
||||||
|
|
||||||
> Type safety patterns in this project.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Document your project's type safety conventions here.
|
|
||||||
|
|
||||||
Questions to answer:
|
|
||||||
- What type system do you use?
|
|
||||||
- How are types organized?
|
|
||||||
- What validation library do you use?
|
|
||||||
- How do you handle type inference?
|
|
||||||
-->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Type Organization
|
|
||||||
|
|
||||||
<!-- Where types are defined, shared types vs local types -->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
<!-- Runtime validation patterns (Zod, Yup, io-ts, etc.) -->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
<!-- Type utilities, generics, type guards -->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Forbidden Patterns
|
|
||||||
|
|
||||||
<!-- any, type assertions, etc. -->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
# Bootstrap Task: Fill Project Development Guidelines
|
|
||||||
|
|
||||||
**You (the AI) are running this task. The developer does not read this file.**
|
|
||||||
|
|
||||||
The developer just ran `trellis init` on this project for the first time.
|
|
||||||
`.trellis/` now exists with empty spec scaffolding, and this bootstrap task
|
|
||||||
exists under `.trellis/tasks/`. When they want to work on it, they should start
|
|
||||||
this task from a session that provides Trellis session identity.
|
|
||||||
|
|
||||||
**Your job**: help them populate `.trellis/spec/` with the team's real
|
|
||||||
coding conventions. Every future AI session — this project's
|
|
||||||
`trellis-implement` and `trellis-check` sub-agents — auto-loads spec files
|
|
||||||
listed in per-task jsonl manifests. Empty spec = sub-agents write generic
|
|
||||||
code. Real spec = sub-agents match the team's actual patterns.
|
|
||||||
|
|
||||||
Don't dump instructions. Open with a short greeting, figure out if the repo
|
|
||||||
has any existing convention docs (CLAUDE.md, .cursorrules, etc.), and drive
|
|
||||||
the rest conversationally.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Status (update the checkboxes as you complete each item)
|
|
||||||
|
|
||||||
- [ ] Fill 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?"
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "00-bootstrap-guidelines",
|
|
||||||
"name": "00-bootstrap-guidelines",
|
|
||||||
"title": "Bootstrap Guidelines",
|
|
||||||
"description": "Fill in project development guidelines for AI agents",
|
|
||||||
"status": "in_progress",
|
|
||||||
"dev_type": "docs",
|
|
||||||
"scope": null,
|
|
||||||
"package": null,
|
|
||||||
"priority": "P1",
|
|
||||||
"creator": "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": {}
|
|
||||||
}
|
|
||||||
@@ -1,706 +0,0 @@
|
|||||||
# Development Workflow
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Core Principles
|
|
||||||
|
|
||||||
1. **Plan before code** — figure out what to do before you start
|
|
||||||
2. **Specs injected, not remembered** — guidelines are injected via hook/skill, not recalled from memory
|
|
||||||
3. **Persist everything** — research, decisions, and lessons all go to files; conversations get compacted, files don't
|
|
||||||
4. **Incremental development** — one task at a time
|
|
||||||
5. **Capture learnings** — after each task, review and write new knowledge back to spec
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Trellis System
|
|
||||||
|
|
||||||
### Developer Identity
|
|
||||||
|
|
||||||
On first use, initialize your identity:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
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)
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# Journal - boos4721 (Part 1)
|
|
||||||
|
|
||||||
> AI development session journal
|
|
||||||
> Started: 2026-06-20
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
# Workspace Index
|
|
||||||
|
|
||||||
> Records of all AI Agent work records across all developers
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This directory tracks records for all developers working with AI Agents on this project.
|
|
||||||
|
|
||||||
### File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
workspace/
|
|
||||||
|-- index.md # This file - main index
|
|
||||||
+-- {developer}/ # Per-developer directory
|
|
||||||
|-- index.md # Personal index with session history
|
|
||||||
|-- tasks/ # Task files
|
|
||||||
| |-- *.json # Active tasks
|
|
||||||
| +-- archive/ # Archived tasks by month
|
|
||||||
+-- journal-N.md # Journal files (sequential: 1, 2, 3...)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Active Developers
|
|
||||||
|
|
||||||
| Developer | Last Active | Sessions | Active File |
|
|
||||||
|-----------|-------------|----------|-------------|
|
|
||||||
| (none yet) | - | - | - |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
### For New Developers
|
|
||||||
|
|
||||||
Run the initialization script:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
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**.
|
|
||||||
@@ -45,25 +45,6 @@ ComfyUI Agent 默认走 **本地模式**:复用本服务自身的 `/v1/*` 路
|
|||||||
- 【TTS】 → 自动调用 TTS API
|
- 【TTS】 → 自动调用 TTS API
|
||||||
- 【WORKFLOW】 → 输出 ComfyUI 工作流 JSON
|
- 【WORKFLOW】 → 输出 ComfyUI 工作流 JSON
|
||||||
|
|
||||||
## 即梦 Provider(图片 + 视频)
|
|
||||||
|
|
||||||
火山引擎「智能视觉 / CV」异步生成 provider,图片与视频共用一套「提交 → 轮询 → 转存 → 计费」状态机,靠 `capability → req_key` 映射区分能力。
|
|
||||||
|
|
||||||
- **能力映射**(`services/jimeng/types.rs` 单一事实源):
|
|
||||||
- 图片 `JimengCapability::Image` → `req_key = jimeng_t2i_v40`,轮询 ~2s / 超时 ~120s,产物 `image_urls[]`(或 `binary_data_base64[]`),24h 有效
|
|
||||||
- 视频 `JimengCapability::Video` → `req_key = jimeng_ti2v_v30_pro`,轮询 ~8s / 超时 ~600s,产物 `video_url`,**仅 1h 有效**
|
|
||||||
- 新增能力(视频3.0 / OmniHuman 等)只加枚举分支,submit/poll/转存逻辑不动
|
|
||||||
- **签名只当签名器**:用 `volc-visual-sdk` 的 `VisualClient::build_signed_request`(纯签名 V4),HTTP 走 metapi 自己的 async `state.http`,绝不调 SDK 的 blocking `request()`(依赖以 `default-features = false` 引入,不拉 `reqwest::blocking`)。Action 显式传 `CVSync2AsyncSubmitTask` / `CVSync2AsyncGetResult`(Version `2022-08-31`,走 query),业务参数走 JSON body。
|
|
||||||
- **解析铁律**:先判外层 `code == 10000` 再读 `data.status`(错误响应 `data:null`,顺序反了会 panic);错误码可重试性见 `types::classify`(输入审核类不可重试,QPS/输出审核类可重试,内部错误码视频可重试、图片不重试)。
|
|
||||||
- **任务模型零迁移**:复用 `background_tasks`(`task_type = jimeng-image-generation` / `jimeng-video-generation`,本地 id `jmi_*` / `jmv_*`),火山 `task_id` 存进 `result_json.volcTaskId`,进度 1=已提交 / 2=生成中 / 3=转存中 / 4=完成。
|
|
||||||
- **产物转存**:轮询到 `done` 立即 `workspace::record_remote_asset`(视频 1h 过期硬约束,绝不透传火山临时 URL),对外只发本地 `public_id` + `/api/user/workspace/assets/{id}/file`。
|
|
||||||
- **计费**:复用 `UsageTracker` 的 `record_comfy_task_success/failure`(`model = req_key`,单价来自 `config.jimeng.{image_cost,video_cost}`,首版默认 0)。
|
|
||||||
- **路由**(鉴权照 `picture_agent` 的 bearer / JWT 解析):
|
|
||||||
- `POST /api/jimeng/image/jobs`、`POST /api/jimeng/video/jobs` → 立即返回本地 task_id,后台 `tokio::spawn` 轮询
|
|
||||||
- `GET /api/jimeng/jobs`、`GET /api/jimeng/jobs/{id}`、`POST /api/jimeng/jobs/{id}/cancel`、`GET /api/jimeng/status`
|
|
||||||
- **配置**:`[jimeng]` 段,AK/SK 走 env(`BOOSAPI_JIMENG__ACCESS_KEY` / `__SECRET_KEY` / 可选 `__SESSION_TOKEN`),不硬编码;`enabled=false` 时路由返回 503「能力未启用」。
|
|
||||||
- **首版范围**:图生视频只支持 `image_urls`(公网图 URL),前端上传 → 转存留二期;`frames ∈ {121(5s), 241(10s)}`,`aspect_ratio` 白名单。
|
|
||||||
|
|
||||||
## 运行
|
## 运行
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -390,7 +390,6 @@ 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",
|
||||||
@@ -408,14 +407,12 @@ dependencies = [
|
|||||||
"mime",
|
"mime",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"open-agent-sdk",
|
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rstest",
|
"rstest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
|
||||||
"sha2",
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -429,9 +426,7 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"tracing-test",
|
"tracing-test",
|
||||||
"url",
|
|
||||||
"uuid",
|
"uuid",
|
||||||
"volc-visual-sdk",
|
|
||||||
"wiremock",
|
"wiremock",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1828,31 +1823,6 @@ 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"
|
||||||
@@ -2558,19 +2528,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde_yaml"
|
|
||||||
version = "0.9.34+deprecated"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
|
||||||
dependencies = [
|
|
||||||
"indexmap",
|
|
||||||
"itoa",
|
|
||||||
"ryu",
|
|
||||||
"serde",
|
|
||||||
"unsafe-libyaml",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha1"
|
name = "sha1"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -3500,12 +3457,6 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unsafe-libyaml"
|
|
||||||
version = "0.2.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -3572,20 +3523,6 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "volc-visual-sdk"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"chrono",
|
|
||||||
"hex",
|
|
||||||
"hmac",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"sha2",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
"url",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "want"
|
name = "want"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -3853,15 +3790,6 @@ 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"
|
||||||
|
|||||||
@@ -27,13 +27,11 @@ sqlx = { version = "0.8", features = [
|
|||||||
|
|
||||||
# HTTP client
|
# HTTP client
|
||||||
reqwest = { version = "0.12", features = ["stream", "json", "multipart"] }
|
reqwest = { version = "0.12", features = ["stream", "json", "multipart"] }
|
||||||
url = "2"
|
|
||||||
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "pool", "rustls-tls", "smtp-transport"] }
|
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "pool", "rustls-tls", "smtp-transport"] }
|
||||||
|
|
||||||
# Serialization
|
# Serialization
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_yaml = "0.9"
|
|
||||||
regex = "1"
|
regex = "1"
|
||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
@@ -78,18 +76,6 @@ 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"
|
|
||||||
|
|
||||||
# Volcengine Visual (CV) Signature V4 signer for the Jimeng provider. Used as a
|
|
||||||
# pure request signer only (build_signed_request) — the blocking reqwest HTTP
|
|
||||||
# client is disabled (default-features = false) so requests go out over the
|
|
||||||
# shared async reqwest client in AppState instead.
|
|
||||||
volc-visual-sdk = { path = "../volc-visual-sdk", default-features = false }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
rstest = "0.24"
|
rstest = "0.24"
|
||||||
wiremock = "0.6"
|
wiremock = "0.6"
|
||||||
|
|||||||
@@ -347,16 +347,9 @@ docker compose up -d
|
|||||||
│ │ └── ...
|
│ │ └── ...
|
||||||
│ └── components/
|
│ └── components/
|
||||||
├── website/ # 独立官网(Next.js 16 App Router + React 19)
|
├── website/ # 独立官网(Next.js 16 App Router + React 19)
|
||||||
├── desktop/ # BoosAPI Desktop 桌面客户端(Tauri + Vue,独立 Cargo workspace)
|
|
||||||
└── dist/web/ # 前端构建产物(由 Rust serve)
|
└── dist/web/ # 前端构建产物(由 Rust serve)
|
||||||
```
|
```
|
||||||
|
|
||||||
> **`desktop/` 子项目**:内置的 BoosAPI Desktop 跨平台桌面客户端(Tauri + Vue 前端 +
|
|
||||||
> 独立的 `src-tauri/` Rust workspace)。它**自带独立的 Cargo workspace**,不属于后端
|
|
||||||
> `boosapi` crate——在 metapi 根 `cargo check` 只编译后端,桌面端需进入
|
|
||||||
> `desktop/src-tauri` 单独构建。构建步骤见 [`desktop/README.md`](desktop/README.md)。
|
|
||||||
> 注:桌面端图标目前仍是占位资源,待替换为正式 BoosAPI logo。
|
|
||||||
|
|
||||||
## 🔧 环境变量
|
## 🔧 环境变量
|
||||||
|
|
||||||
所有配置通过环境变量覆盖,命名规则:`BOOSAPI_<SECTION>__<KEY>`(双下划线分隔层级):
|
所有配置通过环境变量覆盖,命名规则:`BOOSAPI_<SECTION>__<KEY>`(双下划线分隔层级):
|
||||||
|
|||||||
@@ -227,24 +227,6 @@ configure_fast_linker() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
# Enable sccache as the rustc wrapper when available. sccache caches compiled
|
|
||||||
# crates keyed by source + flags, so dependency rebuilds / clean builds / branch
|
|
||||||
# switches reuse prior output. It transparently skips incremental crates, so the
|
|
||||||
# dev profile still relies mainly on incremental; release/fast-release benefit
|
|
||||||
# most. Set BOOSAPI_SCCACHE=off to disable.
|
|
||||||
configure_sccache() {
|
|
||||||
case "${BOOSAPI_SCCACHE:-auto}" in
|
|
||||||
off|none|0) return 0 ;;
|
|
||||||
esac
|
|
||||||
if [ -n "${RUSTC_WRAPPER:-}" ]; then
|
|
||||||
return 0 # respect an externally configured wrapper
|
|
||||||
fi
|
|
||||||
if command -v sccache >/dev/null 2>&1; then
|
|
||||||
export RUSTC_WRAPPER="sccache"
|
|
||||||
log "Rust 编译缓存: sccache(BOOSAPI_SCCACHE=off 可关闭)"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
server_port() {
|
server_port() {
|
||||||
if [ -n "${BOOSAPI_SERVER__PORT:-}" ]; then
|
if [ -n "${BOOSAPI_SERVER__PORT:-}" ]; then
|
||||||
printf '%s\n' "$BOOSAPI_SERVER__PORT"
|
printf '%s\n' "$BOOSAPI_SERVER__PORT"
|
||||||
@@ -515,7 +497,6 @@ build_backend() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
configure_fast_linker
|
configure_fast_linker
|
||||||
configure_sccache
|
|
||||||
build_cargo_args
|
build_cargo_args
|
||||||
|
|
||||||
if [ "$CHECK_ONLY" -eq 1 ]; then
|
if [ "$CHECK_ONLY" -eq 1 ]; then
|
||||||
@@ -600,33 +581,15 @@ if [ "$FRONTEND_ONLY" -eq 1 ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$BACKEND_ONLY" -ne 1 ]; then
|
if [ "$BACKEND_ONLY" -ne 1 ]; then
|
||||||
|
build_frontend
|
||||||
|
build_public_website
|
||||||
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
|
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
|
||||||
# Production embeds frontend assets into the binary; keep the ordering
|
|
||||||
# explicit (frontend → workflow UI → backend) to avoid a race on the
|
|
||||||
# embedded files. No parallelism here.
|
|
||||||
build_frontend
|
|
||||||
build_public_website
|
|
||||||
ensure_workflow_ui_for_embedding
|
ensure_workflow_ui_for_embedding
|
||||||
build_backend
|
|
||||||
else
|
|
||||||
# Dev / normal builds: frontend (vite) + website (next) run in the
|
|
||||||
# background while the backend (cargo) builds in the foreground. The
|
|
||||||
# backend stays in the main shell so it can still set BINARY and other
|
|
||||||
# globals the launch step depends on. Total time ≈ max(frontend, backend).
|
|
||||||
log "并行构建:前端/官网(后台)+ 后端(前台)..."
|
|
||||||
( build_frontend && build_public_website ) &
|
|
||||||
fe_pid=$!
|
|
||||||
be_status=0
|
|
||||||
build_backend || be_status=$?
|
|
||||||
fe_status=0
|
|
||||||
wait "$fe_pid" || fe_status=$?
|
|
||||||
[ "$be_status" -eq 0 ] || fail "后端构建失败(exit $be_status)"
|
|
||||||
[ "$fe_status" -eq 0 ] || fail "前端/官网构建失败(exit $fe_status)"
|
|
||||||
fi
|
fi
|
||||||
else
|
|
||||||
build_backend
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
build_backend
|
||||||
|
|
||||||
if [ "$CHECK_ONLY" -eq 1 ]; then
|
if [ "$CHECK_ONLY" -eq 1 ]; then
|
||||||
print_timing_summary
|
print_timing_summary
|
||||||
log "跳过启动(--check)"
|
log "跳过启动(--check)"
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
# 必填项保留;空字符串字段、有默认值的字段、与凭证相关的内容都从 toml 移走,
|
|
||||||
# 改用环境变量(BOOSAPI_<SECTION>__<KEY>)或后台 admin 界面写入数据库。
|
|
||||||
|
|
||||||
[server]
|
[server]
|
||||||
host = "0.0.0.0"
|
host = "0.0.0.0"
|
||||||
port = 4000
|
port = 4000
|
||||||
@@ -11,9 +8,13 @@ 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"
|
||||||
@@ -24,11 +25,15 @@ 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 = true
|
enabled = false
|
||||||
# provider: "comfy_cloud" | "stripe"
|
# Public HTTPS origin used to build Stripe success/cancel URLs and webhook docs.
|
||||||
provider = "comfy_cloud"
|
# Example: "https://tybossai.com"
|
||||||
|
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
|
||||||
@@ -37,56 +42,35 @@ 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}"
|
||||||
comfy_customer_api_base_url = "https://api.comfy.org"
|
# Prefer environment variables for secrets:
|
||||||
# 凭证统一走环境变量或后台「支付管理」写入;不要写在这里:
|
# BOOSAPI_PAYMENT__STRIPE_SECRET_KEY=sk_live_...
|
||||||
# BOOSAPI_PAYMENT__PUBLIC_BASE_URL
|
# BOOSAPI_PAYMENT__STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
# BOOSAPI_PAYMENT__STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET
|
stripe_secret_key = ""
|
||||||
# BOOSAPI_PAYMENT__COMFY_CUSTOMER_TOKEN / COMFY_CUSTOMER_TOKEN_ENDPOINT
|
stripe_webhook_secret = ""
|
||||||
# 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 = "deepseek-v4-flash"
|
agent_model = "gpt-5.5"
|
||||||
|
|
||||||
[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
|
|
||||||
|
|
||||||
[jimeng]
|
|
||||||
# 即梦(火山引擎视觉/CV)异步生成 provider:图片 jimeng_t2i_v40 + 视频 jimeng_ti2v_v30_pro。
|
|
||||||
# 默认关闭,配齐 AK/SK 后再开。AK/SK 不要写在这里,走环境变量:
|
|
||||||
# BOOSAPI_JIMENG__ACCESS_KEY / BOOSAPI_JIMENG__SECRET_KEY
|
|
||||||
# BOOSAPI_JIMENG__SESSION_TOKEN(STS 临时凭证,可选)
|
|
||||||
enabled = false
|
|
||||||
region = "cn-north-1"
|
|
||||||
host = "visual.volcengineapi.com"
|
|
||||||
image_poll_interval_secs = 2
|
|
||||||
image_timeout_secs = 120
|
|
||||||
video_poll_interval_secs = 8
|
|
||||||
video_timeout_secs = 600
|
|
||||||
# 计费单价,接 billing;先打通链路默认 0:
|
|
||||||
image_cost = 0.0
|
|
||||||
video_cost = 0.0
|
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
name: 🐛 提交 BUG
|
|
||||||
description: 报告一个 Bug 以帮助我们改进
|
|
||||||
labels: ["bug"]
|
|
||||||
body:
|
|
||||||
- type: dropdown
|
|
||||||
id: os
|
|
||||||
attributes:
|
|
||||||
label: 操作系统
|
|
||||||
options:
|
|
||||||
- macOS
|
|
||||||
- Windows
|
|
||||||
- Linux
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
id: arch
|
|
||||||
attributes:
|
|
||||||
label: 系统架构
|
|
||||||
options:
|
|
||||||
- x86_64 (Intel/AMD)
|
|
||||||
- aarch64 (ARM/Apple Silicon)
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: version
|
|
||||||
attributes:
|
|
||||||
label: BoosApi 版本
|
|
||||||
placeholder: "例如:v1.0.0,可在设置中查看"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: description
|
|
||||||
attributes:
|
|
||||||
label: Bug 描述
|
|
||||||
placeholder: 请清楚地描述遇到的问题
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: steps
|
|
||||||
attributes:
|
|
||||||
label: 复现步骤
|
|
||||||
placeholder: |
|
|
||||||
1. 第一步
|
|
||||||
2. 第二步
|
|
||||||
3. ...
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: expected
|
|
||||||
attributes:
|
|
||||||
label: 期望行为
|
|
||||||
placeholder: 描述你期望看到的正确行为
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: actual
|
|
||||||
attributes:
|
|
||||||
label: 实际行为
|
|
||||||
placeholder: 描述实际发生的错误行为
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: screenshots
|
|
||||||
attributes:
|
|
||||||
label: 截图 / 日志
|
|
||||||
placeholder: 可选:附上截图或相关错误日志
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
name: 💡 提交建议
|
|
||||||
description: 提出你的功能建议或改进想法
|
|
||||||
labels: ["enhancement"]
|
|
||||||
body:
|
|
||||||
- type: textarea
|
|
||||||
id: description
|
|
||||||
attributes:
|
|
||||||
label: 建议描述
|
|
||||||
placeholder: 请清楚地描述你希望添加或改进的功能
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: use-case
|
|
||||||
attributes:
|
|
||||||
label: 使用场景
|
|
||||||
placeholder: 描述在什么情况下你需要这个功能
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: extra
|
|
||||||
attributes:
|
|
||||||
label: 补充说明
|
|
||||||
placeholder: 可选:附加截图、参考链接或其他说明
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
Before Width: | Height: | Size: 443 KiB |
|
Before Width: | Height: | Size: 255 KiB |
|
Before Width: | Height: | Size: 457 KiB |
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 436 KiB |
|
Before Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 650 KiB |
|
Before Width: | Height: | Size: 380 KiB |
|
Before Width: | Height: | Size: 344 KiB |
@@ -1,67 +0,0 @@
|
|||||||
name: Deploy Website to GitHub Pages
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["main"]
|
|
||||||
paths:
|
|
||||||
- "website/**"
|
|
||||||
|
|
||||||
release:
|
|
||||||
types: [released]
|
|
||||||
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: "pages"
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: main
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: website/pnpm-lock.yaml
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
working-directory: website
|
|
||||||
|
|
||||||
- name: Build VitePress site
|
|
||||||
run: pnpm build
|
|
||||||
working-directory: website
|
|
||||||
|
|
||||||
- name: Setup Pages
|
|
||||||
uses: actions/configure-pages@v5
|
|
||||||
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-pages-artifact@v3
|
|
||||||
with:
|
|
||||||
path: website/docs/.vitepress/dist
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
environment:
|
|
||||||
name: github-pages
|
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Deploy to GitHub Pages
|
|
||||||
id: deployment
|
|
||||||
uses: actions/deploy-pages@v5
|
|
||||||
@@ -1,374 +0,0 @@
|
|||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*.*.*'
|
|
||||||
|
|
||||||
permissions: write-all
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_INCREMENTAL: 0
|
|
||||||
RUST_BACKTRACE: short
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: '${{ github.workflow }}-${{ github.ref }}'
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# ── Validate tag matches package.json version ──────────
|
|
||||||
check-version:
|
|
||||||
name: Check Version
|
|
||||||
if: ${{ !contains(github.ref_name, '-test') }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Validate tag vs package.json
|
|
||||||
run: |
|
|
||||||
TAG="${GITHUB_REF_NAME}"
|
|
||||||
PKG_VERSION="v$(jq -r .version package.json)"
|
|
||||||
if [[ "$TAG" != "$PKG_VERSION" ]]; then
|
|
||||||
echo "❌ Tag $TAG does not match package.json $PKG_VERSION"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "✅ Version $TAG is consistent"
|
|
||||||
|
|
||||||
# ── Generate release notes via git-cliff ─────────────
|
|
||||||
draft-release-notes:
|
|
||||||
name: Generate Release Notes
|
|
||||||
needs: [check-version]
|
|
||||||
if: ${{ !contains(github.ref_name, '-test') }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
outputs:
|
|
||||||
body: ${{ steps.cliff.outputs.content }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Generate changelog
|
|
||||||
uses: orhun/git-cliff-action@v4
|
|
||||||
id: cliff
|
|
||||||
with:
|
|
||||||
config: cliff.toml
|
|
||||||
args: --latest --strip header
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
GITHUB_REPO: ${{ github.repository }}
|
|
||||||
CLIFF_REPO: ${{ github.repository }}
|
|
||||||
|
|
||||||
- name: Create draft release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
tag_name: ${{ github.ref_name }}
|
|
||||||
name: 'BoosApi ${{ github.ref_name }}'
|
|
||||||
body: ${{ steps.cliff.outputs.content }}
|
|
||||||
draft: true
|
|
||||||
prerelease: ${{ contains(github.ref_name, '-rc') }}
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
# ── Build for macOS / Windows / Linux x86_64 ───────────
|
|
||||||
release:
|
|
||||||
name: Build (${{ matrix.target }})
|
|
||||||
needs: [check-version, draft-release-notes]
|
|
||||||
if: ${{ !contains(github.ref_name, '-test') }}
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
# macOS
|
|
||||||
- os: macos-latest
|
|
||||||
target: aarch64-apple-darwin
|
|
||||||
- os: macos-latest
|
|
||||||
target: x86_64-apple-darwin
|
|
||||||
# Windows
|
|
||||||
- os: windows-latest
|
|
||||||
target: x86_64-pc-windows-msvc
|
|
||||||
- os: windows-latest
|
|
||||||
target: aarch64-pc-windows-msvc
|
|
||||||
# Linux x86_64
|
|
||||||
- os: ubuntu-22.04
|
|
||||||
target: x86_64-unknown-linux-gnu
|
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Install Rust
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
targets: ${{ matrix.target }}
|
|
||||||
|
|
||||||
- name: Rust cache
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
prefix-key: 'v1'
|
|
||||||
key: '${{ matrix.os }}-${{ matrix.target }}'
|
|
||||||
workspaces: src-tauri -> target
|
|
||||||
|
|
||||||
- name: Install Linux dependencies
|
|
||||||
if: matrix.os == 'ubuntu-22.04'
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y \
|
|
||||||
libwebkit2gtk-4.1-dev \
|
|
||||||
libayatana-appindicator3-dev \
|
|
||||||
librsvg2-dev \
|
|
||||||
patchelf
|
|
||||||
|
|
||||||
- name: Install x86_64 OpenSSL (macOS Intel cross-compile)
|
|
||||||
if: matrix.target == 'x86_64-apple-darwin'
|
|
||||||
run: |
|
|
||||||
arch -x86_64 brew install openssl@3
|
|
||||||
echo "OPENSSL_DIR=$(arch -x86_64 brew --prefix openssl@3)" >> $GITHUB_ENV
|
|
||||||
echo "OPENSSL_INCLUDE_DIR=$(arch -x86_64 brew --prefix openssl@3)/include" >> $GITHUB_ENV
|
|
||||||
echo "OPENSSL_LIB_DIR=$(arch -x86_64 brew --prefix openssl@3)/lib" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Install Protoc
|
|
||||||
uses: arduino/setup-protoc@v3
|
|
||||||
with:
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- name: Get linked dependency versions
|
|
||||||
id: dep-versions
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
SM_SHA=$(git -C libs/stream-monaco rev-parse HEAD)
|
|
||||||
MS_SHA=$(git -C libs/markstream-vue rev-parse HEAD)
|
|
||||||
echo "cache-key=${{ runner.os }}-${{ matrix.target }}-linked-deps-${SM_SHA:0:12}-${MS_SHA:0:12}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Cache linked dependencies
|
|
||||||
id: cache-linked-deps
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
libs/stream-monaco/node_modules
|
|
||||||
libs/stream-monaco/dist
|
|
||||||
libs/markstream-vue/node_modules
|
|
||||||
libs/markstream-vue/packages/markdown-parser/node_modules
|
|
||||||
libs/markstream-vue/packages/markdown-parser/dist
|
|
||||||
libs/markstream-vue/packages/markstream-react/node_modules
|
|
||||||
libs/markstream-vue/packages/markstream-react/dist
|
|
||||||
key: ${{ steps.dep-versions.outputs.cache-key }}
|
|
||||||
|
|
||||||
- name: Build linked dependencies
|
|
||||||
if: steps.cache-linked-deps.outputs.cache-hit != 'true'
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
pnpm --dir libs/stream-monaco install --frozen-lockfile
|
|
||||||
pnpm --dir libs/stream-monaco build
|
|
||||||
|
|
||||||
pnpm --dir libs/markstream-vue install --frozen-lockfile
|
|
||||||
pnpm --dir libs/markstream-vue run build:parser
|
|
||||||
pnpm --dir libs/markstream-vue/packages/markstream-react build
|
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build with Tauri
|
|
||||||
uses: tauri-apps/tauri-action@v0
|
|
||||||
env:
|
|
||||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
||||||
APPLE_SIGNING_IDENTITY: ${{ matrix.os == 'macos-latest' && '-' || '' }}
|
|
||||||
with:
|
|
||||||
tagName: ${{ github.ref_name }}
|
|
||||||
releaseName: 'BoosApi ${{ github.ref_name }}'
|
|
||||||
releaseBody: ${{ needs.draft-release-notes.outputs.body }}
|
|
||||||
releaseDraft: true
|
|
||||||
prerelease: ${{ contains(github.ref_name, '-rc') }}
|
|
||||||
tauriScript: pnpm tauri
|
|
||||||
args: --target ${{ matrix.target }}
|
|
||||||
includeUpdaterJson: true
|
|
||||||
|
|
||||||
- name: Package Windows portable (zip)
|
|
||||||
if: contains(matrix.target, 'windows')
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
$exe = "src-tauri/target/${{ matrix.target }}/release/BoosApi.exe"
|
|
||||||
$arch = if ("${{ matrix.target }}" -match "aarch64") { "arm64" } else { "x64" }
|
|
||||||
$zipName = "BoosApi_${{ github.ref_name }}_windows-${arch}-portable.zip"
|
|
||||||
Compress-Archive -Path $exe -DestinationPath $zipName
|
|
||||||
|
|
||||||
- name: Upload Windows portable to release
|
|
||||||
if: contains(matrix.target, 'windows')
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
tag_name: ${{ github.ref_name }}
|
|
||||||
draft: true
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
files: BoosApi_${{ github.ref_name }}_windows-*-portable.zip
|
|
||||||
|
|
||||||
# ── Build for Linux ARM64 (cross-compile) ──────────────
|
|
||||||
release-linux-arm:
|
|
||||||
name: Build (aarch64-unknown-linux-gnu)
|
|
||||||
needs: [check-version, draft-release-notes]
|
|
||||||
if: ${{ !contains(github.ref_name, '-test') }}
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Install Rust
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
targets: aarch64-unknown-linux-gnu
|
|
||||||
|
|
||||||
- name: Rust cache
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
prefix-key: 'v1'
|
|
||||||
key: 'ubuntu-22.04-aarch64-unknown-linux-gnu'
|
|
||||||
workspaces: src-tauri -> target
|
|
||||||
|
|
||||||
- name: Setup cross-compilation sources
|
|
||||||
run: |
|
|
||||||
cat > /tmp/sources.list << 'EOF'
|
|
||||||
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy main multiverse universe restricted
|
|
||||||
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy-security main multiverse universe restricted
|
|
||||||
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy-updates main multiverse universe restricted
|
|
||||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy main multiverse universe restricted
|
|
||||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security main multiverse universe restricted
|
|
||||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates main multiverse universe restricted
|
|
||||||
EOF
|
|
||||||
sudo mv /etc/apt/sources.list /etc/apt/sources.list.bak
|
|
||||||
sudo mv /tmp/sources.list /etc/apt/sources.list
|
|
||||||
sudo dpkg --add-architecture arm64
|
|
||||||
sudo apt-get update
|
|
||||||
|
|
||||||
- name: Install ARM64 dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get install -y \
|
|
||||||
gcc-aarch64-linux-gnu \
|
|
||||||
g++-aarch64-linux-gnu \
|
|
||||||
libwebkit2gtk-4.1-dev:arm64 \
|
|
||||||
libayatana-appindicator3-dev:arm64 \
|
|
||||||
librsvg2-dev:arm64 \
|
|
||||||
libssl-dev:arm64 \
|
|
||||||
patchelf:arm64
|
|
||||||
|
|
||||||
- name: Install Protoc
|
|
||||||
uses: arduino/setup-protoc@v3
|
|
||||||
with:
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- name: Get linked dependency versions
|
|
||||||
id: dep-versions
|
|
||||||
run: |
|
|
||||||
SM_SHA=$(git -C libs/stream-monaco rev-parse HEAD)
|
|
||||||
MS_SHA=$(git -C libs/markstream-vue rev-parse HEAD)
|
|
||||||
echo "cache-key=${{ runner.os }}-aarch64-unknown-linux-gnu-linked-deps-${SM_SHA:0:12}-${MS_SHA:0:12}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Cache linked dependencies
|
|
||||||
id: cache-linked-deps
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
libs/stream-monaco/node_modules
|
|
||||||
libs/stream-monaco/dist
|
|
||||||
libs/markstream-vue/node_modules
|
|
||||||
libs/markstream-vue/packages/markdown-parser/node_modules
|
|
||||||
libs/markstream-vue/packages/markdown-parser/dist
|
|
||||||
libs/markstream-vue/packages/markstream-react/node_modules
|
|
||||||
libs/markstream-vue/packages/markstream-react/dist
|
|
||||||
key: ${{ steps.dep-versions.outputs.cache-key }}
|
|
||||||
|
|
||||||
- name: Build linked dependencies
|
|
||||||
if: steps.cache-linked-deps.outputs.cache-hit != 'true'
|
|
||||||
run: |
|
|
||||||
pnpm --dir libs/stream-monaco install --frozen-lockfile
|
|
||||||
pnpm --dir libs/stream-monaco build
|
|
||||||
|
|
||||||
pnpm --dir libs/markstream-vue install --frozen-lockfile
|
|
||||||
pnpm --dir libs/markstream-vue run build:parser
|
|
||||||
pnpm --dir libs/markstream-vue/packages/markstream-react build
|
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build for Linux ARM64
|
|
||||||
run: |
|
|
||||||
export PKG_CONFIG_ALLOW_CROSS=1
|
|
||||||
export PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig/:$PKG_CONFIG_PATH
|
|
||||||
export PKG_CONFIG_SYSROOT_DIR=/usr/aarch64-linux-gnu/
|
|
||||||
pnpm tauri build --target aarch64-unknown-linux-gnu --bundles deb,rpm
|
|
||||||
env:
|
|
||||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
|
||||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
|
|
||||||
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
|
|
||||||
CXX_aarch64_unknown_linux_gnu: aarch64-linux-gnu-g++
|
|
||||||
AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Upload Linux ARM64 artifacts
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
tag_name: ${{ github.ref_name }}
|
|
||||||
name: 'BoosApi ${{ github.ref_name }}'
|
|
||||||
draft: true
|
|
||||||
prerelease: ${{ contains(github.ref_name, '-rc') }}
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
files: |
|
|
||||||
src-tauri/target/aarch64-unknown-linux-gnu/release/bundle/deb/*.deb
|
|
||||||
src-tauri/target/aarch64-unknown-linux-gnu/release/bundle/rpm/*.rpm
|
|
||||||
|
|
||||||
# ── Publish draft release as latest ────────────────────
|
|
||||||
publish-release:
|
|
||||||
name: Publish Release
|
|
||||||
needs: [release, release-linux-arm]
|
|
||||||
if: ${{ !contains(github.ref_name, '-test') }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- name: Publish draft release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
tag_name: ${{ github.ref_name }}
|
|
||||||
draft: false
|
|
||||||
prerelease: ${{ contains(github.ref_name, '-rc') }}
|
|
||||||
make_latest: true
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
# ── Deploy website after release ────────────────────────
|
|
||||||
deploy-website:
|
|
||||||
name: Deploy Website
|
|
||||||
needs: [publish-release]
|
|
||||||
if: ${{ !contains(github.ref_name, '-test') }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Trigger website deployment
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
await github.rest.actions.createWorkflowDispatch({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
workflow_id: 'deploy-website.yml',
|
|
||||||
ref: 'main',
|
|
||||||
});
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
name: Test Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'test-v*.*.*'
|
|
||||||
|
|
||||||
permissions: write-all
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_INCREMENTAL: 0
|
|
||||||
RUST_BACKTRACE: short
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: '${{ github.workflow }}-${{ github.ref }}'
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test-build:
|
|
||||||
name: Test Build (aarch64-apple-darwin)
|
|
||||||
runs-on: macos-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Install Rust
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
targets: aarch64-apple-darwin
|
|
||||||
|
|
||||||
- name: Rust cache
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
prefix-key: 'v1'
|
|
||||||
key: 'macos-latest-aarch64-apple-darwin'
|
|
||||||
workspaces: src-tauri -> target
|
|
||||||
|
|
||||||
- name: Install Protoc
|
|
||||||
uses: arduino/setup-protoc@v3
|
|
||||||
with:
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- name: Get linked dependency versions
|
|
||||||
id: dep-versions
|
|
||||||
run: |
|
|
||||||
SM_SHA=$(git -C libs/stream-monaco rev-parse HEAD)
|
|
||||||
MS_SHA=$(git -C libs/markstream-vue rev-parse HEAD)
|
|
||||||
echo "cache-key=${{ runner.os }}-aarch64-apple-darwin-linked-deps-${SM_SHA:0:12}-${MS_SHA:0:12}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Cache linked dependencies
|
|
||||||
id: cache-linked-deps
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
libs/stream-monaco/node_modules
|
|
||||||
libs/stream-monaco/dist
|
|
||||||
libs/markstream-vue/node_modules
|
|
||||||
libs/markstream-vue/packages/markdown-parser/node_modules
|
|
||||||
libs/markstream-vue/packages/markdown-parser/dist
|
|
||||||
libs/markstream-vue/packages/markstream-react/node_modules
|
|
||||||
libs/markstream-vue/packages/markstream-react/dist
|
|
||||||
key: ${{ steps.dep-versions.outputs.cache-key }}
|
|
||||||
|
|
||||||
- name: Build linked dependencies
|
|
||||||
if: steps.cache-linked-deps.outputs.cache-hit != 'true'
|
|
||||||
run: |
|
|
||||||
pnpm --dir libs/stream-monaco install --frozen-lockfile
|
|
||||||
pnpm --dir libs/stream-monaco build
|
|
||||||
|
|
||||||
pnpm --dir libs/markstream-vue install --frozen-lockfile
|
|
||||||
pnpm --dir libs/markstream-vue run build:parser
|
|
||||||
pnpm --dir libs/markstream-vue/packages/markstream-react build
|
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build with Tauri
|
|
||||||
uses: tauri-apps/tauri-action@v0
|
|
||||||
env:
|
|
||||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
||||||
APPLE_SIGNING_IDENTITY: '-'
|
|
||||||
with:
|
|
||||||
tagName: ${{ github.ref_name }}
|
|
||||||
releaseName: 'BoosApi ${{ github.ref_name }} (Test)'
|
|
||||||
releaseBody: 'Internal test build for scrollbar fix verification.'
|
|
||||||
releaseDraft: true
|
|
||||||
prerelease: true
|
|
||||||
tauriScript: pnpm tauri
|
|
||||||
args: --target aarch64-apple-darwin
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
name: Test Linux Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*.*.*-test*'
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_INCREMENTAL: 0
|
|
||||||
RUST_BACKTRACE: short
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: '${{ github.workflow }}-${{ github.ref }}'
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-linux:
|
|
||||||
name: Build Linux test package (x86_64)
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Install Rust
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
targets: x86_64-unknown-linux-gnu
|
|
||||||
|
|
||||||
- name: Rust cache
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
prefix-key: 'v1'
|
|
||||||
key: 'ubuntu-22.04-x86_64-unknown-linux-gnu-test'
|
|
||||||
workspaces: src-tauri -> target
|
|
||||||
|
|
||||||
- name: Install Linux dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y \
|
|
||||||
libwebkit2gtk-4.1-dev \
|
|
||||||
libayatana-appindicator3-dev \
|
|
||||||
librsvg2-dev \
|
|
||||||
patchelf
|
|
||||||
|
|
||||||
- name: Install Protoc
|
|
||||||
uses: arduino/setup-protoc@v3
|
|
||||||
with:
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- name: Get linked dependency versions
|
|
||||||
id: dep-versions
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
SM_SHA=$(git -C libs/stream-monaco rev-parse HEAD)
|
|
||||||
MS_SHA=$(git -C libs/markstream-vue rev-parse HEAD)
|
|
||||||
echo "cache-key=${{ runner.os }}-x86_64-unknown-linux-gnu-test-linked-deps-${SM_SHA:0:12}-${MS_SHA:0:12}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Cache linked dependencies
|
|
||||||
id: cache-linked-deps
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
libs/stream-monaco/node_modules
|
|
||||||
libs/stream-monaco/dist
|
|
||||||
libs/markstream-vue/node_modules
|
|
||||||
libs/markstream-vue/packages/markdown-parser/node_modules
|
|
||||||
libs/markstream-vue/packages/markdown-parser/dist
|
|
||||||
libs/markstream-vue/packages/markstream-react/node_modules
|
|
||||||
libs/markstream-vue/packages/markstream-react/dist
|
|
||||||
key: ${{ steps.dep-versions.outputs.cache-key }}
|
|
||||||
|
|
||||||
- name: Build linked dependencies
|
|
||||||
if: steps.cache-linked-deps.outputs.cache-hit != 'true'
|
|
||||||
run: |
|
|
||||||
pnpm --dir libs/stream-monaco install --frozen-lockfile
|
|
||||||
pnpm --dir libs/stream-monaco build
|
|
||||||
|
|
||||||
pnpm --dir libs/markstream-vue install --frozen-lockfile
|
|
||||||
pnpm --dir libs/markstream-vue run build:parser
|
|
||||||
pnpm --dir libs/markstream-vue/packages/markstream-react build
|
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build Linux bundles
|
|
||||||
run: pnpm tauri build --target x86_64-unknown-linux-gnu --bundles appimage,rpm,deb
|
|
||||||
env:
|
|
||||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
|
||||||
BOOSAPI_TEST_BUILD: '1'
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Publish Linux test release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
tag_name: ${{ github.ref_name }}
|
|
||||||
name: 'BoosApi ${{ github.ref_name }}'
|
|
||||||
body: |
|
|
||||||
Linux test build for issue #83 diagnostics.
|
|
||||||
|
|
||||||
If BoosApi still opens as a blank window, start it with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BOOSAPI_LOG_FILE="$HOME/boosapi-${{ github.ref_name }}.log" RUST_LOG=debug BOOSAPI_ENABLE_DEVTOOLS=1 ./BoosApi_0.0.76_amd64.AppImage
|
|
||||||
```
|
|
||||||
|
|
||||||
For the RPM install:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BOOSAPI_LOG_FILE="$HOME/boosapi-${{ github.ref_name }}-rpm.log" RUST_LOG=debug BOOSAPI_ENABLE_DEVTOOLS=1 BoosApi
|
|
||||||
```
|
|
||||||
|
|
||||||
If the log still stops around `builder.build(context)`, run the minimal-plugin diagnostic path:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BOOSAPI_LOG_FILE="$HOME/boosapi-${{ github.ref_name }}-minimal.log" RUST_LOG=debug BOOSAPI_ENABLE_DEVTOOLS=1 BOOSAPI_LINUX_MINIMAL_PLUGINS=1 ./BoosApi_0.0.76_amd64.AppImage
|
|
||||||
```
|
|
||||||
|
|
||||||
Please attach the generated log file back to issue #83.
|
|
||||||
draft: false
|
|
||||||
prerelease: true
|
|
||||||
make_latest: false
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
files: |
|
|
||||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
|
||||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
|
||||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
|
|
||||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm.sig
|
|
||||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
|
||||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
name: Test Windows Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'test-windows-v*.*.*'
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_INCREMENTAL: 0
|
|
||||||
RUST_BACKTRACE: short
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: '${{ github.workflow }}-${{ github.ref }}'
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-windows:
|
|
||||||
name: Build (x86_64-pc-windows-msvc)
|
|
||||||
runs-on: windows-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Install Rust
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
targets: x86_64-pc-windows-msvc
|
|
||||||
|
|
||||||
- name: Rust cache
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
prefix-key: 'v1'
|
|
||||||
key: 'windows-latest-x86_64-pc-windows-msvc'
|
|
||||||
workspaces: src-tauri -> target
|
|
||||||
|
|
||||||
- name: Install Protoc
|
|
||||||
uses: arduino/setup-protoc@v3
|
|
||||||
with:
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- name: Get linked dependency versions
|
|
||||||
id: dep-versions
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
SM_SHA=$(git -C libs/stream-monaco rev-parse HEAD)
|
|
||||||
MS_SHA=$(git -C libs/markstream-vue rev-parse HEAD)
|
|
||||||
echo "cache-key=${{ runner.os }}-x86_64-pc-windows-msvc-linked-deps-${SM_SHA:0:12}-${MS_SHA:0:12}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Cache linked dependencies
|
|
||||||
id: cache-linked-deps
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
libs/stream-monaco/node_modules
|
|
||||||
libs/stream-monaco/dist
|
|
||||||
libs/markstream-vue/node_modules
|
|
||||||
libs/markstream-vue/packages/markdown-parser/node_modules
|
|
||||||
libs/markstream-vue/packages/markdown-parser/dist
|
|
||||||
libs/markstream-vue/packages/markstream-react/node_modules
|
|
||||||
libs/markstream-vue/packages/markstream-react/dist
|
|
||||||
key: ${{ steps.dep-versions.outputs.cache-key }}
|
|
||||||
|
|
||||||
- name: Build linked dependencies
|
|
||||||
if: steps.cache-linked-deps.outputs.cache-hit != 'true'
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
pnpm --dir libs/stream-monaco install --frozen-lockfile
|
|
||||||
pnpm --dir libs/stream-monaco build
|
|
||||||
|
|
||||||
pnpm --dir libs/markstream-vue install --frozen-lockfile
|
|
||||||
pnpm --dir libs/markstream-vue run build:parser
|
|
||||||
pnpm --dir libs/markstream-vue/packages/markstream-react build
|
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build with Tauri
|
|
||||||
uses: tauri-apps/tauri-action@v0
|
|
||||||
env:
|
|
||||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
||||||
with:
|
|
||||||
tagName: ${{ github.ref_name }}
|
|
||||||
releaseName: 'BoosApi Test ${{ github.ref_name }}'
|
|
||||||
releaseDraft: true
|
|
||||||
prerelease: true
|
|
||||||
tauriScript: pnpm tauri
|
|
||||||
args: --target x86_64-pc-windows-msvc
|
|
||||||
|
|
||||||
- name: Package Windows portable (zip)
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
$exe = "src-tauri/target/x86_64-pc-windows-msvc/release/BoosApi.exe"
|
|
||||||
$zipName = "BoosApi_${{ github.ref_name }}_windows-x64-portable.zip"
|
|
||||||
Compress-Archive -Path $exe -DestinationPath $zipName
|
|
||||||
|
|
||||||
- name: Upload Windows portable to release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
tag_name: ${{ github.ref_name }}
|
|
||||||
draft: true
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
files: BoosApi_${{ github.ref_name }}_windows-x64-portable.zip
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
.superpowers
|
|
||||||
.vscode
|
|
||||||
.agents
|
|
||||||
.mcp.json
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
|
|
||||||
# Build outputs
|
|
||||||
dist/
|
|
||||||
target/
|
|
||||||
|
|
||||||
# Secrets
|
|
||||||
*.key
|
|
||||||
|
|
||||||
# Planning files
|
|
||||||
task_plan.md
|
|
||||||
findings.md
|
|
||||||
progress.md
|
|
||||||
docs/superpowers/*
|
|
||||||
|
|
||||||
# Local git worktrees
|
|
||||||
.worktrees/
|
|
||||||
|
|
||||||
# Website (VitePress)
|
|
||||||
website/node_modules/
|
|
||||||
website/docs/.vitepress/cache/
|
|
||||||
website/docs/.vitepress/dist/
|
|
||||||
fix/
|
|
||||||
autoresearch-*.json
|
|
||||||
research-*.tsv
|
|
||||||
.autoresearch/
|
|
||||||
|
|
||||||
debug/*
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
# Contributor Agreement
|
|
||||||
|
|
||||||
This Contributor Agreement ("Agreement") applies to contributions submitted to the BoosApi project. By submitting a pull request, commit, patch, code snippet in an issue, or any other material intended to be incorporated into this project, you confirm that you have read, understood, and agreed to this Agreement.
|
|
||||||
|
|
||||||
This Agreement confirms that each contribution is properly authorized, that the project may continue to be distributed under AGPLv3, and that the Project Owner may use the contribution without restriction for its own purposes.
|
|
||||||
|
|
||||||
## 1. Scope
|
|
||||||
|
|
||||||
This Agreement applies to all contributions you submit to this project, including but not limited to:
|
|
||||||
|
|
||||||
- source code, scripts, configuration, tests, and build files;
|
|
||||||
- documentation, examples, comments, and translations;
|
|
||||||
- UI assets, icons, images, design materials, and data files;
|
|
||||||
- content submitted through pull requests, commits, patches, issues, discussions, or other GitHub features with the intent that it may be incorporated into the project.
|
|
||||||
|
|
||||||
Content submitted only as a problem report, runtime log, screenshot, or reproduction note is not treated as a contribution under this Agreement unless it is clearly intended to be merged into the project.
|
|
||||||
|
|
||||||
## 2. Authority and Rights
|
|
||||||
|
|
||||||
You represent that:
|
|
||||||
|
|
||||||
1. you are the author or lawful rights holder of the contribution, or you have obtained sufficient rights to submit and license it under this Agreement;
|
|
||||||
2. if your employer, client, school, organization, or another third party owns or may own rights in the contribution, you have obtained all required authorization from that party;
|
|
||||||
3. to the best of your knowledge, the contribution does not infringe any third-party copyright, patent, trademark, trade secret, privacy right, or other legal right;
|
|
||||||
4. the contribution does not contain confidential information, secrets, credentials, proprietary code, or restricted data that you are not authorized to disclose or license to this project;
|
|
||||||
5. if the contribution includes third-party material, you have clearly identified its source, copyright notice, license, and any applicable restrictions, and you have confirmed that the material may be used by this project under this Agreement.
|
|
||||||
|
|
||||||
If you cannot make these representations, do not submit the contribution.
|
|
||||||
|
|
||||||
## 3. License to the Project Under AGPLv3
|
|
||||||
|
|
||||||
You grant this project and its users a perpetual, worldwide, irrevocable, non-exclusive, royalty-free license to use, reproduce, modify, merge, publish, distribute, and sublicense your contribution as part of the project under the GNU Affero General Public License version 3 only (SPDX: `AGPL-3.0-only`).
|
|
||||||
|
|
||||||
You understand and agree that, once accepted and merged, your contribution may be distributed to the public as part of this project under `AGPL-3.0-only` and will be subject to that license.
|
|
||||||
|
|
||||||
## 4. Unrestricted Use by the Project Owner
|
|
||||||
|
|
||||||
In addition to the AGPLv3 grant above, you grant the Project Owner, including the current repository owner and its lawful successors, assigns, and transferees, a perpetual, worldwide, irrevocable, non-exclusive, royalty-free, sublicensable, and transferable license to use your contribution without restriction for any lawful purpose.
|
|
||||||
|
|
||||||
This grant includes, without limitation, the right to:
|
|
||||||
|
|
||||||
- use, reproduce, modify, adapt, merge, compile, run, test, and deploy the contribution;
|
|
||||||
- create, maintain, publish, distribute, sell, host, or commercialize works or services that include the contribution;
|
|
||||||
- use the contribution in open-source versions, commercial versions, private versions, hosted services, internal tools, or any other project version;
|
|
||||||
- sublicense or dual-license the contribution, or use it under other licensing models, without additional permission from or payment to you.
|
|
||||||
|
|
||||||
This grant does not require you to transfer copyright ownership. Unless separately agreed in writing, you retain any rights you lawfully hold in your contribution. However, you may not revoke this license or impose additional restrictions or fees on the Project Owner's use of the contribution under this Agreement.
|
|
||||||
|
|
||||||
## 5. Patent License
|
|
||||||
|
|
||||||
To the extent you own or have the right to license patent claims, you grant this project, the Project Owner, and project users a perpetual, worldwide, irrevocable, non-exclusive, royalty-free patent license to use, make, reproduce, modify, publish, distribute, run, deploy, and sublicense your contribution and project versions that include it.
|
|
||||||
|
|
||||||
This patent license covers only patent claims that are necessarily infringed by your contribution itself or by the intended combination of your contribution with this project.
|
|
||||||
|
|
||||||
## 6. Third-Party Materials
|
|
||||||
|
|
||||||
Do not copy code or assets from sources whose license or authorization cannot be confirmed. If a contribution includes third-party material, you must:
|
|
||||||
|
|
||||||
1. identify the source, copyright owner, and license;
|
|
||||||
2. confirm that the license is compatible with `AGPL-3.0-only`;
|
|
||||||
3. confirm that the Project Owner may use the material without restriction as described in Section 4;
|
|
||||||
4. provide any required license text, notice, or attribution.
|
|
||||||
|
|
||||||
Maintainers may reject, modify, or remove contributions with unclear authorization, incompatible licensing, or unacceptable rights risk.
|
|
||||||
|
|
||||||
## 7. Submission and Acceptance
|
|
||||||
|
|
||||||
By submitting a contribution through GitHub or another method accepted by the maintainers, you accept this Agreement. Acceptance or merger of a contribution does not require the maintainers to accept future contributions and does not limit the Project Owner's control over project licensing, releases, product form, or commercial arrangements.
|
|
||||||
|
|
||||||
If you submit a contribution on behalf of an organization, your account, commit author information, or pull request description should reflect the proper authorization relationship. Maintainers may request additional confirmation when needed.
|
|
||||||
|
|
||||||
## 8. No Warranty
|
|
||||||
|
|
||||||
Except for the representations about rights and authority stated in this Agreement, your contribution is provided "as is". The Project Owner and maintainers may decide whether to accept, modify, publish, or remove any contribution.
|
|
||||||
|
|
||||||
## 9. Relationship to Other Files
|
|
||||||
|
|
||||||
This project is publicly distributed under `AGPL-3.0-only`; see [LICENSE](LICENSE) for the full license text. If this Agreement conflicts with other contribution workflow documents, the contribution licensing terms in this Agreement control. If any part of this Agreement conflicts with mandatory applicable law, it should be interpreted and enforced to the maximum extent permitted by law.
|
|
||||||
|
|
||||||
If you have questions about the legal effect of this Agreement, consult your legal counsel before submitting a contribution.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 贡献者协议
|
|
||||||
|
|
||||||
本文件是 BoosApi 项目的贡献者协议(Contributor License Agreement, CLA)。你向本仓库提交 Pull Request、commit、patch、issue 附件中的代码片段,或以其他方式提交可合并到本项目中的内容,即表示你已阅读、理解并同意本协议。
|
|
||||||
|
|
||||||
本协议用于确认贡献内容具有合法授权来源,并确保本项目可以继续按照 AGPLv3 发布,同时允许项目所有者为自身目的不受限制地使用贡献内容。
|
|
||||||
|
|
||||||
## 1. 适用范围
|
|
||||||
|
|
||||||
本协议适用于你提交给本项目的所有贡献内容,包括但不限于:
|
|
||||||
|
|
||||||
- 源代码、脚本、配置、测试、构建文件;
|
|
||||||
- 文档、示例、注释、翻译;
|
|
||||||
- UI 资源、图标、图片、设计稿、数据文件;
|
|
||||||
- 通过 Pull Request、commit、patch、issue、讨论区或其他 GitHub 功能提交,并明确供本项目采用的内容。
|
|
||||||
|
|
||||||
仅用于问题描述、运行日志、截图或复现说明,且未明确要求合并进项目的内容,不视为本协议下的贡献内容。
|
|
||||||
|
|
||||||
## 2. 贡献资格与权利保证
|
|
||||||
|
|
||||||
你声明并保证:
|
|
||||||
|
|
||||||
1. 你是贡献内容的作者或合法权利人,或者已经取得足够授权,可以按照本协议提交并授权该贡献内容;
|
|
||||||
2. 如果贡献内容由你的雇主、客户、学校、组织或其他第三方拥有或可能拥有权利,你已经取得其必要授权;
|
|
||||||
3. 就你所知,贡献内容不会侵犯任何第三方的著作权、专利权、商标权、商业秘密、隐私权或其他合法权益;
|
|
||||||
4. 贡献内容不包含你无权公开或无权授权给本项目使用的保密信息、密钥、凭证、专有代码或受限制数据;
|
|
||||||
5. 如果贡献内容包含第三方材料,你已清楚标明来源、版权声明、许可证及任何使用限制,并确认这些材料允许本项目按本协议使用。
|
|
||||||
|
|
||||||
如果你无法作出上述声明和保证,请不要提交该贡献内容。
|
|
||||||
|
|
||||||
## 3. 授权本项目以 AGPLv3 发布
|
|
||||||
|
|
||||||
你授予本项目及其用户一项永久、全球范围、不可撤销、非独占、免版税的许可,允许本项目将你的贡献内容作为项目的一部分,按照 GNU Affero General Public License version 3 only(SPDX: `AGPL-3.0-only`)进行使用、复制、修改、合并、发布、分发和再许可。
|
|
||||||
|
|
||||||
你理解并同意:一旦贡献内容被项目接收并合并,该贡献内容可以随本项目以 `AGPL-3.0-only` 许可证向公众发布,并适用该许可证的全部条款。
|
|
||||||
|
|
||||||
## 4. 授权项目所有者不受限制地自身使用
|
|
||||||
|
|
||||||
除第 3 条的 AGPLv3 开源发布授权外,你还授予项目所有者(包括本仓库当前所有者及其合法继受者、受让人和转让对象)一项永久、全球范围、不可撤销、非独占、免版税、可转授权、可转让的许可,允许其为任何合法目的不受限制地使用你的贡献内容。
|
|
||||||
|
|
||||||
该授权包括但不限于:
|
|
||||||
|
|
||||||
- 使用、复制、修改、改编、合并、编译、运行、测试和部署贡献内容;
|
|
||||||
- 创建、维护、发布、分发、销售、托管或商业化包含贡献内容的作品或服务;
|
|
||||||
- 将贡献内容用于开源版本、商业版本、私有版本、托管服务、内部工具或其他任何项目版本;
|
|
||||||
- 在不额外征得你许可或支付费用的情况下,对贡献内容进行再许可、双重许可,或与其他许可模式并行使用。
|
|
||||||
|
|
||||||
该授权不要求你转让贡献内容的著作权。除非另有书面约定,你仍保留你依法拥有的权利。但你不得撤销本条授权,也不得因项目所有者依据本协议使用贡献内容而主张额外限制或费用。
|
|
||||||
|
|
||||||
## 5. 专利授权
|
|
||||||
|
|
||||||
在你拥有或有权授权的范围内,你授予本项目、项目所有者及项目用户一项永久、全球范围、不可撤销、非独占、免版税的专利许可,允许其使用、制作、复制、修改、发布、分发、运行、部署和再许可你的贡献内容,以及包含该贡献内容的项目版本。
|
|
||||||
|
|
||||||
该专利许可仅覆盖因你的贡献内容本身,或因你的贡献内容与本项目的预期组合,而必然被实施的专利权利要求。
|
|
||||||
|
|
||||||
## 6. 第三方材料
|
|
||||||
|
|
||||||
请不要直接复制无法确认授权来源的代码或资源。若贡献内容包含第三方材料,你需要:
|
|
||||||
|
|
||||||
1. 明确说明材料来源、版权归属和许可证;
|
|
||||||
2. 确认其许可证兼容 `AGPL-3.0-only`;
|
|
||||||
3. 确认项目所有者可以按第 4 条不受限制地使用该材料;
|
|
||||||
4. 在需要时补充对应的许可证文本、NOTICE 或归属说明。
|
|
||||||
|
|
||||||
项目维护者可以拒绝、修改或移除授权来源不清、许可证不兼容或权利风险不可接受的贡献内容。
|
|
||||||
|
|
||||||
## 7. 提交与接受
|
|
||||||
|
|
||||||
你通过 GitHub 或其他项目维护者认可的方式提交贡献内容,即表示你接受本协议。项目维护者合并贡献内容,并不表示其承诺采纳你未来的贡献,也不表示项目所有者放弃对项目许可、发布方式、产品形态或商业安排的决定权。
|
|
||||||
|
|
||||||
如果你代表组织提交贡献,应确保提交账号、commit 作者信息或 Pull Request 说明能够反映适当的授权关系;项目维护者可以在必要时要求你提供额外确认。
|
|
||||||
|
|
||||||
## 8. 无担保
|
|
||||||
|
|
||||||
除本协议中关于权利来源和授权能力的声明与保证外,你的贡献内容按“现状”提交。项目所有者和维护者可以自行决定是否接收、修改、发布或移除贡献内容。
|
|
||||||
|
|
||||||
## 9. 与其他文件的关系
|
|
||||||
|
|
||||||
本项目当前使用 `AGPL-3.0-only` 作为公开发布许可证,完整文本见 [LICENSE](LICENSE)。若本协议与项目贡献流程文档存在不一致,以本协议中关于贡献授权的条款为准;若本协议与适用法律强制规定冲突,以适用法律允许的最大范围解释和执行。
|
|
||||||
|
|
||||||
如你对本协议的法律效果存在疑问,请在提交贡献前咨询你的法律顾问。
|
|
||||||
@@ -1,661 +0,0 @@
|
|||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
|
||||||
Version 3, 19 November 2007
|
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
|
||||||
of this license document, but changing it is not allowed.
|
|
||||||
|
|
||||||
Preamble
|
|
||||||
|
|
||||||
The GNU Affero General Public License is a free, copyleft license for
|
|
||||||
software and other kinds of works, specifically designed to ensure
|
|
||||||
cooperation with the community in the case of network server software.
|
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
|
||||||
to take away your freedom to share and change the works. By contrast,
|
|
||||||
our General Public Licenses are intended to guarantee your freedom to
|
|
||||||
share and change all versions of a program--to make sure it remains free
|
|
||||||
software for all its users.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
|
||||||
have the freedom to distribute copies of free software (and charge for
|
|
||||||
them if you wish), that you receive source code or can get it if you
|
|
||||||
want it, that you can change the software or use pieces of it in new
|
|
||||||
free programs, and that you know you can do these things.
|
|
||||||
|
|
||||||
Developers that use our General Public Licenses protect your rights
|
|
||||||
with two steps: (1) assert copyright on the software, and (2) offer
|
|
||||||
you this License which gives you legal permission to copy, distribute
|
|
||||||
and/or modify the software.
|
|
||||||
|
|
||||||
A secondary benefit of defending all users' freedom is that
|
|
||||||
improvements made in alternate versions of the program, if they
|
|
||||||
receive widespread use, become available for other developers to
|
|
||||||
incorporate. Many developers of free software are heartened and
|
|
||||||
encouraged by the resulting cooperation. However, in the case of
|
|
||||||
software used on network servers, this result may fail to come about.
|
|
||||||
The GNU General Public License permits making a modified version and
|
|
||||||
letting the public access it on a server without ever releasing its
|
|
||||||
source code to the public.
|
|
||||||
|
|
||||||
The GNU Affero General Public License is designed specifically to
|
|
||||||
ensure that, in such cases, the modified source code becomes available
|
|
||||||
to the community. It requires the operator of a network server to
|
|
||||||
provide the source code of the modified version running there to the
|
|
||||||
users of that server. Therefore, public use of a modified version, on
|
|
||||||
a publicly accessible server, gives the public access to the source
|
|
||||||
code of the modified version.
|
|
||||||
|
|
||||||
An older license, called the Affero General Public License and
|
|
||||||
published by Affero, was designed to accomplish similar goals. This is
|
|
||||||
a different license, not a version of the Affero GPL, but Affero has
|
|
||||||
released a new version of the Affero GPL which permits relicensing under
|
|
||||||
this license.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
|
||||||
modification follow.
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
0. Definitions.
|
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
|
||||||
works, such as semiconductor masks.
|
|
||||||
|
|
||||||
"The Program" refers to any copyrightable work licensed under this
|
|
||||||
License. Each licensee is addressed as "you". "Licensees" and
|
|
||||||
"recipients" may be individuals or organizations.
|
|
||||||
|
|
||||||
To "modify" a work means to copy from or adapt all or part of the work
|
|
||||||
in a fashion requiring copyright permission, other than the making of an
|
|
||||||
exact copy. The resulting work is called a "modified version" of the
|
|
||||||
earlier work or a work "based on" the earlier work.
|
|
||||||
|
|
||||||
A "covered work" means either the unmodified Program or a work based
|
|
||||||
on the Program.
|
|
||||||
|
|
||||||
To "propagate" a work means to do anything with it that, without
|
|
||||||
permission, would make you directly or secondarily liable for
|
|
||||||
infringement under applicable copyright law, except executing it on a
|
|
||||||
computer or modifying a private copy. Propagation includes copying,
|
|
||||||
distribution (with or without modification), making available to the
|
|
||||||
public, and in some countries other activities as well.
|
|
||||||
|
|
||||||
To "convey" a work means any kind of propagation that enables other
|
|
||||||
parties to make or receive copies. Mere interaction with a user through
|
|
||||||
a computer network, with no transfer of a copy, is not conveying.
|
|
||||||
|
|
||||||
An interactive user interface displays "Appropriate Legal Notices"
|
|
||||||
to the extent that it includes a convenient and prominently visible
|
|
||||||
feature that (1) displays an appropriate copyright notice, and (2)
|
|
||||||
tells the user that there is no warranty for the work (except to the
|
|
||||||
extent that warranties are provided), that licensees may convey the
|
|
||||||
work under this License, and how to view a copy of this License. If
|
|
||||||
the interface presents a list of user commands or options, such as a
|
|
||||||
menu, a prominent item in the list meets this criterion.
|
|
||||||
|
|
||||||
1. Source Code.
|
|
||||||
|
|
||||||
The "source code" for a work means the preferred form of the work
|
|
||||||
for making modifications to it. "Object code" means any non-source
|
|
||||||
form of a work.
|
|
||||||
|
|
||||||
A "Standard Interface" means an interface that either is an official
|
|
||||||
standard defined by a recognized standards body, or, in the case of
|
|
||||||
interfaces specified for a particular programming language, one that
|
|
||||||
is widely used among developers working in that language.
|
|
||||||
|
|
||||||
The "System Libraries" of an executable work include anything, other
|
|
||||||
than the work as a whole, that (a) is included in the normal form of
|
|
||||||
packaging a Major Component, but which is not part of that Major
|
|
||||||
Component, and (b) serves only to enable use of the work with that
|
|
||||||
Major Component, or to implement a Standard Interface for which an
|
|
||||||
implementation is available to the public in source code form. A
|
|
||||||
"Major Component", in this context, means a major essential component
|
|
||||||
(kernel, window system, and so on) of the specific operating system
|
|
||||||
(if any) on which the executable work runs, or a compiler used to
|
|
||||||
produce the work, or an object code interpreter used to run it.
|
|
||||||
|
|
||||||
The "Corresponding Source" for a work in object code form means all
|
|
||||||
the source code needed to generate, install, and (for an executable
|
|
||||||
work) run the object code and to modify the work, including scripts to
|
|
||||||
control those activities. However, it does not include the work's
|
|
||||||
System Libraries, or general-purpose tools or generally available free
|
|
||||||
programs which are used unmodified in performing those activities but
|
|
||||||
which are not part of the work. For example, Corresponding Source
|
|
||||||
includes interface definition files associated with source files for
|
|
||||||
the work, and the source code for shared libraries and dynamically
|
|
||||||
linked subprograms that the work is specifically designed to require,
|
|
||||||
such as by intimate data communication or control flow between those
|
|
||||||
subprograms and other parts of the work.
|
|
||||||
|
|
||||||
The Corresponding Source need not include anything that users
|
|
||||||
can regenerate automatically from other parts of the Corresponding
|
|
||||||
Source.
|
|
||||||
|
|
||||||
The Corresponding Source for a work in source code form is that
|
|
||||||
same work.
|
|
||||||
|
|
||||||
2. Basic Permissions.
|
|
||||||
|
|
||||||
All rights granted under this License are granted for the term of
|
|
||||||
copyright on the Program, and are irrevocable provided the stated
|
|
||||||
conditions are met. This License explicitly affirms your unlimited
|
|
||||||
permission to run the unmodified Program. The output from running a
|
|
||||||
covered work is covered by this License only if the output, given its
|
|
||||||
content, constitutes a covered work. This License acknowledges your
|
|
||||||
rights of fair use or other equivalent, as provided by copyright law.
|
|
||||||
|
|
||||||
You may make, run and propagate covered works that you do not
|
|
||||||
convey, without conditions so long as your license otherwise remains
|
|
||||||
in force. You may convey covered works to others for the sole purpose
|
|
||||||
of having them make modifications exclusively for you, or provide you
|
|
||||||
with facilities for running those works, provided that you comply with
|
|
||||||
the terms of this License in conveying all material for which you do
|
|
||||||
not control copyright. Those thus making or running the covered works
|
|
||||||
for you must do so exclusively on your behalf, under your direction
|
|
||||||
and control, on terms that prohibit them from making any copies of
|
|
||||||
your copyrighted material outside their relationship with you.
|
|
||||||
|
|
||||||
Conveying under any other circumstances is permitted solely under
|
|
||||||
the conditions stated below. Sublicensing is not allowed; section 10
|
|
||||||
makes it unnecessary.
|
|
||||||
|
|
||||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
|
||||||
|
|
||||||
No covered work shall be deemed part of an effective technological
|
|
||||||
measure under any applicable law fulfilling obligations under article
|
|
||||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
|
||||||
similar laws prohibiting or restricting circumvention of such
|
|
||||||
measures.
|
|
||||||
|
|
||||||
When you convey a covered work, you waive any legal power to forbid
|
|
||||||
circumvention of technological measures to the extent such circumvention
|
|
||||||
is effected by exercising rights under this License with respect to
|
|
||||||
the covered work, and you disclaim any intention to limit operation or
|
|
||||||
modification of the work as a means of enforcing, against the work's
|
|
||||||
users, your or third parties' legal rights to forbid circumvention of
|
|
||||||
technological measures.
|
|
||||||
|
|
||||||
4. Conveying Verbatim Copies.
|
|
||||||
|
|
||||||
You may convey verbatim copies of the Program's source code as you
|
|
||||||
receive it, in any medium, provided that you conspicuously and
|
|
||||||
appropriately publish on each copy an appropriate copyright notice;
|
|
||||||
keep intact all notices stating that this License and any
|
|
||||||
non-permissive terms added in accord with section 7 apply to the code;
|
|
||||||
keep intact all notices of the absence of any warranty; and give all
|
|
||||||
recipients a copy of this License along with the Program.
|
|
||||||
|
|
||||||
You may charge any price or no price for each copy that you convey,
|
|
||||||
and you may offer support or warranty protection for a fee.
|
|
||||||
|
|
||||||
5. Conveying Modified Source Versions.
|
|
||||||
|
|
||||||
You may convey a work based on the Program, or the modifications to
|
|
||||||
produce it from the Program, in the form of source code under the
|
|
||||||
terms of section 4, provided that you also meet all of these conditions:
|
|
||||||
|
|
||||||
a) The work must carry prominent notices stating that you modified
|
|
||||||
it, and giving a relevant date.
|
|
||||||
|
|
||||||
b) The work must carry prominent notices stating that it is
|
|
||||||
released under this License and any conditions added under section
|
|
||||||
7. This requirement modifies the requirement in section 4 to
|
|
||||||
"keep intact all notices".
|
|
||||||
|
|
||||||
c) You must license the entire work, as a whole, under this
|
|
||||||
License to anyone who comes into possession of a copy. This
|
|
||||||
License will therefore apply, along with any applicable section 7
|
|
||||||
additional terms, to the whole of the work, and all its parts,
|
|
||||||
regardless of how they are packaged. This License gives no
|
|
||||||
permission to license the work in any other way, but it does not
|
|
||||||
invalidate such permission if you have separately received it.
|
|
||||||
|
|
||||||
d) If the work has interactive user interfaces, each must display
|
|
||||||
Appropriate Legal Notices; however, if the Program has interactive
|
|
||||||
interfaces that do not display Appropriate Legal Notices, your
|
|
||||||
work need not make them do so.
|
|
||||||
|
|
||||||
A compilation of a covered work with other separate and independent
|
|
||||||
works, which are not by their nature extensions of the covered work,
|
|
||||||
and which are not combined with it such as to form a larger program,
|
|
||||||
in or on a volume of a storage or distribution medium, is called an
|
|
||||||
"aggregate" if the compilation and its resulting copyright are not
|
|
||||||
used to limit the access or legal rights of the compilation's users
|
|
||||||
beyond what the individual works permit. Inclusion of a covered work
|
|
||||||
in an aggregate does not cause this License to apply to the other
|
|
||||||
parts of the aggregate.
|
|
||||||
|
|
||||||
6. Conveying Non-Source Forms.
|
|
||||||
|
|
||||||
You may convey a covered work in object code form under the terms
|
|
||||||
of sections 4 and 5, provided that you also convey the
|
|
||||||
machine-readable Corresponding Source under the terms of this License,
|
|
||||||
in one of these ways:
|
|
||||||
|
|
||||||
a) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by the
|
|
||||||
Corresponding Source fixed on a durable physical medium
|
|
||||||
customarily used for software interchange.
|
|
||||||
|
|
||||||
b) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by a
|
|
||||||
written offer, valid for at least three years and valid for as
|
|
||||||
long as you offer spare parts or customer support for that product
|
|
||||||
model, to give anyone who possesses the object code either (1) a
|
|
||||||
copy of the Corresponding Source for all the software in the
|
|
||||||
product that is covered by this License, on a durable physical
|
|
||||||
medium customarily used for software interchange, for a price no
|
|
||||||
more than your reasonable cost of physically performing this
|
|
||||||
conveying of source, or (2) access to copy the
|
|
||||||
Corresponding Source from a network server at no charge.
|
|
||||||
|
|
||||||
c) Convey individual copies of the object code with a copy of the
|
|
||||||
written offer to provide the Corresponding Source. This
|
|
||||||
alternative is allowed only occasionally and noncommercially, and
|
|
||||||
only if you received the object code with such an offer, in accord
|
|
||||||
with subsection 6b.
|
|
||||||
|
|
||||||
d) Convey the object code by offering access from a designated
|
|
||||||
place (gratis or for a charge), and offer equivalent access to the
|
|
||||||
Corresponding Source in the same way through the same place at no
|
|
||||||
further charge. You need not require recipients to copy the
|
|
||||||
Corresponding Source along with the object code. If the place to
|
|
||||||
copy the object code is a network server, the Corresponding Source
|
|
||||||
may be on a different server (operated by you or a third party)
|
|
||||||
that supports equivalent copying facilities, provided you maintain
|
|
||||||
clear directions next to the object code saying where to find the
|
|
||||||
Corresponding Source. Regardless of what server hosts the
|
|
||||||
Corresponding Source, you remain obligated to ensure that it is
|
|
||||||
available for as long as needed to satisfy these requirements.
|
|
||||||
|
|
||||||
e) Convey the object code using peer-to-peer transmission, provided
|
|
||||||
you inform other peers where the object code and Corresponding
|
|
||||||
Source of the work are being offered to the general public at no
|
|
||||||
charge under subsection 6d.
|
|
||||||
|
|
||||||
A separable portion of the object code, whose source code is excluded
|
|
||||||
from the Corresponding Source as a System Library, need not be
|
|
||||||
included in conveying the object code work.
|
|
||||||
|
|
||||||
A "User Product" is either (1) a "consumer product", which means any
|
|
||||||
tangible personal property which is normally used for personal, family,
|
|
||||||
or household purposes, or (2) anything designed or sold for incorporation
|
|
||||||
into a dwelling. In determining whether a product is a consumer product,
|
|
||||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
|
||||||
product received by a particular user, "normally used" refers to a
|
|
||||||
typical or common use of that class of product, regardless of the status
|
|
||||||
of the particular user or of the way in which the particular user
|
|
||||||
actually uses, or expects or is expected to use, the product. A product
|
|
||||||
is a consumer product regardless of whether the product has substantial
|
|
||||||
commercial, industrial or non-consumer uses, unless such uses represent
|
|
||||||
the only significant mode of use of the product.
|
|
||||||
|
|
||||||
"Installation Information" for a User Product means any methods,
|
|
||||||
procedures, authorization keys, or other information required to install
|
|
||||||
and execute modified versions of a covered work in that User Product from
|
|
||||||
a modified version of its Corresponding Source. The information must
|
|
||||||
suffice to ensure that the continued functioning of the modified object
|
|
||||||
code is in no case prevented or interfered with solely because
|
|
||||||
modification has been made.
|
|
||||||
|
|
||||||
If you convey an object code work under this section in, or with, or
|
|
||||||
specifically for use in, a User Product, and the conveying occurs as
|
|
||||||
part of a transaction in which the right of possession and use of the
|
|
||||||
User Product is transferred to the recipient in perpetuity or for a
|
|
||||||
fixed term (regardless of how the transaction is characterized), the
|
|
||||||
Corresponding Source conveyed under this section must be accompanied
|
|
||||||
by the Installation Information. But this requirement does not apply
|
|
||||||
if neither you nor any third party retains the ability to install
|
|
||||||
modified object code on the User Product (for example, the work has
|
|
||||||
been installed in ROM).
|
|
||||||
|
|
||||||
The requirement to provide Installation Information does not include a
|
|
||||||
requirement to continue to provide support service, warranty, or updates
|
|
||||||
for a work that has been modified or installed by the recipient, or for
|
|
||||||
the User Product in which it has been modified or installed. Access to a
|
|
||||||
network may be denied when the modification itself materially and
|
|
||||||
adversely affects the operation of the network or violates the rules and
|
|
||||||
protocols for communication across the network.
|
|
||||||
|
|
||||||
Corresponding Source conveyed, and Installation Information provided,
|
|
||||||
in accord with this section must be in a format that is publicly
|
|
||||||
documented (and with an implementation available to the public in
|
|
||||||
source code form), and must require no special password or key for
|
|
||||||
unpacking, reading or copying.
|
|
||||||
|
|
||||||
7. Additional Terms.
|
|
||||||
|
|
||||||
"Additional permissions" are terms that supplement the terms of this
|
|
||||||
License by making exceptions from one or more of its conditions.
|
|
||||||
Additional permissions that are applicable to the entire Program shall
|
|
||||||
be treated as though they were included in this License, to the extent
|
|
||||||
that they are valid under applicable law. If additional permissions
|
|
||||||
apply only to part of the Program, that part may be used separately
|
|
||||||
under those permissions, but the entire Program remains governed by
|
|
||||||
this License without regard to the additional permissions.
|
|
||||||
|
|
||||||
When you convey a copy of a covered work, you may at your option
|
|
||||||
remove any additional permissions from that copy, or from any part of
|
|
||||||
it. (Additional permissions may be written to require their own
|
|
||||||
removal in certain cases when you modify the work.) You may place
|
|
||||||
additional permissions on material, added by you to a covered work,
|
|
||||||
for which you have or can give appropriate copyright permission.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, for material you
|
|
||||||
add to a covered work, you may (if authorized by the copyright holders of
|
|
||||||
that material) supplement the terms of this License with terms:
|
|
||||||
|
|
||||||
a) Disclaiming warranty or limiting liability differently from the
|
|
||||||
terms of sections 15 and 16 of this License; or
|
|
||||||
|
|
||||||
b) Requiring preservation of specified reasonable legal notices or
|
|
||||||
author attributions in that material or in the Appropriate Legal
|
|
||||||
Notices displayed by works containing it; or
|
|
||||||
|
|
||||||
c) Prohibiting misrepresentation of the origin of that material, or
|
|
||||||
requiring that modified versions of such material be marked in
|
|
||||||
reasonable ways as different from the original version; or
|
|
||||||
|
|
||||||
d) Limiting the use for publicity purposes of names of licensors or
|
|
||||||
authors of the material; or
|
|
||||||
|
|
||||||
e) Declining to grant rights under trademark law for use of some
|
|
||||||
trade names, trademarks, or service marks; or
|
|
||||||
|
|
||||||
f) Requiring indemnification of licensors and authors of that
|
|
||||||
material by anyone who conveys the material (or modified versions of
|
|
||||||
it) with contractual assumptions of liability to the recipient, for
|
|
||||||
any liability that these contractual assumptions directly impose on
|
|
||||||
those licensors and authors.
|
|
||||||
|
|
||||||
All other non-permissive additional terms are considered "further
|
|
||||||
restrictions" within the meaning of section 10. If the Program as you
|
|
||||||
received it, or any part of it, contains a notice stating that it is
|
|
||||||
governed by this License along with a term that is a further
|
|
||||||
restriction, you may remove that term. If a license document contains
|
|
||||||
a further restriction but permits relicensing or conveying under this
|
|
||||||
License, you may add to a covered work material governed by the terms
|
|
||||||
of that license document, provided that the further restriction does
|
|
||||||
not survive such relicensing or conveying.
|
|
||||||
|
|
||||||
If you add terms to a covered work in accord with this section, you
|
|
||||||
must place, in the relevant source files, a statement of the
|
|
||||||
additional terms that apply to those files, or a notice indicating
|
|
||||||
where to find the applicable terms.
|
|
||||||
|
|
||||||
Additional terms, permissive or non-permissive, may be stated in the
|
|
||||||
form of a separately written license, or stated as exceptions;
|
|
||||||
the above requirements apply either way.
|
|
||||||
|
|
||||||
8. Termination.
|
|
||||||
|
|
||||||
You may not propagate or modify a covered work except as expressly
|
|
||||||
provided under this License. Any attempt otherwise to propagate or
|
|
||||||
modify it is void, and will automatically terminate your rights under
|
|
||||||
this License (including any patent licenses granted under the third
|
|
||||||
paragraph of section 11).
|
|
||||||
|
|
||||||
However, if you cease all violation of this License, then your
|
|
||||||
license from a particular copyright holder is reinstated (a)
|
|
||||||
provisionally, unless and until the copyright holder explicitly and
|
|
||||||
finally terminates your license, and (b) permanently, if the copyright
|
|
||||||
holder fails to notify you of the violation by some reasonable means
|
|
||||||
prior to 60 days after the cessation.
|
|
||||||
|
|
||||||
Moreover, your license from a particular copyright holder is
|
|
||||||
reinstated permanently if the copyright holder notifies you of the
|
|
||||||
violation by some reasonable means, this is the first time you have
|
|
||||||
received notice of violation of this License (for any work) from that
|
|
||||||
copyright holder, and you cure the violation prior to 30 days after
|
|
||||||
your receipt of the notice.
|
|
||||||
|
|
||||||
Termination of your rights under this section does not terminate the
|
|
||||||
licenses of parties who have received copies or rights from you under
|
|
||||||
this License. If your rights have been terminated and not permanently
|
|
||||||
reinstated, you do not qualify to receive new licenses for the same
|
|
||||||
material under section 10.
|
|
||||||
|
|
||||||
9. Acceptance Not Required for Having Copies.
|
|
||||||
|
|
||||||
You are not required to accept this License in order to receive or
|
|
||||||
run a copy of the Program. Ancillary propagation of a covered work
|
|
||||||
occurring solely as a consequence of using peer-to-peer transmission
|
|
||||||
to receive a copy likewise does not require acceptance. However,
|
|
||||||
nothing other than this License grants you permission to propagate or
|
|
||||||
modify any covered work. These actions infringe copyright if you do
|
|
||||||
not accept this License. Therefore, by modifying or propagating a
|
|
||||||
covered work, you indicate your acceptance of this License to do so.
|
|
||||||
|
|
||||||
10. Automatic Licensing of Downstream Recipients.
|
|
||||||
|
|
||||||
Each time you convey a covered work, the recipient automatically
|
|
||||||
receives a license from the original licensors, to run, modify and
|
|
||||||
propagate that work, subject to this License. You are not responsible
|
|
||||||
for enforcing compliance by third parties with this License.
|
|
||||||
|
|
||||||
An "entity transaction" is a transaction transferring control of an
|
|
||||||
organization, or substantially all assets of one, or subdividing an
|
|
||||||
organization, or merging organizations. If propagation of a covered
|
|
||||||
work results from an entity transaction, each party to that
|
|
||||||
transaction who receives a copy of the work also receives whatever
|
|
||||||
licenses to the work the party's predecessor in interest had or could
|
|
||||||
give under the previous paragraph, plus a right to possession of the
|
|
||||||
Corresponding Source of the work from the predecessor in interest, if
|
|
||||||
the predecessor has it or can get it with reasonable efforts.
|
|
||||||
|
|
||||||
You may not impose any further restrictions on the exercise of the
|
|
||||||
rights granted or affirmed under this License. For example, you may
|
|
||||||
not impose a license fee, royalty, or other charge for exercise of
|
|
||||||
rights granted under this License, and you may not initiate litigation
|
|
||||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
|
||||||
any patent claim is infringed by making, using, selling, offering for
|
|
||||||
sale, or importing the Program or any portion of it.
|
|
||||||
|
|
||||||
11. Patents.
|
|
||||||
|
|
||||||
A "contributor" is a copyright holder who authorizes use under this
|
|
||||||
License of the Program or a work on which the Program is based. The
|
|
||||||
work thus licensed is called the contributor's "contributor version".
|
|
||||||
|
|
||||||
A contributor's "essential patent claims" are all patent claims
|
|
||||||
owned or controlled by the contributor, whether already acquired or
|
|
||||||
hereafter acquired, that would be infringed by some manner, permitted
|
|
||||||
by this License, of making, using, or selling its contributor version,
|
|
||||||
but do not include claims that would be infringed only as a
|
|
||||||
consequence of further modification of the contributor version. For
|
|
||||||
purposes of this definition, "control" includes the right to grant
|
|
||||||
patent sublicenses in a manner consistent with the requirements of
|
|
||||||
this License.
|
|
||||||
|
|
||||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
|
||||||
patent license under the contributor's essential patent claims, to
|
|
||||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
|
||||||
propagate the contents of its contributor version.
|
|
||||||
|
|
||||||
In the following three paragraphs, a "patent license" is any express
|
|
||||||
agreement or commitment, however denominated, not to enforce a patent
|
|
||||||
(such as an express permission to practice a patent or covenant not to
|
|
||||||
sue for patent infringement). To "grant" such a patent license to a
|
|
||||||
party means to make such an agreement or commitment not to enforce a
|
|
||||||
patent against the party.
|
|
||||||
|
|
||||||
If you convey a covered work, knowingly relying on a patent license,
|
|
||||||
and the Corresponding Source of the work is not available for anyone
|
|
||||||
to copy, free of charge and under the terms of this License, through a
|
|
||||||
publicly available network server or other readily accessible means,
|
|
||||||
then you must either (1) cause the Corresponding Source to be so
|
|
||||||
available, or (2) arrange to deprive yourself of the benefit of the
|
|
||||||
patent license for this particular work, or (3) arrange, in a manner
|
|
||||||
consistent with the requirements of this License, to extend the patent
|
|
||||||
license to downstream recipients. "Knowingly relying" means you have
|
|
||||||
actual knowledge that, but for the patent license, your conveying the
|
|
||||||
covered work in a country, or your recipient's use of the covered work
|
|
||||||
in a country, would infringe one or more identifiable patents in that
|
|
||||||
country that you have reason to believe are valid.
|
|
||||||
|
|
||||||
If, pursuant to or in connection with a single transaction or
|
|
||||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
|
||||||
covered work, and grant a patent license to some of the parties
|
|
||||||
receiving the covered work authorizing them to use, propagate, modify
|
|
||||||
or convey a specific copy of the covered work, then the patent license
|
|
||||||
you grant is automatically extended to all recipients of the covered
|
|
||||||
work and works based on it.
|
|
||||||
|
|
||||||
A patent license is "discriminatory" if it does not include within
|
|
||||||
the scope of its coverage, prohibits the exercise of, or is
|
|
||||||
conditioned on the non-exercise of one or more of the rights that are
|
|
||||||
specifically granted under this License. You may not convey a covered
|
|
||||||
work if you are a party to an arrangement with a third party that is
|
|
||||||
in the business of distributing software, under which you make payment
|
|
||||||
to the third party based on the extent of your activity of conveying
|
|
||||||
the work, and under which the third party grants, to any of the
|
|
||||||
parties who would receive the covered work from you, a discriminatory
|
|
||||||
patent license (a) in connection with copies of the covered work
|
|
||||||
conveyed by you (or copies made from those copies), or (b) primarily
|
|
||||||
for and in connection with specific products or compilations that
|
|
||||||
contain the covered work, unless you entered into that arrangement,
|
|
||||||
or that patent license was granted, prior to 28 March 2007.
|
|
||||||
|
|
||||||
Nothing in this License shall be construed as excluding or limiting
|
|
||||||
any implied license or other defenses to infringement that may
|
|
||||||
otherwise be available to you under applicable patent law.
|
|
||||||
|
|
||||||
12. No Surrender of Others' Freedom.
|
|
||||||
|
|
||||||
If conditions are imposed on you (whether by court order, agreement or
|
|
||||||
otherwise) that contradict the conditions of this License, they do not
|
|
||||||
excuse you from the conditions of this License. If you cannot convey a
|
|
||||||
covered work so as to satisfy simultaneously your obligations under this
|
|
||||||
License and any other pertinent obligations, then as a consequence you may
|
|
||||||
not convey it at all. For example, if you agree to terms that obligate you
|
|
||||||
to collect a royalty for further conveying from those to whom you convey
|
|
||||||
the Program, the only way you could satisfy both those terms and this
|
|
||||||
License would be to refrain entirely from conveying the Program.
|
|
||||||
|
|
||||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, if you modify the
|
|
||||||
Program, your modified version must prominently offer all users
|
|
||||||
interacting with it remotely through a computer network (if your version
|
|
||||||
supports such interaction) an opportunity to receive the Corresponding
|
|
||||||
Source of your version by providing access to the Corresponding Source
|
|
||||||
from a network server at no charge, through some standard or customary
|
|
||||||
means of facilitating copying of software. This Corresponding Source
|
|
||||||
shall include the Corresponding Source for any work covered by version 3
|
|
||||||
of the GNU General Public License that is incorporated pursuant to the
|
|
||||||
following paragraph.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
|
||||||
permission to link or combine any covered work with a work licensed
|
|
||||||
under version 3 of the GNU General Public License into a single
|
|
||||||
combined work, and to convey the resulting work. The terms of this
|
|
||||||
License will continue to apply to the part which is the covered work,
|
|
||||||
but the work with which it is combined will remain governed by version
|
|
||||||
3 of the GNU General Public License.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
|
||||||
the GNU Affero General Public License from time to time. Such new versions
|
|
||||||
will be similar in spirit to the present version, but may differ in detail to
|
|
||||||
address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
|
||||||
Program specifies that a certain numbered version of the GNU Affero General
|
|
||||||
Public License "or any later version" applies to it, you have the
|
|
||||||
option of following the terms and conditions either of that numbered
|
|
||||||
version or of any later version published by the Free Software
|
|
||||||
Foundation. If the Program does not specify a version number of the
|
|
||||||
GNU Affero General Public License, you may choose any version ever published
|
|
||||||
by the Free Software Foundation.
|
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
|
||||||
versions of the GNU Affero General Public License can be used, that proxy's
|
|
||||||
public statement of acceptance of a version permanently authorizes you
|
|
||||||
to choose that version for the Program.
|
|
||||||
|
|
||||||
Later license versions may give you additional or different
|
|
||||||
permissions. However, no additional obligations are imposed on any
|
|
||||||
author or copyright holder as a result of your choosing to follow a
|
|
||||||
later version.
|
|
||||||
|
|
||||||
15. Disclaimer of Warranty.
|
|
||||||
|
|
||||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
|
||||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
|
||||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
|
||||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
|
||||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
||||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
|
||||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
|
||||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
|
||||||
|
|
||||||
16. Limitation of Liability.
|
|
||||||
|
|
||||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
|
||||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
|
||||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
|
||||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
|
||||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
|
||||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
|
||||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
|
||||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
|
||||||
SUCH DAMAGES.
|
|
||||||
|
|
||||||
17. Interpretation of Sections 15 and 16.
|
|
||||||
|
|
||||||
If the disclaimer of warranty and limitation of liability provided
|
|
||||||
above cannot be given local legal effect according to their terms,
|
|
||||||
reviewing courts shall apply local law that most closely approximates
|
|
||||||
an absolute waiver of all civil liability in connection with the
|
|
||||||
Program, unless a warranty or assumption of liability accompanies a
|
|
||||||
copy of the Program in return for a fee.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
|
||||||
|
|
||||||
If you develop a new program, and you want it to be of the greatest
|
|
||||||
possible use to the public, the best way to achieve this is to make it
|
|
||||||
free software which everyone can redistribute and change under these terms.
|
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest
|
|
||||||
to attach them to the start of each source file to most effectively
|
|
||||||
state the exclusion of warranty; and each file should have at least
|
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
|
||||||
Copyright (C) <year> <name of author>
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
|
||||||
|
|
||||||
If your software can interact with users remotely through a computer
|
|
||||||
network, you should also make sure that it provides a way for users to
|
|
||||||
get its source. For example, if your program is a web application, its
|
|
||||||
interface could display a "Source" link that leads users to an archive
|
|
||||||
of the code. There are many ways you could offer source, and different
|
|
||||||
solutions will be better for different programs; see section 13 for the
|
|
||||||
specific requirements.
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
|
||||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
|
||||||
<https://www.gnu.org/licenses/>.
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
<div align="center">
|
|
||||||
|
|
||||||
# BoosAPI Desktop
|
|
||||||
|
|
||||||
**English** | [简体中文](./README.zh-CN.md)
|
|
||||||
|
|
||||||
A local-first AI desktop client for the Boos ecosystem — multi-provider chat, AI agents, knowledge base, MCP, and a built-in API gateway, in one app.
|
|
||||||
|
|
||||||
[](https://github.com/Boos4721/boos-chat/releases)
|
|
||||||
[](#platform-support)
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## What is BoosAPI Desktop
|
|
||||||
|
|
||||||
BoosAPI Desktop is a desktop AI workspace that runs on your own machine. It ships with **BoosAPI** (`https://api.boos.lat`) wired in as the default provider, so you can start chatting immediately, while staying free to connect any other OpenAI-compatible endpoint. Your conversations, keys, and files live locally.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### Chat & models
|
|
||||||
- **Multi-provider chat** — Connect BoosAPI, OpenAI, Claude, Gemini, DeepSeek, Qwen, and any OpenAI-compatible endpoint, with custom base URL, API path, headers, and proxy rules.
|
|
||||||
- **Provider onboarding** — Import provider profiles via `boosapi://` deep links and CC Switch config import, applied after your confirmation.
|
|
||||||
- **Model management** — Sync remote model lists, manage groups, test latency, and configure capability tags, context length, sampling defaults, reasoning profiles, and per-model `extra_body`.
|
|
||||||
- **Conversation workflow** — Streaming replies, reasoning blocks, message versions, conversation branching, title generation, long-conversation compression, and multi-model parallel answers.
|
|
||||||
|
|
||||||
### AI agent
|
|
||||||
- **Agent mode** — Let models read/edit files, run commands, and analyze code inside a controlled desktop workflow.
|
|
||||||
- **Permission control** — Choose standard review, auto-accept edits, or full-access mode, with working-directory sandbox checks.
|
|
||||||
- **Approval & cost panel** — Watch tool calls live, remember allow decisions, and track tokens and cost per agent session.
|
|
||||||
|
|
||||||
### Content rendering
|
|
||||||
- **Markdown & math** — Render Markdown, code highlighting, tables, task lists, and LaTeX inline during streaming.
|
|
||||||
- **Code, diagrams & artifacts** — Built-in Monaco code blocks, Mermaid, D2 diagrams, and an artifact panel for code, notes, reports, and previews.
|
|
||||||
- **HTML fragment rendering** — Safely preview model-generated HTML fragments with streaming-stability fixes.
|
|
||||||
|
|
||||||
### Search & knowledge
|
|
||||||
- **Web search** — Plug in Tavily, Zhipu WebSearch, Bocha, and more, with source citations and search-query generation.
|
|
||||||
- **Local knowledge base** — Index private documents with sqlite-vec, configure retrieval/rerank options, and inspect retrieval feedback.
|
|
||||||
- **Context management** — Attach files, search results, knowledge snippets, memory, and tool output to conversation context.
|
|
||||||
|
|
||||||
### Tools & extensions
|
|
||||||
- **MCP protocol** — Run Model Context Protocol servers over stdio, SSE, or StreamableHTTP transports.
|
|
||||||
- **Built-in tools** — Use built-in MCP tools like fetch and file search without installing separate servers.
|
|
||||||
- **Tool-loop limits** — Configure a max tool-call loop count and recover interrupted or stuck tool sessions.
|
|
||||||
|
|
||||||
### API gateway
|
|
||||||
- **Local gateway** — Expose OpenAI Chat Completions, OpenAI Responses, Claude-native, and Gemini-native APIs from the desktop app.
|
|
||||||
- **Access & observability** — Manage gateway keys, SSL/TLS certificates, request logs, and usage stats locally.
|
|
||||||
- **Client templates** — Ready-made config templates for Claude Code, Codex CLI, OpenCode, Gemini CLI, and custom clients.
|
|
||||||
|
|
||||||
### Data import & backup
|
|
||||||
- **Third-party import** — Import ChatGPT exports, Cherry Studio backups, and Kelivo backups, with preview stats, warnings, and duplicate handling.
|
|
||||||
- **Provider & file migration** — Cherry Studio / Kelivo import can optionally migrate linked providers, API keys, and file attachments.
|
|
||||||
- **Backup** — Back up and restore via a local directory, WebDAV, or S3-compatible storage.
|
|
||||||
|
|
||||||
### Desktop experience & security
|
|
||||||
- **Local encryption** — App state lives in `~/.boosapi/`, user files in `~/Documents/BoosAPI Desktop/`, and API keys are protected with AES-256 and a local master key.
|
|
||||||
- **Desktop integration** — Tray, always-on-top, global shortcuts, autostart, proxy settings, and update checks.
|
|
||||||
- **11 UI languages** — Switch between Simplified Chinese, Traditional Chinese, English, Japanese, Korean, French, German, Spanish, Russian, Hindi, and Arabic.
|
|
||||||
|
|
||||||
## Platform support
|
|
||||||
|
|
||||||
| Platform | Architecture |
|
|
||||||
|----------|--------------|
|
|
||||||
| macOS | Apple Silicon (arm64), Intel (x86_64) |
|
|
||||||
| Windows 10/11 | x86_64, arm64 |
|
|
||||||
| Linux | x86_64 (AppImage/deb/rpm), arm64 (AppImage/deb/rpm) |
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
Download the build for your platform from the [Releases](https://github.com/Boos4721/boos-chat/releases) page. On first launch, BoosAPI is preconfigured as the default provider — add your key in Settings and start chatting.
|
|
||||||
|
|
||||||
## Build from source
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Frontend deps
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# Build the bundled renderer packages (first time only)
|
|
||||||
pnpm --dir libs/stream-monaco install && pnpm --dir libs/stream-monaco build
|
|
||||||
pnpm --dir libs/markstream-vue install && pnpm --dir libs/markstream-vue run build:parser
|
|
||||||
pnpm --dir libs/markstream-vue/packages/markstream-react build
|
|
||||||
|
|
||||||
# Run in dev / produce a bundle
|
|
||||||
pnpm tauri dev
|
|
||||||
pnpm tauri build
|
|
||||||
```
|
|
||||||
|
|
||||||
Requires Rust (stable), Node 20+, pnpm, and the Tauri Linux system dependencies (`webkit2gtk-4.1`, `libsoup-3.0`) on Linux.
|
|
||||||
|
|
||||||
## FAQ
|
|
||||||
|
|
||||||
### macOS says the app is "damaged" or "from an unidentified developer"
|
|
||||||
|
|
||||||
The app is not signed by Apple, so macOS may warn you. Remove the quarantine attribute:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo xattr -dr com.apple.quarantine "/Applications/BoosAPI Desktop.app"
|
|
||||||
```
|
|
||||||
|
|
||||||
Then open it from **System Settings → Privacy & Security** and click **Open Anyway** if prompted.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
BoosAPI Desktop is a fork of [AQBot](https://github.com/AQBot-Desktop/AQBot), used and relicensed with the author's permission. It bundles the MIT-licensed open-agent-sdk. See [LICENSE](LICENSE).
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
<div align="center">
|
|
||||||
|
|
||||||
# BoosAPI Desktop
|
|
||||||
|
|
||||||
[English](./README.md) | **简体中文**
|
|
||||||
|
|
||||||
面向 Boos 生态的本地优先 AI 桌面客户端 —— 多服务商对话、AI Agent、知识库、MCP 与内置 API 网关,集于一身。
|
|
||||||
|
|
||||||
[](https://github.com/Boos4721/boos-chat/releases)
|
|
||||||
[](#平台支持)
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## BoosAPI Desktop 是什么
|
|
||||||
|
|
||||||
BoosAPI Desktop 是一款运行在你本机的 AI 桌面工作台。它默认接入 **BoosAPI**(`https://api.boos.lat`)作为服务商,开箱即用,同时也能连接任意 OpenAI 兼容端点。对话、密钥与文件都保存在本地。
|
|
||||||
|
|
||||||
## 功能特性
|
|
||||||
|
|
||||||
### 对话与模型
|
|
||||||
- **多服务商对话** —— 连接 BoosAPI、OpenAI、Claude、Gemini、DeepSeek、Qwen 以及任意 OpenAI 兼容端点,支持自定义 Base URL、API Path、请求头和代理规则。
|
|
||||||
- **服务商快速导入** —— 通过 `boosapi://` 服务商链接和 CC Switch 配置导入,在用户确认后把服务商资料带入应用。
|
|
||||||
- **模型管理** —— 同步远程模型列表、管理分组、测试延迟,并配置能力标签、上下文长度、采样默认值、推理档位和模型级 `extra_body`。
|
|
||||||
- **对话工作流** —— 流式回复、思考块、消息多版本、对话分支、标题生成状态、长对话压缩和多模型并行回答。
|
|
||||||
|
|
||||||
### AI Agent
|
|
||||||
- **Agent 模式** —— 让模型在受控桌面工作流中读取/编辑文件、执行命令并分析代码。
|
|
||||||
- **权限控制** —— 可选择标准审核、自动接受编辑或完全访问模式,同时保留工作目录沙箱检查。
|
|
||||||
- **审批与成本面板** —— 实时查看工具调用、记住允许决策,并跟踪每个 Agent 会话的 token 与成本。
|
|
||||||
|
|
||||||
### 内容渲染
|
|
||||||
- **Markdown 与数学公式** —— 在流式对话中渲染 Markdown、代码高亮、表格、任务列表和 LaTeX 公式。
|
|
||||||
- **代码、图表与 Artifact** —— 内置 Monaco 代码块、Mermaid、D2 图表和 Artifact 面板,用于代码、笔记、报告和预览。
|
|
||||||
- **HTML 片段渲染** —— 安全预览模型生成的 HTML 片段,并包含流式渲染稳定性修复。
|
|
||||||
|
|
||||||
### 搜索与知识
|
|
||||||
- **联网搜索** —— 接入 Tavily、智谱 WebSearch、Bocha 等搜索服务,支持引用来源和搜索查询生成。
|
|
||||||
- **本地知识库** —— 用 sqlite-vec 索引私有文档,配置检索/重排选项并查看检索反馈。
|
|
||||||
- **上下文管理** —— 把文件、搜索结果、知识片段、记忆和工具输出附加到对话上下文。
|
|
||||||
|
|
||||||
### 工具与扩展
|
|
||||||
- **MCP 协议** —— 运行 stdio、SSE 或 StreamableHTTP 传输的 Model Context Protocol 服务器。
|
|
||||||
- **内置工具** —— 直接使用 fetch、文件搜索等内置 MCP 工具,无需额外安装独立服务器。
|
|
||||||
- **工具循环上限** —— 可配置 MCP 工具调用最大循环次数,并更好地恢复中断或卡住的工具会话。
|
|
||||||
|
|
||||||
### API 网关
|
|
||||||
- **本地网关** —— 从桌面应用暴露 OpenAI Chat Completions、OpenAI Responses、Claude 原生和 Gemini 原生接口。
|
|
||||||
- **访问与观测** —— 本地管理网关密钥、SSL/TLS 证书、请求日志和用量统计。
|
|
||||||
- **客户端模板** —— 提供 Claude Code、Codex CLI、OpenCode、Gemini CLI 和自定义客户端的配置模板。
|
|
||||||
|
|
||||||
### 数据导入与备份
|
|
||||||
- **第三方导入** —— 导入 ChatGPT 官方导出、Cherry Studio 备份和 Kelivo 备份,带预览统计、警告和重复处理。
|
|
||||||
- **服务商与文件迁移** —— Cherry Studio/Kelivo 导入可选择迁移关联服务商、API Key 和文件附件。
|
|
||||||
- **备份** —— 通过本地目录、WebDAV 或 S3 兼容存储进行备份与恢复。
|
|
||||||
|
|
||||||
### 桌面体验与安全
|
|
||||||
- **本地加密** —— 应用状态位于 `~/.boosapi/`,用户文件位于 `~/Documents/BoosAPI Desktop/`,API Key 使用 AES-256 和本地主密钥保护。
|
|
||||||
- **桌面集成** —— 支持托盘、窗口置顶、全局快捷键、开机自启、代理设置和自动更新检查。
|
|
||||||
- **11 种界面语言** —— 可在简体中文、繁体中文、英语、日语、韩语、法语、德语、西班牙语、俄语、印地语和阿拉伯语之间切换。
|
|
||||||
|
|
||||||
## 平台支持
|
|
||||||
|
|
||||||
| 平台 | 架构 |
|
|
||||||
|------|------|
|
|
||||||
| macOS | Apple Silicon (arm64)、Intel (x86_64) |
|
|
||||||
| Windows 10/11 | x86_64、arm64 |
|
|
||||||
| Linux | x86_64 (AppImage/deb/rpm)、arm64 (AppImage/deb/rpm) |
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
前往 [Releases](https://github.com/Boos4721/boos-chat/releases) 页面下载适合你平台的安装包。首次启动时 BoosAPI 已预置为默认服务商 —— 在设置中填入你的密钥即可开始对话。
|
|
||||||
|
|
||||||
## 从源码构建
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 前端依赖
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# 构建内置渲染包(仅首次)
|
|
||||||
pnpm --dir libs/stream-monaco install && pnpm --dir libs/stream-monaco build
|
|
||||||
pnpm --dir libs/markstream-vue install && pnpm --dir libs/markstream-vue run build:parser
|
|
||||||
pnpm --dir libs/markstream-vue/packages/markstream-react build
|
|
||||||
|
|
||||||
# 开发运行 / 打包
|
|
||||||
pnpm tauri dev
|
|
||||||
pnpm tauri build
|
|
||||||
```
|
|
||||||
|
|
||||||
需要 Rust (stable)、Node 20+、pnpm,Linux 下还需 Tauri 系统依赖(`webkit2gtk-4.1`、`libsoup-3.0`)。
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
### macOS 提示"已损坏"或"无法验证开发者"
|
|
||||||
|
|
||||||
应用未经 Apple 签名,macOS 可能弹出提示。移除隔离属性即可:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo xattr -dr com.apple.quarantine "/Applications/BoosAPI Desktop.app"
|
|
||||||
```
|
|
||||||
|
|
||||||
随后在 **「系统设置 → 隐私与安全性」** 点击 **「仍要打开」**。
|
|
||||||
|
|
||||||
## 许可证
|
|
||||||
|
|
||||||
BoosAPI Desktop 是 [AQBot](https://github.com/AQBot-Desktop/AQBot) 的 fork,经作者授权使用并重新许可,内含 MIT 许可的 open-agent-sdk。详见 [LICENSE](LICENSE)。
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
# BoosApi Storage Policy
|
|
||||||
|
|
||||||
## Dual-Root Directory Model
|
|
||||||
|
|
||||||
BoosApi uses two directory roots with distinct responsibilities:
|
|
||||||
|
|
||||||
| Root | Platform path | Purpose |
|
|
||||||
|------|---------------|---------|
|
|
||||||
| **Config home** | macOS/Linux: `~/.boosapi/`<br>Windows: `%USERPROFILE%\.boosapi\` | Application state, database, encryption keys, SSL, vector DB |
|
|
||||||
| **Documents root** | macOS/Linux: `~/Documents/boosapi/`<br>Windows: `%USERPROFILE%\Documents\boosapi\` | User-visible files: images, documents, backups |
|
|
||||||
|
|
||||||
Both directories are created automatically on first launch.
|
|
||||||
|
|
||||||
## Directory Layout
|
|
||||||
|
|
||||||
### Config home (`~/.boosapi/`)
|
|
||||||
|
|
||||||
```
|
|
||||||
~/.boosapi/
|
|
||||||
├── boosapi.db # SQLite database (all app state, settings, keys, …)
|
|
||||||
├── master.key # 32-byte AES-256 master encryption key (mode 0600 on Unix)
|
|
||||||
├── vector_db/ # sqlite-vec vector store for knowledge-base embeddings
|
|
||||||
└── ssl/ # Self-signed TLS certificate and private key for the gateway
|
|
||||||
├── cert.pem
|
|
||||||
└── key.pem # mode 0600 on Unix
|
|
||||||
```
|
|
||||||
|
|
||||||
### Documents root (`~/Documents/boosapi/`)
|
|
||||||
|
|
||||||
```
|
|
||||||
~/Documents/boosapi/
|
|
||||||
├── images/ # Image attachments (chat uploads, avatars, AI-generated)
|
|
||||||
├── files/ # Non-image file attachments (documents, code, archives)
|
|
||||||
└── backups/ # Default location for auto- and manual backups
|
|
||||||
```
|
|
||||||
|
|
||||||
All paths stored in the database (e.g. `messages.attachments`, `stored_files.storage_path`)
|
|
||||||
use **relative paths** under the documents root (e.g. `images/abc123_photo.jpg`).
|
|
||||||
|
|
||||||
## Design Decisions
|
|
||||||
|
|
||||||
### Dual-Root vs. Single Home
|
|
||||||
|
|
||||||
User-created files (images, documents, backups) belong in a user-visible
|
|
||||||
location under `~/Documents/` so users can browse, back up, and share them
|
|
||||||
with standard OS tools. Application internals (database, encryption keys,
|
|
||||||
vector indices) stay hidden in `~/.boosapi/` to avoid clutter and accidental
|
|
||||||
modification.
|
|
||||||
|
|
||||||
### Single Home vs. Tauri `app_data_dir`
|
|
||||||
|
|
||||||
Tauri's `app_data_dir()` resolves to platform-specific, version-locked paths
|
|
||||||
(e.g. `~/Library/Application Support/top.boosapi.app/` on macOS). Using a
|
|
||||||
user-visible `~/.boosapi/` makes backups, debugging, and cross-version upgrades
|
|
||||||
predictable and independent of the Tauri bundle identifier.
|
|
||||||
|
|
||||||
### `boosapi.db` + `master.key` — Atomic Migration
|
|
||||||
|
|
||||||
The database and its master encryption key are always migrated as a matched
|
|
||||||
pair. During the one-time migration from the legacy `app_data_dir`:
|
|
||||||
|
|
||||||
1. Both files are copied to `~/.boosapi/*.migrating` staging names.
|
|
||||||
2. Both staging files are renamed to their final names in a single pass.
|
|
||||||
3. If either step fails the staging files are cleaned up and the old location
|
|
||||||
is left intact — no data is ever left in a half-migrated state.
|
|
||||||
|
|
||||||
### Other Subdirectories
|
|
||||||
|
|
||||||
`vector_db/` and `ssl/` are migrated best-effort (rename, falling back to
|
|
||||||
copy+delete for cross-device moves). A failure to migrate a subdirectory is
|
|
||||||
logged as a warning; the application continues normally and the new
|
|
||||||
subdirectory will be created empty on next use.
|
|
||||||
|
|
||||||
### Backup Defaults
|
|
||||||
|
|
||||||
`resolve_backup_dir(None)` returns `~/Documents/boosapi/backups/`.
|
|
||||||
Users may override this via Settings → Backup → Backup Directory; an absolute
|
|
||||||
path stored there takes precedence.
|
|
||||||
|
|
||||||
### SSL Certificate Storage
|
|
||||||
|
|
||||||
`generate_self_signed_cert` writes `cert.pem` and `key.pem` to
|
|
||||||
`~/.boosapi/ssl/`. The private key is written atomically (temp-file + rename)
|
|
||||||
with mode `0600` on Unix.
|
|
||||||
|
|
||||||
## Agent / Automation Guidance
|
|
||||||
|
|
||||||
- Application state (database, keys, vector DB): read/write under `~/.boosapi/`
|
|
||||||
- User files (images, documents, backups): read/write under `~/Documents/boosapi/`
|
|
||||||
- Database paths for files must be **relative** to the documents root
|
|
||||||
- Do **not** hard-code paths derived from `app_data_dir`, bundle identifier,
|
|
||||||
or application version strings
|
|
||||||
- All directory names are **lowercase** with no spaces
|
|
||||||
|
|
||||||
## UI Conventions
|
|
||||||
|
|
||||||
### Image Preview & Modal Rules
|
|
||||||
|
|
||||||
All antd `<Image>` components **must** use blur-mask preview:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<Image preview={{ mask: { blur: true }, scaleStep: 0.5 }} />
|
|
||||||
```
|
|
||||||
|
|
||||||
- `mask: { blur: true }` — hover shows a blurred overlay (never `mask: false` or plain text mask)
|
|
||||||
- `scaleStep: 0.5` — consistent zoom step across the app
|
|
||||||
- These settings apply to **all** image previews: chat attachments, file list thumbnails, avatar previews, etc.
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# git-cliff configuration
|
|
||||||
# https://git-cliff.org/docs/configuration
|
|
||||||
|
|
||||||
[changelog]
|
|
||||||
header = ""
|
|
||||||
body = """
|
|
||||||
{% for group, commits in commits | group_by(attribute="group") %}
|
|
||||||
### {{ group | upper_first }}
|
|
||||||
{% for commit in commits %}
|
|
||||||
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.message | split(pat="\n") | first | trim }}{% if commit.remote.pr_number %} (#{{ commit.remote.pr_number }}){% endif %} ([#{{ commit.id | truncate(length=7, end="") }}](https://github.com/BoosApi-Desktop/BoosApi/commit/{{ commit.id }}))
|
|
||||||
{%- endfor %}
|
|
||||||
{% endfor %}
|
|
||||||
"""
|
|
||||||
footer = ""
|
|
||||||
trim = true
|
|
||||||
|
|
||||||
[git]
|
|
||||||
conventional_commits = true
|
|
||||||
filter_unconventional = false
|
|
||||||
split_commits = false
|
|
||||||
commit_preprocessors = [
|
|
||||||
{ pattern = '\(#([0-9]+)\)', replace = "" },
|
|
||||||
]
|
|
||||||
commit_parsers = [
|
|
||||||
{ message = "^feat", group = "🚀 新功能" },
|
|
||||||
{ message = "^fix", group = "🐛 Bug 修复" },
|
|
||||||
{ message = "^perf", group = "⚡ 性能提升" },
|
|
||||||
{ message = "^refactor", group = "🔨 重构" },
|
|
||||||
{ message = "^style", group = "🎨 样式" },
|
|
||||||
{ message = "^docs", group = "📝 文档" },
|
|
||||||
{ message = "^ci", group = "🔧 CI/CD" },
|
|
||||||
{ message = "^chore\\(version\\)", skip = true },
|
|
||||||
{ message = "^chore", group = "📦 杂项" },
|
|
||||||
{ message = "^test", group = "🧪 测试" },
|
|
||||||
{ message = "^build", group = "🏗️ 构建" },
|
|
||||||
{ body = ".*security", group = "🔒 安全" },
|
|
||||||
]
|
|
||||||
filter_commits = false
|
|
||||||
tag_pattern = "v[0-9].*"
|
|
||||||
sort_commits = "newest"
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>BoosAPI Desktop</title>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
<script>
|
|
||||||
// Match initial background to theme to avoid white flash
|
|
||||||
try {
|
|
||||||
var m = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)');
|
|
||||||
if (m && m.matches) document.body.style.backgroundColor = '#141414';
|
|
||||||
} catch(e) {}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
charset = utf-8
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
end_of_line = lf
|
|
||||||
insert_final_newline = true
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
* text=auto eol=lf
|
|
||||||
*.ts linguist-detectable=false
|
|
||||||
*.css linguist-detectable=false
|
|
||||||
*.scss linguist-detectable=false
|
|
||||||
*.js linguist-detectable=true
|
|
||||||
*.vue linguist-detectable=true
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
github: Simon-He95
|
|
||||||
custom: ['https://github.com/Simon-He95/sponsor']
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
name: 🐞 Bug report
|
|
||||||
description: Report an issue with markstream-vue (renderer or parser)
|
|
||||||
labels: [pending triage]
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Thanks for taking the time to fill out this bug report!
|
|
||||||
- Please attach a shareable repro link from the test page when possible: https://markstream-vue.simonhe.me/test
|
|
||||||
- Or provide a minimal StackBlitz/GitHub repo with the failing case.
|
|
||||||
- Raw Markdown samples help us reproduce quickly.
|
|
||||||
- type: textarea
|
|
||||||
id: bug-description
|
|
||||||
attributes:
|
|
||||||
label: Describe the bug
|
|
||||||
description: A clear and concise description of what the bug is. If you intend to submit a PR for this issue, tell us in the description. Thanks!
|
|
||||||
placeholder: I am doing ... What I expect is ... What actually happening is ...
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: input
|
|
||||||
id: reproduction
|
|
||||||
attributes:
|
|
||||||
label: Reproduction link
|
|
||||||
description: Shareable link from the test page (preferred) or a minimal StackBlitz/repo. Reports without a repro may be closed after 3 days.
|
|
||||||
placeholder: https://markstream-vue.simonhe.me/test#...
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: markdown
|
|
||||||
attributes:
|
|
||||||
label: Markdown / content sample
|
|
||||||
description: Provide the exact Markdown (or nodes JSON) that triggers the issue.
|
|
||||||
placeholder: |
|
|
||||||
## Example
|
|
||||||
- [ ] task
|
|
||||||
```js
|
|
||||||
console.log('hello')
|
|
||||||
```
|
|
||||||
- type: textarea
|
|
||||||
id: expected
|
|
||||||
attributes:
|
|
||||||
label: Expected behavior
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: actual
|
|
||||||
attributes:
|
|
||||||
label: Actual behavior
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: dropdown
|
|
||||||
id: package-manager
|
|
||||||
attributes:
|
|
||||||
label: Used Package Manager
|
|
||||||
description: Select the used package manager
|
|
||||||
options:
|
|
||||||
- npm
|
|
||||||
- yarn
|
|
||||||
- pnpm
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: input
|
|
||||||
id: versions
|
|
||||||
attributes:
|
|
||||||
label: Versions
|
|
||||||
description: markstream-vue version, Vue version, and browser/runtime (if applicable).
|
|
||||||
placeholder: markstream-vue 0.0.x, Vue 3.x, Chrome 131 / SSR
|
|
||||||
- type: textarea
|
|
||||||
id: logs
|
|
||||||
attributes:
|
|
||||||
label: Logs
|
|
||||||
description: |
|
|
||||||
Optional if provided reproduction. Please avoid screenshots; copy/paste text instead.
|
|
||||||
|
|
||||||
- If using the playground/nuxt playground, run with `pnpm dev` / `pnpm play:nuxt` and capture console output.
|
|
||||||
- For build issues, run `pnpm build` with `DEBUG=*` and include the stack trace.
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
blank_issues_enabled: false
|
|
||||||
contact_links:
|
|
||||||
- name: 📚 Documentation
|
|
||||||
url: https://markstream-vue-docs.simonhe.me/guide/
|
|
||||||
about: Full docs, troubleshooting, and API usage.
|
|
||||||
- name: 🧪 Test page (shareable repro)
|
|
||||||
url: https://markstream-vue.simonhe.me/test
|
|
||||||
about: Reproduce rendering issues and generate a shareable link for your issue.
|
|
||||||
- name: 🕹️ Playground
|
|
||||||
url: https://markstream-vue.simonhe.me/
|
|
||||||
about: Try the renderer live with sample content.
|
|
||||||
- name: 📖 Support guide
|
|
||||||
url: https://github.com/Simon-He95/markstream-vue/blob/main/SUPPORT.md
|
|
||||||
about: Where to get help, ask questions, or report issues (docs, test page, discussions, discord).
|
|
||||||
- name: 🛡️ Security policy
|
|
||||||
url: https://github.com/Simon-He95/markstream-vue/blob/main/SECURITY.md
|
|
||||||
about: Report potential vulnerabilities privately via email.
|
|
||||||
- name: 💬 Discussions
|
|
||||||
url: https://github.com/Simon-He95/markstream-vue/discussions
|
|
||||||
about: Ask questions, share ideas, or discuss integrations.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
name: 🚀 Feature proposal
|
|
||||||
description: Propose a feature or improvement for markstream-vue
|
|
||||||
labels: ['enhancement: pending triage']
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Thanks for your interest in the project and taking the time to fill out this feature report!
|
|
||||||
- type: textarea
|
|
||||||
id: feature-description
|
|
||||||
attributes:
|
|
||||||
label: Description
|
|
||||||
description: 'What problem does this solve? Please describe the use case (AI/chat/SSE, docs, diff viewer, custom node, etc.). If you intend to submit a PR for this issue, tell us in the description. Thanks!'
|
|
||||||
placeholder: As a developer using markstream-vue I want [goal] so that [benefit].
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: current-config
|
|
||||||
attributes:
|
|
||||||
label: Current config (if applicable)
|
|
||||||
description: 'Renderer props or parser options you are using (e.g., max-live-nodes, batchRendering, renderBatchSize, enableMermaid/Katex/Monaco, custom components).'
|
|
||||||
- type: textarea
|
|
||||||
id: suggested-solution
|
|
||||||
attributes:
|
|
||||||
label: Suggested solution / API shape
|
|
||||||
description: What would the API/prop/config look like?
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: alternative
|
|
||||||
attributes:
|
|
||||||
label: Alternatives
|
|
||||||
description: Clear and concise description of any alternative solutions or workarounds you've considered.
|
|
||||||
- type: textarea
|
|
||||||
id: additional-context
|
|
||||||
attributes:
|
|
||||||
label: Additional context
|
|
||||||
description: Any other context or screenshots about the feature request here (include sample Markdown or mockups if relevant).
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
## Summary
|
|
||||||
|
|
||||||
Please explain the goal of this change in 2–3 lines.
|
|
||||||
|
|
||||||
## Changes
|
|
||||||
|
|
||||||
- [ ]
|
|
||||||
|
|
||||||
## Screenshots / Demo (if UI/behavior changes)
|
|
||||||
|
|
||||||
- [ ] Added GIF/screenshot
|
|
||||||
- [ ] Shareable repro link from https://markstream-vue.simonhe.me/test (recommended)
|
|
||||||
|
|
||||||
## Checklist
|
|
||||||
|
|
||||||
- [ ] Lint: `pnpm lint`
|
|
||||||
- [ ] Typecheck: `pnpm typecheck`
|
|
||||||
- [ ] Tests: `pnpm test` (or `pnpm test:update` if snapshots changed)
|
|
||||||
- [ ] Build: `pnpm build` (library + CSS)
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
name: Lint, Typecheck & Tests
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
os: [ubuntu-latest]
|
|
||||||
node_version: ['lts/*']
|
|
||||||
include:
|
|
||||||
- os: macos-latest
|
|
||||||
node_version: 'lts/*'
|
|
||||||
- os: windows-latest
|
|
||||||
node_version: 'lts/*'
|
|
||||||
fail-fast: false
|
|
||||||
env:
|
|
||||||
CI: true
|
|
||||||
steps:
|
|
||||||
- name: Set git to use LF
|
|
||||||
run: |
|
|
||||||
git config --global core.autocrlf false
|
|
||||||
git config --global core.eol lf
|
|
||||||
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
|
||||||
|
|
||||||
- name: Set node ${{ matrix.node_version }}
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: ${{ matrix.node_version }}
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- run: corepack enable
|
|
||||||
|
|
||||||
- name: Setup
|
|
||||||
run: npm i -g @antfu/ni
|
|
||||||
|
|
||||||
- name: Install
|
|
||||||
run: nci
|
|
||||||
|
|
||||||
- name: Lint (Ubuntu only)
|
|
||||||
if: matrix.os == 'ubuntu-latest'
|
|
||||||
run: pnpm lint
|
|
||||||
|
|
||||||
- name: Typecheck
|
|
||||||
run: nr typecheck
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: nr test -- --run
|
|
||||||
|
|
||||||
- name: Run SSR check
|
|
||||||
run: nr check:ssr
|
|
||||||
|
|
||||||
- name: Build (Ubuntu only)
|
|
||||||
if: matrix.os == 'ubuntu-latest'
|
|
||||||
run: pnpm build
|
|
||||||
|
|
||||||
- name: Check package size budget (Ubuntu only)
|
|
||||||
if: matrix.os == 'ubuntu-latest'
|
|
||||||
run: pnpm size:check
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
name: Docs check
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-docs:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: pnpm
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
- name: Build docs
|
|
||||||
run: pnpm docs:build:ci
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
name: Docs parity check
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
docs-parity:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: pnpm
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
- name: Check docs parity
|
|
||||||
run: pnpm docs:check-zh
|
|
||||||
- name: Build docs
|
|
||||||
run: pnpm build:parser && pnpm build && pnpm docs:build
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
name: Types Declaration Check
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
dts-and-typecheck:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: pnpm
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
- name: Run typecheck
|
|
||||||
run: pnpm run typecheck
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
name: Deploy Docs to GitHub Pages
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: github-pages
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- name: Configure GitHub Pages
|
|
||||||
uses: actions/configure-pages@v5
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build docs for GitHub Pages
|
|
||||||
run: pnpm docs:build:gh:ci
|
|
||||||
|
|
||||||
- name: Disable Jekyll on Pages artifact
|
|
||||||
run: touch docs/.vitepress/dist/.nojekyll
|
|
||||||
|
|
||||||
- name: Upload Pages artifact
|
|
||||||
uses: actions/upload-pages-artifact@v3
|
|
||||||
with:
|
|
||||||
path: docs/.vitepress/dist
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
environment:
|
|
||||||
name: github-pages
|
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
|
||||||
steps:
|
|
||||||
- name: Deploy to GitHub Pages
|
|
||||||
id: deployment
|
|
||||||
uses: actions/deploy-pages@v4
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
name: pkg.pr.new
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
# pkg.pr.new requires its GitHub App to be installed on the repository.
|
|
||||||
# Keep previews opt-in so PR CI does not fail before that external setup exists.
|
|
||||||
if: ${{ vars.PKG_PR_NEW_ENABLED == 'true' }}
|
|
||||||
name: Publish preview packages
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
|
||||||
|
|
||||||
- name: Set node lts/*
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: lts/*
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- run: corepack enable
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build packages
|
|
||||||
run: |
|
|
||||||
pnpm -r --filter "./packages/**" build
|
|
||||||
pnpm build
|
|
||||||
|
|
||||||
- name: Publish preview packages
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: pnpm dlx pkg-pr-new publish . ./packages/markdown-parser ./packages/markstream-react ./packages/markstream-vue2
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
name: Release (Nightly)
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
# 02:15 UTC daily
|
|
||||||
- cron: '15 2 * * *'
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
all:
|
|
||||||
description: Tag all packages even if unchanged
|
|
||||||
required: false
|
|
||||||
default: 'false'
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
tag:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
created: ${{ steps.nightly.outputs.created }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
persist-credentials: true
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
|
|
||||||
- name: Configure git identity (annotated tags)
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
||||||
|
|
||||||
- name: Create nightly tags (if needed)
|
|
||||||
id: nightly
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ARGS=(--push --remote origin)
|
|
||||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.all }}" == "true" ]]; then
|
|
||||||
ARGS+=(--all)
|
|
||||||
fi
|
|
||||||
|
|
||||||
OUT="$(node scripts/create-nightly-tags.mjs "${ARGS[@]}")"
|
|
||||||
echo "${OUT}"
|
|
||||||
CREATED_JSON="$(node -e 'const s=process.argv[1]; const j=JSON.parse(s); console.log(JSON.stringify(j.created||[]))' "${OUT}")"
|
|
||||||
echo "created=${CREATED_JSON}" >> "${GITHUB_OUTPUT}"
|
|
||||||
|
|
||||||
release:
|
|
||||||
needs: tag
|
|
||||||
if: ${{ needs.tag.outputs.created != '[]' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
tag: ${{ fromJson(needs.tag.outputs.created) }}
|
|
||||||
steps:
|
|
||||||
- name: Parse tag
|
|
||||||
id: meta
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
TAG="${{ matrix.tag }}"
|
|
||||||
PKG="${TAG%@*}"
|
|
||||||
ID="${TAG##*@}"
|
|
||||||
echo "pkg=${PKG}" >> "${GITHUB_OUTPUT}"
|
|
||||||
echo "name=[${PKG}] ${ID}" >> "${GITHUB_OUTPUT}"
|
|
||||||
|
|
||||||
- name: Checkout (for changelog body)
|
|
||||||
if: ${{ steps.meta.outputs.pkg == 'markstream-vue' }}
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Build changelog body (best effort)
|
|
||||||
id: changelog
|
|
||||||
if: ${{ steps.meta.outputs.pkg == 'markstream-vue' }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
VERSION="$(grep -m1 '"version"' package.json | sed -E 's/.*"version": "([^"]+)".*/\1/')"
|
|
||||||
BODY_FILE="$(mktemp)"
|
|
||||||
FOUND=0
|
|
||||||
|
|
||||||
if [[ -n "${VERSION}" ]]; then
|
|
||||||
awk -v version="${VERSION}" '
|
|
||||||
BEGIN { capture = 0; found = 0 }
|
|
||||||
/^## \[/ {
|
|
||||||
if (capture == 1)
|
|
||||||
exit
|
|
||||||
if ($0 ~ "^## \\[" version "\\]") {
|
|
||||||
capture = 1
|
|
||||||
found = 1
|
|
||||||
next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
capture == 1 { print }
|
|
||||||
END {
|
|
||||||
if (found == 0)
|
|
||||||
exit 2
|
|
||||||
}
|
|
||||||
' CHANGELOG.md > "${BODY_FILE}" && FOUND=1 || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${FOUND}" == "1" && -s "${BODY_FILE}" ]]; then
|
|
||||||
echo "has_body=true" >> "${GITHUB_OUTPUT}"
|
|
||||||
echo "body_path=${BODY_FILE}" >> "${GITHUB_OUTPUT}"
|
|
||||||
else
|
|
||||||
echo "has_body=false" >> "${GITHUB_OUTPUT}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Create GitHub Pre-release (markstream-vue with changelog)
|
|
||||||
if: ${{ steps.meta.outputs.pkg == 'markstream-vue' && steps.changelog.outputs.has_body == 'true' }}
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
tag_name: ${{ matrix.tag }}
|
|
||||||
name: ${{ steps.meta.outputs.name }}
|
|
||||||
prerelease: true
|
|
||||||
generate_release_notes: true
|
|
||||||
body_path: ${{ steps.changelog.outputs.body_path }}
|
|
||||||
append_body: true
|
|
||||||
|
|
||||||
- name: Create GitHub Pre-release (fallback)
|
|
||||||
if: ${{ steps.meta.outputs.pkg != 'markstream-vue' || steps.changelog.outputs.has_body != 'true' }}
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
tag_name: ${{ matrix.tag }}
|
|
||||||
name: ${{ steps.meta.outputs.name }}
|
|
||||||
prerelease: true
|
|
||||||
generate_release_notes: true
|
|
||||||