docs: add Trellis planning and project specs
This commit is contained in:
32
.trellis/.gitignore
vendored
Normal file
32
.trellis/.gitignore
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
# Developer identity (local only)
|
||||
.developer
|
||||
|
||||
# Current task pointer (each dev works on different task)
|
||||
.current-task
|
||||
|
||||
# Session/window scoped runtime state
|
||||
.runtime/
|
||||
|
||||
# Ralph Loop state file
|
||||
.ralph-state.json
|
||||
|
||||
# Agent runtime files
|
||||
.agents/
|
||||
.agent-log
|
||||
.session-id
|
||||
|
||||
# Task directory runtime files
|
||||
.plan-log
|
||||
|
||||
# Atomic update temp files
|
||||
*.tmp
|
||||
|
||||
# Update backup directories
|
||||
.backup-*
|
||||
|
||||
# Conflict resolution temp files
|
||||
*.new
|
||||
|
||||
# Python cache
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
333
.trellis/.template-hashes.json
Normal file
333
.trellis/.template-hashes.json
Normal file
@@ -0,0 +1,333 @@
|
||||
{
|
||||
"__version": 2,
|
||||
"hashes": {
|
||||
".claude/agents/trellis-check.md": "9e48342243f311d55386f8fb42933945e87aba73d5ade547133aa98345a06128",
|
||||
".claude/agents/trellis-implement.md": "73b56b3047c0e852382c4630aa181fb847d1e5ea1f63459dac4bf728f43fd097",
|
||||
".claude/agents/trellis-research.md": "add4aa4259ded425b04ec992802c646490352bb4eb3730a7ab450beea87d4faa",
|
||||
".claude/settings.json": "1a65892b2b161910468970ab30ebc3f8216241640f75fccbe4be5552c58a2752",
|
||||
".claude/hooks/inject-subagent-context.py": "c8ea04062990530dffb26c4d1efa3e6887e042d3086da2f2325b491c9d544931",
|
||||
".claude/hooks/inject-workflow-state.py": "552d4ee4ca059337856c587fde1aaf79364444d0465689faa88c43797969b7de",
|
||||
".claude/hooks/session-start.py": "688e9d5c2273575b2c1cc8db17fa39da7b1798542a67f65286d97cdbdc347995",
|
||||
".claude/hooks/statusline.py": "019130b79f062192e149b6b09fda2103ee2aaac9e2f0ccba0efd6fbc5e156cf3",
|
||||
".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": "3bbfb6506af3c318c6f9b3c280517ce93a02e40e27535de7ff5fc029d3027860",
|
||||
".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": "4c18b134f05d5ba1609c517ec94c97c9442b5f0670aa2211c487c31ed2a4f358",
|
||||
".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": "1128d45b2e8c011c247bcf3a3ca1184ca4951fb34a0802c50bf3fecbd7e4de55",
|
||||
".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": "acdeba5866ccbe0e68fffc847e77e726cf79f579898e61312f6a8d62b48380ff",
|
||||
".claude/skills/trellis-meta/references/platform-files/overview.md": "821a2718b38e17439c2bb487a6dd3a95c8138e83dbda60b142a00ff80178dc3c",
|
||||
".claude/skills/trellis-meta/references/platform-files/platform-map.md": "59e13cdaa2077149b5d82ad25fa5dd821195be30bdc43d853619c4702d334a6e",
|
||||
".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",
|
||||
".cursor/commands/trellis-continue.md": "338f80aab71e576de25c736e3aa7ea73fc2d5b433ff03f90e78d8958870945bc",
|
||||
".cursor/commands/trellis-finish-work.md": "5a8a72fd87d009c15068bae75165af97f1b03e975b24f5dc3b901b01c6914160",
|
||||
".cursor/skills/trellis-before-dev/SKILL.md": "859894f2e8258cbfa142d710363433761c54984c69f7ed7bd44512fb4eb165cd",
|
||||
".cursor/skills/trellis-brainstorm/SKILL.md": "3bbfb6506af3c318c6f9b3c280517ce93a02e40e27535de7ff5fc029d3027860",
|
||||
".cursor/skills/trellis-break-loop/SKILL.md": "f5a93699832f29dee443b53c135a7459519b371f689af191d15c29e8ee5c7bde",
|
||||
".cursor/skills/trellis-check/SKILL.md": "b21ff04b7680ebacb8c5ecbc48a22d627eb13e2b47fceb78c8ced0b43b60b282",
|
||||
".cursor/skills/trellis-update-spec/SKILL.md": "cef32aec88db973a0a0272cf18b91d4585fe6ed6625e3de06851a5d3402f65d6",
|
||||
".cursor/skills/trellis-channel/references/command-reference.md": "c4df3d940d89814310bfdaded20e84dd1d6b96786f6772688de7a123338d3c8e",
|
||||
".cursor/skills/trellis-channel/references/forum.md": "407db39e8ebde47cea8637e3069bdb3d76ba42b43ed1d08ba97a56f26dbcfcb4",
|
||||
".cursor/skills/trellis-channel/references/progress-debugging.md": "93b021fd8d5d3d4ee54952c40424ab91ca57c2ab8032bfbfc3b5c5e22f1464cd",
|
||||
".cursor/skills/trellis-channel/references/workers.md": "d4481b2858bca82050cb8d131916da8a2ccc3857f199cdb9ced8bb41d592e2a7",
|
||||
".cursor/skills/trellis-channel/references/workflows.md": "0782d602efde6e94a7d7d7950fd605eedc9a1f5a523908284e76b628fd618fd4",
|
||||
".cursor/skills/trellis-channel/SKILL.md": "ff84801c511a736da018b4b1149348ebc9a16b7fc18d905ec10cfa7acbf269a3",
|
||||
".cursor/skills/trellis-meta/references/customize-local/add-project-local-conventions.md": "86009ccb5d0373f399582da0bc570c4e5c6053c3c764857424ff93384f0e04e5",
|
||||
".cursor/skills/trellis-meta/references/customize-local/change-agents.md": "3eef0d9b9cf875f121c1a38afb8eee6d3cd3894127db601ae5a7760fa5b61575",
|
||||
".cursor/skills/trellis-meta/references/customize-local/change-context-loading.md": "350d319dc1ab99609ddbf52cf8c06c71bd97ba1a29ce2eac8b97d0bb192938bf",
|
||||
".cursor/skills/trellis-meta/references/customize-local/change-hooks.md": "4c18b134f05d5ba1609c517ec94c97c9442b5f0670aa2211c487c31ed2a4f358",
|
||||
".cursor/skills/trellis-meta/references/customize-local/change-skills-or-commands.md": "a6994e2418cdf5bcad10b4236b02741179ae794bc4fdd811a64f443293b69268",
|
||||
".cursor/skills/trellis-meta/references/customize-local/change-spec-structure.md": "31eccaad7097d96e66a45c1b4caea1ba4f2e54b7814184c3ebf82c87dafc4841",
|
||||
".cursor/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "60ff9efb93604b87a461a4af30322d76750402a51e40f31531a7ff88d309996d",
|
||||
".cursor/skills/trellis-meta/references/customize-local/change-workflow.md": "43fa780a2ca580de121b10893d49b99f978873deebbf45008c466e5ac6651519",
|
||||
".cursor/skills/trellis-meta/references/customize-local/overview.md": "ce8f09e9f93ce9a48500763fb3a4db2b3908a5fbf4f985ab71dacebb404cf8f4",
|
||||
".cursor/skills/trellis-meta/references/local-architecture/bundled-skills.md": "bac739b042d7a12dae5d14d85cbc312e56ca03f571d2ba7a25b20641ddaefc3f",
|
||||
".cursor/skills/trellis-meta/references/local-architecture/context-injection.md": "8497289bf333b3aa456f317039d1239b7ece79254aa0eb62cfc647714c866084",
|
||||
".cursor/skills/trellis-meta/references/local-architecture/generated-files.md": "1128d45b2e8c011c247bcf3a3ca1184ca4951fb34a0802c50bf3fecbd7e4de55",
|
||||
".cursor/skills/trellis-meta/references/local-architecture/multi-agent-channel.md": "56e5070474aeca872e2d70c46feea5aaafecd3d3ec052c3f7b1877358dca62e9",
|
||||
".cursor/skills/trellis-meta/references/local-architecture/overview.md": "50638fd9eaaba2e0edf2f2a84d920578d5b2fb7934031b174e417d3dc510b2a6",
|
||||
".cursor/skills/trellis-meta/references/local-architecture/spec-system.md": "b8d8a6a0888b44a232c8f50161b9e20e903cf621ad7be4021715ab6fab226f47",
|
||||
".cursor/skills/trellis-meta/references/local-architecture/task-system.md": "2b561d49c390f7d0db5391912946133be4bf73189231e2b8cc9afa1c5ac6165a",
|
||||
".cursor/skills/trellis-meta/references/local-architecture/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6",
|
||||
".cursor/skills/trellis-meta/references/local-architecture/workspace-memory.md": "e6427b46aba744563c2444b30df4043cd856561b7709ec2dece26095416421fd",
|
||||
".cursor/skills/trellis-meta/references/platform-files/agents.md": "ce1a22c2b3ff0bff68755b5663e1f3b63ef2f1658cff43e511cee9013537c698",
|
||||
".cursor/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "acdeba5866ccbe0e68fffc847e77e726cf79f579898e61312f6a8d62b48380ff",
|
||||
".cursor/skills/trellis-meta/references/platform-files/overview.md": "821a2718b38e17439c2bb487a6dd3a95c8138e83dbda60b142a00ff80178dc3c",
|
||||
".cursor/skills/trellis-meta/references/platform-files/platform-map.md": "59e13cdaa2077149b5d82ad25fa5dd821195be30bdc43d853619c4702d334a6e",
|
||||
".cursor/skills/trellis-meta/references/platform-files/skills-and-commands.md": "5732bb0a7d384a91abd0c103372f3865e0b186e75185f35e0e91b00a6b6bf5a3",
|
||||
".cursor/skills/trellis-meta/SKILL.md": "a190ecbde70a658a5b5c3e2558783e99c9afc6f2e5cdecdac8f11c06dd42065e",
|
||||
".cursor/skills/trellis-session-insight/references/cli-quick-reference.md": "c520353fe3fc00b9702ed4f780647c8fbd6d342e66527a03647f533a9fe09779",
|
||||
".cursor/skills/trellis-session-insight/references/triggering-patterns.md": "121ecd23be83d1567e8ce15c366a81073d7a2b1d3ad616fce235c07ca1f1cc20",
|
||||
".cursor/skills/trellis-session-insight/SKILL.md": "f6ad80eaca21b8fa63d31ae26b612bb2bb69dd04469603828509a8c5a0463ff1",
|
||||
".cursor/skills/trellis-spec-bootstrap/references/mcp-setup.md": "df542fc8f279edd38046d26a7c8151804b708f57b24d4aa2733cea587a88c65e",
|
||||
".cursor/skills/trellis-spec-bootstrap/references/repository-analysis.md": "0dae98d774f6e34559b9f3442888ac43e3a8af110c37cbefc49ce256986858b6",
|
||||
".cursor/skills/trellis-spec-bootstrap/references/spec-task-planning.md": "ef493d028c3b0807a8a534bb71fb92a68129f273db763ad27ceb464a522e799d",
|
||||
".cursor/skills/trellis-spec-bootstrap/references/spec-writing.md": "e9800fe9ed4a4cd87062ea1829cf2caa8d170ec15e141678a6a30e74c497f47d",
|
||||
".cursor/skills/trellis-spec-bootstrap/SKILL.md": "97bfa68c06cebb558eb4464bc1b81f7d2d56040d75baa8de1ee5ad90cca0196a",
|
||||
".cursor/agents/trellis-check.md": "e54d3de996bab4abd7653eef4d64c171638fbc2859e9b1fe4aae2483df72de99",
|
||||
".cursor/agents/trellis-implement.md": "636d55ebb56d9d3ea6ef9ec615c53bcaeab2b1afb3dc7e30ad51091efe5ab5ea",
|
||||
".cursor/agents/trellis-research.md": "1311b229a5d8c2c388ceb52460c3d837aa982c988aac39fb9ae30385072fbacf",
|
||||
".cursor/hooks/inject-shell-session-context.py": "28502dd7cb657fed92005c2e1c60334ce216545c40d4791b9433cbf779f83968",
|
||||
".cursor/hooks/inject-subagent-context.py": "c8ea04062990530dffb26c4d1efa3e6887e042d3086da2f2325b491c9d544931",
|
||||
".cursor/hooks/session-start.py": "688e9d5c2273575b2c1cc8db17fa39da7b1798542a67f65286d97cdbdc347995",
|
||||
".cursor/hooks.json": "c7a830671610c1d433c97b3cb880e317730862631fbe3fd76d052553c83f49b3",
|
||||
".agents/skills/trellis-continue/SKILL.md": "7723ccf49fbf19d8f086cacc7a080bd8be8db6fc70a32908b80f68efa318d7bf",
|
||||
".agents/skills/trellis-finish-work/SKILL.md": "161060fbcd44f787440d3a5c297a9f5223ea7774bb3021a50e376875a9ac5b2d",
|
||||
".agents/skills/trellis-start/SKILL.md": "79a5ba7a2aff3c72e06d7f4cd6942dc4f4f4092dd40f9c8e94f1838024a81e4d",
|
||||
".agents/skills/trellis-before-dev/SKILL.md": "859894f2e8258cbfa142d710363433761c54984c69f7ed7bd44512fb4eb165cd",
|
||||
".agents/skills/trellis-brainstorm/SKILL.md": "3bbfb6506af3c318c6f9b3c280517ce93a02e40e27535de7ff5fc029d3027860",
|
||||
".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": "4c18b134f05d5ba1609c517ec94c97c9442b5f0670aa2211c487c31ed2a4f358",
|
||||
".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": "1128d45b2e8c011c247bcf3a3ca1184ca4951fb34a0802c50bf3fecbd7e4de55",
|
||||
".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": "acdeba5866ccbe0e68fffc847e77e726cf79f579898e61312f6a8d62b48380ff",
|
||||
".agents/skills/trellis-meta/references/platform-files/overview.md": "821a2718b38e17439c2bb487a6dd3a95c8138e83dbda60b142a00ff80178dc3c",
|
||||
".agents/skills/trellis-meta/references/platform-files/platform-map.md": "59e13cdaa2077149b5d82ad25fa5dd821195be30bdc43d853619c4702d334a6e",
|
||||
".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",
|
||||
".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": "552d4ee4ca059337856c587fde1aaf79364444d0465689faa88c43797969b7de",
|
||||
".codex/hooks.json": "522ba3c488c100027783e52ecff84c0bd799852dd77ad3f1936e86db105f01d6",
|
||||
".codex/config.toml": "4224eb7df6802a623cb1bee522aed0a23ba6be862b90f1b597a313fc16864b06",
|
||||
".pi/prompts/trellis-start.md": "28af1eb6645d8b517cf705277d8405370b712926e6b01667d6698564002c6a9d",
|
||||
".pi/prompts/trellis-continue.md": "12c2f0288ff67af3368c0b577a50027a11a25fec1d32ed34282a4dc84be08f1c",
|
||||
".pi/prompts/trellis-finish-work.md": "5a8a72fd87d009c15068bae75165af97f1b03e975b24f5dc3b901b01c6914160",
|
||||
".pi/skills/trellis-before-dev/SKILL.md": "859894f2e8258cbfa142d710363433761c54984c69f7ed7bd44512fb4eb165cd",
|
||||
".pi/skills/trellis-brainstorm/SKILL.md": "3bbfb6506af3c318c6f9b3c280517ce93a02e40e27535de7ff5fc029d3027860",
|
||||
".pi/skills/trellis-break-loop/SKILL.md": "f5a93699832f29dee443b53c135a7459519b371f689af191d15c29e8ee5c7bde",
|
||||
".pi/skills/trellis-check/SKILL.md": "b21ff04b7680ebacb8c5ecbc48a22d627eb13e2b47fceb78c8ced0b43b60b282",
|
||||
".pi/skills/trellis-update-spec/SKILL.md": "cef32aec88db973a0a0272cf18b91d4585fe6ed6625e3de06851a5d3402f65d6",
|
||||
".pi/skills/trellis-channel/references/command-reference.md": "c4df3d940d89814310bfdaded20e84dd1d6b96786f6772688de7a123338d3c8e",
|
||||
".pi/skills/trellis-channel/references/forum.md": "407db39e8ebde47cea8637e3069bdb3d76ba42b43ed1d08ba97a56f26dbcfcb4",
|
||||
".pi/skills/trellis-channel/references/progress-debugging.md": "93b021fd8d5d3d4ee54952c40424ab91ca57c2ab8032bfbfc3b5c5e22f1464cd",
|
||||
".pi/skills/trellis-channel/references/workers.md": "d4481b2858bca82050cb8d131916da8a2ccc3857f199cdb9ced8bb41d592e2a7",
|
||||
".pi/skills/trellis-channel/references/workflows.md": "0782d602efde6e94a7d7d7950fd605eedc9a1f5a523908284e76b628fd618fd4",
|
||||
".pi/skills/trellis-channel/SKILL.md": "ff84801c511a736da018b4b1149348ebc9a16b7fc18d905ec10cfa7acbf269a3",
|
||||
".pi/skills/trellis-meta/references/customize-local/add-project-local-conventions.md": "86009ccb5d0373f399582da0bc570c4e5c6053c3c764857424ff93384f0e04e5",
|
||||
".pi/skills/trellis-meta/references/customize-local/change-agents.md": "3eef0d9b9cf875f121c1a38afb8eee6d3cd3894127db601ae5a7760fa5b61575",
|
||||
".pi/skills/trellis-meta/references/customize-local/change-context-loading.md": "350d319dc1ab99609ddbf52cf8c06c71bd97ba1a29ce2eac8b97d0bb192938bf",
|
||||
".pi/skills/trellis-meta/references/customize-local/change-hooks.md": "4c18b134f05d5ba1609c517ec94c97c9442b5f0670aa2211c487c31ed2a4f358",
|
||||
".pi/skills/trellis-meta/references/customize-local/change-skills-or-commands.md": "a6994e2418cdf5bcad10b4236b02741179ae794bc4fdd811a64f443293b69268",
|
||||
".pi/skills/trellis-meta/references/customize-local/change-spec-structure.md": "31eccaad7097d96e66a45c1b4caea1ba4f2e54b7814184c3ebf82c87dafc4841",
|
||||
".pi/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "60ff9efb93604b87a461a4af30322d76750402a51e40f31531a7ff88d309996d",
|
||||
".pi/skills/trellis-meta/references/customize-local/change-workflow.md": "43fa780a2ca580de121b10893d49b99f978873deebbf45008c466e5ac6651519",
|
||||
".pi/skills/trellis-meta/references/customize-local/overview.md": "ce8f09e9f93ce9a48500763fb3a4db2b3908a5fbf4f985ab71dacebb404cf8f4",
|
||||
".pi/skills/trellis-meta/references/local-architecture/bundled-skills.md": "bac739b042d7a12dae5d14d85cbc312e56ca03f571d2ba7a25b20641ddaefc3f",
|
||||
".pi/skills/trellis-meta/references/local-architecture/context-injection.md": "8497289bf333b3aa456f317039d1239b7ece79254aa0eb62cfc647714c866084",
|
||||
".pi/skills/trellis-meta/references/local-architecture/generated-files.md": "1128d45b2e8c011c247bcf3a3ca1184ca4951fb34a0802c50bf3fecbd7e4de55",
|
||||
".pi/skills/trellis-meta/references/local-architecture/multi-agent-channel.md": "56e5070474aeca872e2d70c46feea5aaafecd3d3ec052c3f7b1877358dca62e9",
|
||||
".pi/skills/trellis-meta/references/local-architecture/overview.md": "50638fd9eaaba2e0edf2f2a84d920578d5b2fb7934031b174e417d3dc510b2a6",
|
||||
".pi/skills/trellis-meta/references/local-architecture/spec-system.md": "b8d8a6a0888b44a232c8f50161b9e20e903cf621ad7be4021715ab6fab226f47",
|
||||
".pi/skills/trellis-meta/references/local-architecture/task-system.md": "2b561d49c390f7d0db5391912946133be4bf73189231e2b8cc9afa1c5ac6165a",
|
||||
".pi/skills/trellis-meta/references/local-architecture/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6",
|
||||
".pi/skills/trellis-meta/references/local-architecture/workspace-memory.md": "e6427b46aba744563c2444b30df4043cd856561b7709ec2dece26095416421fd",
|
||||
".pi/skills/trellis-meta/references/platform-files/agents.md": "ce1a22c2b3ff0bff68755b5663e1f3b63ef2f1658cff43e511cee9013537c698",
|
||||
".pi/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "acdeba5866ccbe0e68fffc847e77e726cf79f579898e61312f6a8d62b48380ff",
|
||||
".pi/skills/trellis-meta/references/platform-files/overview.md": "821a2718b38e17439c2bb487a6dd3a95c8138e83dbda60b142a00ff80178dc3c",
|
||||
".pi/skills/trellis-meta/references/platform-files/platform-map.md": "59e13cdaa2077149b5d82ad25fa5dd821195be30bdc43d853619c4702d334a6e",
|
||||
".pi/skills/trellis-meta/references/platform-files/skills-and-commands.md": "5732bb0a7d384a91abd0c103372f3865e0b186e75185f35e0e91b00a6b6bf5a3",
|
||||
".pi/skills/trellis-meta/SKILL.md": "a190ecbde70a658a5b5c3e2558783e99c9afc6f2e5cdecdac8f11c06dd42065e",
|
||||
".pi/skills/trellis-session-insight/references/cli-quick-reference.md": "c520353fe3fc00b9702ed4f780647c8fbd6d342e66527a03647f533a9fe09779",
|
||||
".pi/skills/trellis-session-insight/references/triggering-patterns.md": "121ecd23be83d1567e8ce15c366a81073d7a2b1d3ad616fce235c07ca1f1cc20",
|
||||
".pi/skills/trellis-session-insight/SKILL.md": "f6ad80eaca21b8fa63d31ae26b612bb2bb69dd04469603828509a8c5a0463ff1",
|
||||
".pi/skills/trellis-spec-bootstrap/references/mcp-setup.md": "df542fc8f279edd38046d26a7c8151804b708f57b24d4aa2733cea587a88c65e",
|
||||
".pi/skills/trellis-spec-bootstrap/references/repository-analysis.md": "0dae98d774f6e34559b9f3442888ac43e3a8af110c37cbefc49ce256986858b6",
|
||||
".pi/skills/trellis-spec-bootstrap/references/spec-task-planning.md": "ef493d028c3b0807a8a534bb71fb92a68129f273db763ad27ceb464a522e799d",
|
||||
".pi/skills/trellis-spec-bootstrap/references/spec-writing.md": "e9800fe9ed4a4cd87062ea1829cf2caa8d170ec15e141678a6a30e74c497f47d",
|
||||
".pi/skills/trellis-spec-bootstrap/SKILL.md": "97bfa68c06cebb558eb4464bc1b81f7d2d56040d75baa8de1ee5ad90cca0196a",
|
||||
".pi/agents/trellis-check.md": "1dbfedd3403f201fbfdbae8d810afba0a1f812b97f0f8e308908db7eaceea496",
|
||||
".pi/agents/trellis-implement.md": "9bb1f70d09b7104a671ef9a0ba072b4500d45b8556a253126a003e2b1e7281a2",
|
||||
".pi/agents/trellis-research.md": "ef77555f4c2c4ade36f1c23a076b6f4bb9d180ff24a2f00d7cdc2f8fd5af0b0a",
|
||||
".pi/extensions/trellis/index.ts": "1b82e383661077daa09a5e26193499a1f01c547834b44ebcd1fe496b730b6d75",
|
||||
".pi/settings.json": "66cc59c9b410b267cd081c5a312aea3f32d82ed30c254cef6b4d99248d3bea50",
|
||||
".trae/commands/trellis-continue.md": "c1cd356883657e75b8e36c282853f94279cbd6cf393a9a953acc04713814cab8",
|
||||
".trae/commands/trellis-finish-work.md": "93ffc50e3c2af48d082075a761e3028498913c405f8fc043ac12d9726990c92a",
|
||||
".trae/skills/trellis-before-dev/SKILL.md": "859894f2e8258cbfa142d710363433761c54984c69f7ed7bd44512fb4eb165cd",
|
||||
".trae/skills/trellis-brainstorm/SKILL.md": "3bbfb6506af3c318c6f9b3c280517ce93a02e40e27535de7ff5fc029d3027860",
|
||||
".trae/skills/trellis-break-loop/SKILL.md": "f5a93699832f29dee443b53c135a7459519b371f689af191d15c29e8ee5c7bde",
|
||||
".trae/skills/trellis-check/SKILL.md": "b21ff04b7680ebacb8c5ecbc48a22d627eb13e2b47fceb78c8ced0b43b60b282",
|
||||
".trae/skills/trellis-update-spec/SKILL.md": "cef32aec88db973a0a0272cf18b91d4585fe6ed6625e3de06851a5d3402f65d6",
|
||||
".trae/skills/trellis-channel/references/command-reference.md": "c4df3d940d89814310bfdaded20e84dd1d6b96786f6772688de7a123338d3c8e",
|
||||
".trae/skills/trellis-channel/references/forum.md": "407db39e8ebde47cea8637e3069bdb3d76ba42b43ed1d08ba97a56f26dbcfcb4",
|
||||
".trae/skills/trellis-channel/references/progress-debugging.md": "93b021fd8d5d3d4ee54952c40424ab91ca57c2ab8032bfbfc3b5c5e22f1464cd",
|
||||
".trae/skills/trellis-channel/references/workers.md": "d4481b2858bca82050cb8d131916da8a2ccc3857f199cdb9ced8bb41d592e2a7",
|
||||
".trae/skills/trellis-channel/references/workflows.md": "0782d602efde6e94a7d7d7950fd605eedc9a1f5a523908284e76b628fd618fd4",
|
||||
".trae/skills/trellis-channel/SKILL.md": "ff84801c511a736da018b4b1149348ebc9a16b7fc18d905ec10cfa7acbf269a3",
|
||||
".trae/skills/trellis-meta/references/customize-local/add-project-local-conventions.md": "86009ccb5d0373f399582da0bc570c4e5c6053c3c764857424ff93384f0e04e5",
|
||||
".trae/skills/trellis-meta/references/customize-local/change-agents.md": "3eef0d9b9cf875f121c1a38afb8eee6d3cd3894127db601ae5a7760fa5b61575",
|
||||
".trae/skills/trellis-meta/references/customize-local/change-context-loading.md": "350d319dc1ab99609ddbf52cf8c06c71bd97ba1a29ce2eac8b97d0bb192938bf",
|
||||
".trae/skills/trellis-meta/references/customize-local/change-hooks.md": "4c18b134f05d5ba1609c517ec94c97c9442b5f0670aa2211c487c31ed2a4f358",
|
||||
".trae/skills/trellis-meta/references/customize-local/change-skills-or-commands.md": "a6994e2418cdf5bcad10b4236b02741179ae794bc4fdd811a64f443293b69268",
|
||||
".trae/skills/trellis-meta/references/customize-local/change-spec-structure.md": "31eccaad7097d96e66a45c1b4caea1ba4f2e54b7814184c3ebf82c87dafc4841",
|
||||
".trae/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "60ff9efb93604b87a461a4af30322d76750402a51e40f31531a7ff88d309996d",
|
||||
".trae/skills/trellis-meta/references/customize-local/change-workflow.md": "43fa780a2ca580de121b10893d49b99f978873deebbf45008c466e5ac6651519",
|
||||
".trae/skills/trellis-meta/references/customize-local/overview.md": "ce8f09e9f93ce9a48500763fb3a4db2b3908a5fbf4f985ab71dacebb404cf8f4",
|
||||
".trae/skills/trellis-meta/references/local-architecture/bundled-skills.md": "bac739b042d7a12dae5d14d85cbc312e56ca03f571d2ba7a25b20641ddaefc3f",
|
||||
".trae/skills/trellis-meta/references/local-architecture/context-injection.md": "8497289bf333b3aa456f317039d1239b7ece79254aa0eb62cfc647714c866084",
|
||||
".trae/skills/trellis-meta/references/local-architecture/generated-files.md": "1128d45b2e8c011c247bcf3a3ca1184ca4951fb34a0802c50bf3fecbd7e4de55",
|
||||
".trae/skills/trellis-meta/references/local-architecture/multi-agent-channel.md": "56e5070474aeca872e2d70c46feea5aaafecd3d3ec052c3f7b1877358dca62e9",
|
||||
".trae/skills/trellis-meta/references/local-architecture/overview.md": "50638fd9eaaba2e0edf2f2a84d920578d5b2fb7934031b174e417d3dc510b2a6",
|
||||
".trae/skills/trellis-meta/references/local-architecture/spec-system.md": "b8d8a6a0888b44a232c8f50161b9e20e903cf621ad7be4021715ab6fab226f47",
|
||||
".trae/skills/trellis-meta/references/local-architecture/task-system.md": "2b561d49c390f7d0db5391912946133be4bf73189231e2b8cc9afa1c5ac6165a",
|
||||
".trae/skills/trellis-meta/references/local-architecture/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6",
|
||||
".trae/skills/trellis-meta/references/local-architecture/workspace-memory.md": "e6427b46aba744563c2444b30df4043cd856561b7709ec2dece26095416421fd",
|
||||
".trae/skills/trellis-meta/references/platform-files/agents.md": "ce1a22c2b3ff0bff68755b5663e1f3b63ef2f1658cff43e511cee9013537c698",
|
||||
".trae/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "acdeba5866ccbe0e68fffc847e77e726cf79f579898e61312f6a8d62b48380ff",
|
||||
".trae/skills/trellis-meta/references/platform-files/overview.md": "821a2718b38e17439c2bb487a6dd3a95c8138e83dbda60b142a00ff80178dc3c",
|
||||
".trae/skills/trellis-meta/references/platform-files/platform-map.md": "59e13cdaa2077149b5d82ad25fa5dd821195be30bdc43d853619c4702d334a6e",
|
||||
".trae/skills/trellis-meta/references/platform-files/skills-and-commands.md": "5732bb0a7d384a91abd0c103372f3865e0b186e75185f35e0e91b00a6b6bf5a3",
|
||||
".trae/skills/trellis-meta/SKILL.md": "a190ecbde70a658a5b5c3e2558783e99c9afc6f2e5cdecdac8f11c06dd42065e",
|
||||
".trae/skills/trellis-session-insight/references/cli-quick-reference.md": "c520353fe3fc00b9702ed4f780647c8fbd6d342e66527a03647f533a9fe09779",
|
||||
".trae/skills/trellis-session-insight/references/triggering-patterns.md": "121ecd23be83d1567e8ce15c366a81073d7a2b1d3ad616fce235c07ca1f1cc20",
|
||||
".trae/skills/trellis-session-insight/SKILL.md": "f6ad80eaca21b8fa63d31ae26b612bb2bb69dd04469603828509a8c5a0463ff1",
|
||||
".trae/skills/trellis-spec-bootstrap/references/mcp-setup.md": "df542fc8f279edd38046d26a7c8151804b708f57b24d4aa2733cea587a88c65e",
|
||||
".trae/skills/trellis-spec-bootstrap/references/repository-analysis.md": "0dae98d774f6e34559b9f3442888ac43e3a8af110c37cbefc49ce256986858b6",
|
||||
".trae/skills/trellis-spec-bootstrap/references/spec-task-planning.md": "ef493d028c3b0807a8a534bb71fb92a68129f273db763ad27ceb464a522e799d",
|
||||
".trae/skills/trellis-spec-bootstrap/references/spec-writing.md": "e9800fe9ed4a4cd87062ea1829cf2caa8d170ec15e141678a6a30e74c497f47d",
|
||||
".trae/skills/trellis-spec-bootstrap/SKILL.md": "97bfa68c06cebb558eb4464bc1b81f7d2d56040d75baa8de1ee5ad90cca0196a",
|
||||
".trae/agents/trellis-check.md": "86ed37a6cdb5ae39fe20692d84a0529f4cf74fc83a69fb83ee0e1a24ea5a94f1",
|
||||
".trae/agents/trellis-implement.md": "96b78811300bc70309eef390995e9e1f222e5eaa4ee55c476ed3496e261da0f2",
|
||||
".trae/agents/trellis-research.md": "7ebd1103ba77e484823ea203d65f74963fadc14979f87aec3a48e0466571482f",
|
||||
".trae/hooks/inject-workflow-state.py": "552d4ee4ca059337856c587fde1aaf79364444d0465689faa88c43797969b7de",
|
||||
".trae/hooks/session-start.py": "688e9d5c2273575b2c1cc8db17fa39da7b1798542a67f65286d97cdbdc347995",
|
||||
".trae/hooks.json": "5904359afd81064dde5c58245871014a9ebf40d6c73cf43fc08d0060a6c5e797",
|
||||
"AGENTS.md": "6cacfe99748b435d0660c2463c697bc323d53798aecf3492283ca8eac1b29682",
|
||||
".trellis/agents/check.md": "edb4f57361407249a53bf5998ebf91c40d2b969e826a2c5e1b4e813a08bcb175",
|
||||
".trellis/agents/implement.md": "66e25ad046c94869442834bc3cdfbd5a9a7412d3ff54561d64d2886552c27e87",
|
||||
".trellis/config.yaml": "3e295bf4310763240647f40b3aeee7a7c6d134142cdc826e02d850ca2407fc43",
|
||||
".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c",
|
||||
".trellis/scripts/add_session.py": "7fcacb29ffd13599949a517aa967d868040eb4c9b40356b0c460a32361237eb6",
|
||||
".trellis/scripts/common/__init__.py": "3d5e9347141f0296319a5beb29d69ae714c5a474b9078caeb3edd7c5f6562e22",
|
||||
".trellis/scripts/common/active_task.py": "8ac10263a88262aeaec2c600b33f3761f99d80c305718609a3bef97a2ff08938",
|
||||
".trellis/scripts/common/cli_adapter.py": "e7586505462c5449654a8fad70a50360a37a8aefa1efedfa06195bf80e9d1dae",
|
||||
".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": "1019cb5e262001d01960ae0d9751a9a5346aafccb1695d4be474dbc18be5de6c",
|
||||
".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": "078bc526d7a29b1d391cc198d113d28225cf46a6868d655498832a6cc9a36acf",
|
||||
".trellis/spec/README.md": "40dfb9fdacc5c24e85a65fad92984af638339ee12dccabffa8b1f1ce68f9096b",
|
||||
".trellis/spec/backend/ai-sdk-integration.md": "4effa6c48cfa03286bc334014c195c981b52d7b097f910c8e14ae441f4edbe16",
|
||||
".trellis/spec/backend/authentication.md": "123994afc32105ccab2d3b3ab71de5363a93035749faa0c0f029f08b958927b6",
|
||||
".trellis/spec/backend/database.md": "b9efab775a1a7f4cd55d909af667107dfee13e03963a8b8b1183e6a4bbebfd70",
|
||||
".trellis/spec/backend/directory-structure.md": "25b2355111c7b15096bc8e7eb5d0d1470a1dc32ce5c4e09369d362c8940fe35c",
|
||||
".trellis/spec/backend/index.md": "8af39732405c088fc8da4f376b19e9ee6cf8242ee24e7c48a5a96e2261506ca4",
|
||||
".trellis/spec/backend/logging.md": "30c6f062fa4d18bb7cd84a3483a49e114d83831a031b95faa77deb3bdeed6d8c",
|
||||
".trellis/spec/backend/orpc-usage.md": "734d417ce460f5aae1813da13ac5fa9a2cf348fc9f8d1de3a4735fb5cb2820d3",
|
||||
".trellis/spec/backend/performance.md": "42060b1ee2addedabad6af5e3938e371558e0ecb1f4e6fc7c97b6e3294a83d65",
|
||||
".trellis/spec/backend/quality.md": "f9f2295923faf514ded044fec613f4a97d9fc25ecdc0695320d2a2892e46f5d6",
|
||||
".trellis/spec/backend/type-safety.md": "070b40889098eaed88332cbcdd54aa211300a8e6457a7f05f63c7bd1a0c22929",
|
||||
".trellis/spec/big-question/index.md": "d4887ee011998d5820f11c59ba1b17ce7a31b41f61564183724602208b4b5c79",
|
||||
".trellis/spec/big-question/postgres-json-jsonb.md": "6dc6597756ab2ccf5ef15d50b4b76a78fb10b2cc2222c6380ac8cf435756135b",
|
||||
".trellis/spec/big-question/sentry-nextintl-conflict.md": "bb5dd8f4db9646ad27b910c8abc922bf2380820dd2a6a0cf3dbb0c46d7c9c7c8",
|
||||
".trellis/spec/big-question/turbopack-webpack-flexbox.md": "11b606360b37d9726efaa3fd24175dc1cccff1059c2dabc4e92296a6a7eb692a",
|
||||
".trellis/spec/big-question/webkit-tap-highlight.md": "d2340dfe7ba49f21d5587bdd7e724c28d1e6b42fd48c48bd8c4a088f73803805",
|
||||
".trellis/spec/frontend/ai-sdk-integration.md": "3c93feb6bb567354a17e6de97f417182bcd3cfb6e52bbe17002f791e4650ea56",
|
||||
".trellis/spec/frontend/api-integration.md": "dced0eea574a7deba428fbfba050a3ac89f38fffbdba095c3a98b8898fc1a3a0",
|
||||
".trellis/spec/frontend/authentication.md": "b4dc99456c937122a9c6998c1b57083d408fd774ecf6a0d5ecf2be9fb4a8618f",
|
||||
".trellis/spec/frontend/components.md": "157eac19346073a5d41fec288e4d5417f6e8e9cdd32568f347779dcdcae011b3",
|
||||
".trellis/spec/frontend/css-layout.md": "932513eae947d921b4e6a42dbcb0c53465ac11a9b1780acae325241ee631d1ac",
|
||||
".trellis/spec/frontend/directory-structure.md": "3a3206a5bb673553f3e428b4bb5717da9a2b978a3e9b2d757d234f904f53ac95",
|
||||
".trellis/spec/frontend/hooks.md": "96c3263997d8c40b5065fc4dc609a079864ef34da522cb9f52fe0b3dff48ab7d",
|
||||
".trellis/spec/frontend/index.md": "3d9beb1b41664da116d2d921126bf3f90e5cb0880eb6d30427bc89c2780d9b93",
|
||||
".trellis/spec/frontend/orpc-usage.md": "7724f962587bb6f055ce15d73c6c5893e80178d26465b7fb49174edf291d88d2",
|
||||
".trellis/spec/frontend/quality.md": "2e6f18347350ac5b3417542329647dfae1e5c81831b1f622a4c481efa9e93666",
|
||||
".trellis/spec/frontend/state-management.md": "50032924868267cbd8de2bf4ac49c17cab42a89d5471ce27b49486b32d19112a",
|
||||
".trellis/spec/frontend/type-safety.md": "ff8cfaaaa4b97654d8bb72a10e9d0f58139cef595db1e7040d6e3f874f06ae97",
|
||||
".trellis/spec/guides/cross-layer-thinking-guide.md": "ef671e8e1a35362af065077051f6812d871a48de1b3dd801020572ae9ba54410",
|
||||
".trellis/spec/guides/index.md": "e73055bc4b37b91bd57ba57c8bfd92e730ec5755a860c81fa56718af9ae011b7",
|
||||
".trellis/spec/guides/pre-implementation-checklist.md": "cbf6f379755849d09846716ddec7146702888ccc094a940774abe32d98334271",
|
||||
".trellis/spec/shared/code-quality.md": "b2dff1fef8f0f94d168ea0b6a7d31d54c28a33639811bf5589eb3ec7d3b4bbba",
|
||||
".trellis/spec/shared/dependencies.md": "1dcfc9467870f006648cb133129b94345f87fc515d8fc532cb9820c0c10e37f6",
|
||||
".trellis/spec/shared/index.md": "ae4f000c5e35d7ca003ca7b736ccbf46f6a666a67db5c8937e0e62aeba6a6f44",
|
||||
".trellis/spec/shared/typescript.md": "6840bc24db8dcedfb30a03f490e3fc128fc915f1165396876d09c2bd441bb458"
|
||||
}
|
||||
}
|
||||
1
.trellis/.version
Normal file
1
.trellis/.version
Normal file
@@ -0,0 +1 @@
|
||||
0.6.5
|
||||
70
.trellis/agents/check.md
Normal file
70
.trellis/agents/check.md
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: check
|
||||
description: |
|
||||
Code quality auditor for the Trellis channel runtime. Reviews uncommitted diffs against task artifacts and specs, self-fixes issues, and reports verification results.
|
||||
provider: claude
|
||||
labels: [trellis, check]
|
||||
---
|
||||
|
||||
# Check Agent (channel runtime)
|
||||
|
||||
You are the Check Agent spawned by `trellis channel spawn --agent check` inside the Trellis channel runtime. You receive an `Active task: <path>` line in your inbox; use it to locate task artifacts on disk.
|
||||
|
||||
## Context
|
||||
|
||||
Before reviewing, read in this order:
|
||||
|
||||
1. `<task-path>/check.jsonl` if present — spec manifest curated for this turn; read every listed file
|
||||
2. `<task-path>/prd.md` — requirements
|
||||
3. `<task-path>/design.md` if present — technical design
|
||||
4. `<task-path>/implement.md` if present — execution plan
|
||||
5. `.trellis/spec/` — project-wide guidelines (load only what is relevant to the diff under review)
|
||||
|
||||
## Core Responsibilities
|
||||
|
||||
1. **Get the diff** — `git diff` / `git diff --staged` for uncommitted changes
|
||||
2. **Review against task artifacts** — does the diff satisfy `prd.md` (and `design.md` / `implement.md` if present)?
|
||||
3. **Review against specs** — naming, structure, type safety, error handling, conventions in `.trellis/spec/`
|
||||
4. **Self-fix** — when an issue is mechanical and small, fix it directly with the editing tools you have
|
||||
5. **Run verification** — project lint and typecheck on the changed scope
|
||||
6. **Report** — concrete findings with `file:line` citations and what was fixed vs. what is open
|
||||
|
||||
## Forbidden Operations
|
||||
|
||||
- `git commit`
|
||||
- `git push`
|
||||
- `git merge`
|
||||
|
||||
The supervising main session owns commits. Report the post-fix state; do not commit on its behalf.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Run `git diff --name-only` and `git diff` to scope the changes
|
||||
2. Read the task artifacts and relevant spec files
|
||||
3. For each issue:
|
||||
- If mechanical (lint nit, missing type, wrong import, dead branch) → fix in-place
|
||||
- If a design/judgment issue → record and report, do not silently rewrite
|
||||
4. Run the project's lint and typecheck on the changed scope after self-fixes
|
||||
5. Report
|
||||
|
||||
## Report Format
|
||||
|
||||
```
|
||||
## Self-Check Complete
|
||||
|
||||
### Files Checked
|
||||
- <path>
|
||||
|
||||
### Issues Found and Fixed
|
||||
1. `<file>:<line>` — <what was wrong> → <what you changed>
|
||||
|
||||
### Issues Not Fixed
|
||||
- `<file>:<line>` — <issue> — <why deferred to the main session>
|
||||
|
||||
### Verification Results
|
||||
- TypeCheck: <pass|fail|skipped + reason>
|
||||
- Lint: <pass|fail|skipped + reason>
|
||||
|
||||
### Summary
|
||||
Checked <N> files, found <X> issues, fixed <Y>, <X-Y> open.
|
||||
```
|
||||
71
.trellis/agents/implement.md
Normal file
71
.trellis/agents/implement.md
Normal file
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: implement
|
||||
description: |
|
||||
Code implementation expert for the Trellis channel runtime. Understands specs and task artifacts, then implements features. No git commit allowed.
|
||||
provider: claude
|
||||
labels: [trellis, implement]
|
||||
---
|
||||
|
||||
# Implement Agent (channel runtime)
|
||||
|
||||
You are the Implement Agent spawned by `trellis channel spawn --agent implement` inside the Trellis channel runtime. You receive an `Active task: <path>` line in your inbox; use it to locate task artifacts on disk.
|
||||
|
||||
## Context
|
||||
|
||||
Before implementing, read in this order:
|
||||
|
||||
1. `<task-path>/implement.jsonl` if present — spec manifest curated for this turn; read every listed file
|
||||
2. `<task-path>/prd.md` — requirements
|
||||
3. `<task-path>/design.md` if present — technical design
|
||||
4. `<task-path>/implement.md` if present — execution plan
|
||||
5. `.trellis/spec/` — project-wide guidelines (load only what is relevant to the diff you are about to write)
|
||||
|
||||
## Core Responsibilities
|
||||
|
||||
1. **Understand specs** — read relevant spec files in `.trellis/spec/`
|
||||
2. **Understand task artifacts** — read the artifacts listed above
|
||||
3. **Implement features** — write code that follows specs and existing patterns
|
||||
4. **Self-check** — run lint and typecheck on the changed scope before reporting
|
||||
|
||||
## Forbidden Operations
|
||||
|
||||
- `git commit`
|
||||
- `git push`
|
||||
- `git merge`
|
||||
|
||||
The supervising main session owns commits. Report what changed; do not commit on its behalf.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read relevant specs based on task type and the files in `implement.jsonl` if present
|
||||
2. Read the task's `prd.md`, `design.md` if present, and `implement.md` if present
|
||||
3. Implement features following specs and existing patterns
|
||||
4. Run the project's lint and typecheck commands on the changed scope
|
||||
5. Report files touched, key decisions, and verification results back to the channel
|
||||
|
||||
## Code Standards
|
||||
|
||||
- Follow existing code patterns
|
||||
- Don't add unnecessary abstractions
|
||||
- Only do what the PRD asks for; no speculative scope expansion
|
||||
- Surface uncertainty back to the channel rather than guessing
|
||||
|
||||
## Report Format
|
||||
|
||||
```
|
||||
## Implementation Complete
|
||||
|
||||
### Files Modified
|
||||
- <path> — <one-line description>
|
||||
|
||||
### Implementation Summary
|
||||
1. <step>
|
||||
2. <step>
|
||||
|
||||
### Verification Results
|
||||
- Lint: <pass|fail|skipped + reason>
|
||||
- TypeCheck: <pass|fail|skipped + reason>
|
||||
|
||||
### Open Questions
|
||||
- <if any, otherwise omit>
|
||||
```
|
||||
110
.trellis/config.yaml
Normal file
110
.trellis/config.yaml
Normal file
@@ -0,0 +1,110 @@
|
||||
# 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
|
||||
5
.trellis/scripts/__init__.py
Executable file
5
.trellis/scripts/__init__.py
Executable file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Trellis Python Scripts
|
||||
|
||||
This module provides Python implementations of Trellis workflow scripts.
|
||||
"""
|
||||
567
.trellis/scripts/add_session.py
Executable file
567
.trellis/scripts/add_session.py
Executable file
@@ -0,0 +1,567 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Add a new session to journal file and update index.md.
|
||||
|
||||
Usage:
|
||||
python3 add_session.py --title "Title" --commit "hash" --summary "Summary" [--package cli]
|
||||
python3 add_session.py --title "Title" --branch "feat/my-branch"
|
||||
|
||||
# Pipe detailed content via stdin (use --stdin to opt in):
|
||||
cat << 'EOF' | python3 add_session.py --stdin --title "Title" --summary "Summary"
|
||||
<session content here>
|
||||
EOF
|
||||
|
||||
Branch resolution order:
|
||||
1. --branch CLI arg (explicit)
|
||||
2. task.json branch field (from active task)
|
||||
3. git branch --show-current (auto-detect)
|
||||
4. None (omitted gracefully)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from common.paths import (
|
||||
DIR_TASKS,
|
||||
DIR_WORKFLOW,
|
||||
FILE_JOURNAL_PREFIX,
|
||||
get_repo_root,
|
||||
get_current_task,
|
||||
get_developer,
|
||||
get_workspace_dir,
|
||||
)
|
||||
from common.developer import ensure_developer
|
||||
from common.git import run_git
|
||||
from common.safe_commit import (
|
||||
print_gitignore_warning,
|
||||
safe_git_add,
|
||||
safe_trellis_paths_to_add,
|
||||
)
|
||||
from common.tasks import load_task
|
||||
from common.config import (
|
||||
get_packages,
|
||||
get_session_auto_commit,
|
||||
get_session_commit_message,
|
||||
get_max_journal_lines,
|
||||
is_monorepo,
|
||||
resolve_package,
|
||||
validate_package,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
def get_latest_journal_info(dev_dir: Path) -> tuple[Path | None, int, int]:
|
||||
"""Get latest journal file info.
|
||||
|
||||
Returns:
|
||||
Tuple of (file_path, file_number, line_count).
|
||||
"""
|
||||
latest_file: Path | None = None
|
||||
latest_num = -1
|
||||
|
||||
for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
|
||||
match = re.search(r"(\d+)$", f.stem)
|
||||
if match:
|
||||
num = int(match.group(1))
|
||||
if num > latest_num:
|
||||
latest_num = num
|
||||
latest_file = f
|
||||
|
||||
if latest_file:
|
||||
lines = len(latest_file.read_text(encoding="utf-8").splitlines())
|
||||
return latest_file, latest_num, lines
|
||||
|
||||
return None, 0, 0
|
||||
|
||||
|
||||
def get_current_session(index_file: Path) -> int:
|
||||
"""Get current session number from index.md."""
|
||||
if not index_file.is_file():
|
||||
return 0
|
||||
|
||||
content = index_file.read_text(encoding="utf-8")
|
||||
for line in content.splitlines():
|
||||
if "Total Sessions" in line:
|
||||
match = re.search(r":\s*(\d+)", line)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return 0
|
||||
|
||||
|
||||
def _extract_journal_num(filename: str) -> int:
|
||||
"""Extract journal number from filename for sorting."""
|
||||
match = re.search(r"(\d+)", filename)
|
||||
return int(match.group(1)) if match else 0
|
||||
|
||||
|
||||
def count_journal_files(dev_dir: Path, active_num: int) -> str:
|
||||
"""Count journal files and return table rows."""
|
||||
active_file = f"{FILE_JOURNAL_PREFIX}{active_num}.md"
|
||||
result_lines = []
|
||||
|
||||
files = sorted(
|
||||
[f for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md") if f.is_file()],
|
||||
key=lambda f: _extract_journal_num(f.stem),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
for f in files:
|
||||
filename = f.name
|
||||
lines = len(f.read_text(encoding="utf-8").splitlines())
|
||||
status = "Active" if filename == active_file else "Archived"
|
||||
result_lines.append(f"| `{filename}` | ~{lines} | {status} |")
|
||||
|
||||
return "\n".join(result_lines)
|
||||
|
||||
|
||||
def create_new_journal_file(
|
||||
dev_dir: Path, num: int, developer: str, today: str, max_lines: int = 2000,
|
||||
) -> Path:
|
||||
"""Create a new journal file."""
|
||||
prev_num = num - 1
|
||||
new_file = dev_dir / f"{FILE_JOURNAL_PREFIX}{num}.md"
|
||||
|
||||
content = f"""# Journal - {developer} (Part {num})
|
||||
|
||||
> Continuation from `{FILE_JOURNAL_PREFIX}{prev_num}.md` (archived at ~{max_lines} lines)
|
||||
> Started: {today}
|
||||
|
||||
---
|
||||
|
||||
"""
|
||||
new_file.write_text(content, encoding="utf-8")
|
||||
return new_file
|
||||
|
||||
|
||||
def generate_session_content(
|
||||
session_num: int,
|
||||
title: str,
|
||||
commit: str,
|
||||
summary: str,
|
||||
extra_content: str,
|
||||
today: str,
|
||||
package: str | None = None,
|
||||
branch: str | None = None,
|
||||
) -> str:
|
||||
"""Generate session content."""
|
||||
if commit and commit != "-":
|
||||
commit_table = """| Hash | Message |
|
||||
|------|---------|"""
|
||||
for c in commit.split(","):
|
||||
c = c.strip()
|
||||
commit_table += f"\n| `{c}` | (see git log) |"
|
||||
else:
|
||||
commit_table = "(No commits - planning session)"
|
||||
|
||||
package_line = f"\n**Package**: {package}" if package else ""
|
||||
branch_line = f"\n**Branch**: `{branch}`" if branch else ""
|
||||
|
||||
return f"""
|
||||
|
||||
## Session {session_num}: {title}
|
||||
|
||||
**Date**: {today}
|
||||
**Task**: {title}{package_line}{branch_line}
|
||||
|
||||
### Summary
|
||||
|
||||
{summary}
|
||||
|
||||
### Main Changes
|
||||
|
||||
{extra_content}
|
||||
|
||||
### Git Commits
|
||||
|
||||
{commit_table}
|
||||
|
||||
### Testing
|
||||
|
||||
- [OK] (Add test results)
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
### Next Steps
|
||||
|
||||
- None - task complete
|
||||
"""
|
||||
|
||||
|
||||
def update_index(
|
||||
index_file: Path,
|
||||
dev_dir: Path,
|
||||
title: str,
|
||||
commit: str,
|
||||
new_session: int,
|
||||
active_file: str,
|
||||
today: str,
|
||||
branch: str | None = None,
|
||||
) -> bool:
|
||||
"""Update index.md with new session info."""
|
||||
# Format commit for display
|
||||
commit_display = "-"
|
||||
if commit and commit != "-":
|
||||
commit_display = re.sub(r"([a-f0-9]{7,})", r"`\1`", commit.replace(",", ", "))
|
||||
|
||||
# Get file number from active_file name
|
||||
match = re.search(r"(\d+)", active_file)
|
||||
active_num = int(match.group(1)) if match else 0
|
||||
files_table = count_journal_files(dev_dir, active_num)
|
||||
|
||||
print(f"Updating index.md for session {new_session}...")
|
||||
print(f" Title: {title}")
|
||||
print(f" Commit: {commit_display}")
|
||||
print(f" Active File: {active_file}")
|
||||
print()
|
||||
|
||||
content = index_file.read_text(encoding="utf-8")
|
||||
|
||||
if "@@@auto:current-status" not in content:
|
||||
print("Error: Markers not found in index.md. Please ensure markers exist.", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Process sections
|
||||
lines = content.splitlines()
|
||||
new_lines = []
|
||||
|
||||
in_current_status = False
|
||||
in_active_documents = False
|
||||
in_session_history = False
|
||||
header_written = False
|
||||
|
||||
for line in lines:
|
||||
if "@@@auto:current-status" in line:
|
||||
new_lines.append(line)
|
||||
in_current_status = True
|
||||
new_lines.append(f"- **Active File**: `{active_file}`")
|
||||
new_lines.append(f"- **Total Sessions**: {new_session}")
|
||||
new_lines.append(f"- **Last Active**: {today}")
|
||||
continue
|
||||
|
||||
if "@@@/auto:current-status" in line:
|
||||
in_current_status = False
|
||||
new_lines.append(line)
|
||||
continue
|
||||
|
||||
if "@@@auto:active-documents" in line:
|
||||
new_lines.append(line)
|
||||
in_active_documents = True
|
||||
new_lines.append("| File | Lines | Status |")
|
||||
new_lines.append("|------|-------|--------|")
|
||||
new_lines.append(files_table)
|
||||
continue
|
||||
|
||||
if "@@@/auto:active-documents" in line:
|
||||
in_active_documents = False
|
||||
new_lines.append(line)
|
||||
continue
|
||||
|
||||
if "@@@auto:session-history" in line:
|
||||
new_lines.append(line)
|
||||
in_session_history = True
|
||||
header_written = False
|
||||
continue
|
||||
|
||||
if "@@@/auto:session-history" in line:
|
||||
in_session_history = False
|
||||
new_lines.append(line)
|
||||
continue
|
||||
|
||||
if in_current_status:
|
||||
continue
|
||||
|
||||
if in_active_documents:
|
||||
continue
|
||||
|
||||
if in_session_history:
|
||||
# Migrate old 4/6-column headers to 5-column Branch-only history.
|
||||
if re.match(
|
||||
r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*Base Branch\s*\|\s*$",
|
||||
line,
|
||||
):
|
||||
new_lines.append("| # | Date | Title | Commits | Branch |")
|
||||
continue
|
||||
if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*$", line):
|
||||
new_lines.append("| # | Date | Title | Commits | Branch |")
|
||||
continue
|
||||
if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*$", line):
|
||||
new_lines.append("| # | Date | Title | Commits | Branch |")
|
||||
continue
|
||||
if re.match(r"^\|[-| ]+\|\s*$", line) and not header_written:
|
||||
new_lines.append("|---|------|-------|---------|--------|")
|
||||
new_lines.append(f"| {new_session} | {today} | {title} | {commit_display} | `{branch or '-'}` |")
|
||||
header_written = True
|
||||
continue
|
||||
new_lines.append(line)
|
||||
continue
|
||||
|
||||
new_lines.append(line)
|
||||
|
||||
index_file.write_text("\n".join(new_lines), encoding="utf-8")
|
||||
print("[OK] Updated index.md successfully!")
|
||||
return True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Function
|
||||
# =============================================================================
|
||||
|
||||
def _auto_commit_workspace(repo_root: Path) -> None:
|
||||
"""Stage Trellis-owned workspace + current-task paths and commit.
|
||||
|
||||
Path scope is restricted to specific products: the current developer's
|
||||
journal files + index.md, and ONLY the current task directory (resolved
|
||||
via ``get_current_task``). We never `git add` the whole `.trellis/` tree
|
||||
or iterate over all active task dirs (#303: parallel-window dirty task
|
||||
dirs must not be bundled into the session auto-commit). If `.gitignore`
|
||||
blocks the specific paths we warn + skip — never retry with ``-f``.
|
||||
|
||||
Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when set to
|
||||
``false``, this function returns immediately without touching git
|
||||
(journal/index files are still written to disk by the caller).
|
||||
"""
|
||||
if not get_session_auto_commit(repo_root):
|
||||
print(
|
||||
"[OK] session_auto_commit: false — skipping git stage/commit.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
|
||||
commit_msg = get_session_commit_message(repo_root)
|
||||
# Resolve the current task so staging is scoped to its dir only. The ref
|
||||
# is ``.trellis/tasks/<name>`` (or under archive/) — pass the bare name.
|
||||
current = get_current_task(repo_root)
|
||||
if current:
|
||||
task_name = Path(current).name
|
||||
paths = safe_trellis_paths_to_add(repo_root, task_name=task_name)
|
||||
else:
|
||||
# Current task unknown (0 or >=2 parallel sessions — exactly the
|
||||
# parallel-window case #303 is about). Do NOT fall back to the wide
|
||||
# `tasks_dir.iterdir()` scan; that would re-leak other tasks' dirty
|
||||
# dirs into the session commit. Stage only the developer's journal/
|
||||
# index and skip every task dir.
|
||||
paths = [
|
||||
p
|
||||
for p in safe_trellis_paths_to_add(repo_root, task_name=None)
|
||||
if not p.startswith(f"{DIR_WORKFLOW}/{DIR_TASKS}/")
|
||||
]
|
||||
if not paths:
|
||||
print("[OK] No workspace changes to commit.", file=sys.stderr)
|
||||
return
|
||||
|
||||
success, _, err = safe_git_add(paths, repo_root)
|
||||
if not success:
|
||||
if err and "ignored by" in err.lower():
|
||||
print_gitignore_warning(paths)
|
||||
else:
|
||||
print(
|
||||
f"[WARN] git add failed: {err.strip() if err else 'unknown error'}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
|
||||
# Check if there are staged changes for the paths we just staged.
|
||||
rc, _, _ = run_git(
|
||||
["diff", "--cached", "--quiet", "--", *paths], cwd=repo_root
|
||||
)
|
||||
if rc == 0:
|
||||
print("[OK] No workspace changes to commit.", file=sys.stderr)
|
||||
return
|
||||
|
||||
rc, _, commit_err = run_git(["commit", "-m", commit_msg], cwd=repo_root)
|
||||
if rc == 0:
|
||||
print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr)
|
||||
else:
|
||||
print(
|
||||
f"[WARN] Auto-commit failed: {commit_err.strip()}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def add_session(
|
||||
title: str,
|
||||
commit: str = "-",
|
||||
summary: str = "(Add summary)",
|
||||
extra_content: str = "(Add details)",
|
||||
auto_commit: bool = True,
|
||||
package: str | None = None,
|
||||
branch: str | None = None,
|
||||
) -> int:
|
||||
"""Add a new session."""
|
||||
repo_root = get_repo_root()
|
||||
ensure_developer(repo_root)
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
if not developer:
|
||||
print("Error: Developer not initialized", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
dev_dir = get_workspace_dir(repo_root)
|
||||
if not dev_dir:
|
||||
print("Error: Workspace directory not found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
max_lines = get_max_journal_lines(repo_root)
|
||||
|
||||
index_file = dev_dir / "index.md"
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
journal_file, current_num, current_lines = get_latest_journal_info(dev_dir)
|
||||
current_session = get_current_session(index_file)
|
||||
new_session = current_session + 1
|
||||
|
||||
session_content = generate_session_content(
|
||||
new_session, title, commit, summary, extra_content, today, package,
|
||||
branch,
|
||||
)
|
||||
content_lines = len(session_content.splitlines())
|
||||
|
||||
print("========================================", file=sys.stderr)
|
||||
print("ADD SESSION", file=sys.stderr)
|
||||
print("========================================", file=sys.stderr)
|
||||
print("", file=sys.stderr)
|
||||
print(f"Session: {new_session}", file=sys.stderr)
|
||||
print(f"Title: {title}", file=sys.stderr)
|
||||
print(f"Commit: {commit}", file=sys.stderr)
|
||||
print("", file=sys.stderr)
|
||||
print(f"Current journal file: {FILE_JOURNAL_PREFIX}{current_num}.md", file=sys.stderr)
|
||||
print(f"Current lines: {current_lines}", file=sys.stderr)
|
||||
print(f"New content lines: {content_lines}", file=sys.stderr)
|
||||
print(f"Total after append: {current_lines + content_lines}", file=sys.stderr)
|
||||
print("", file=sys.stderr)
|
||||
|
||||
target_file = journal_file
|
||||
target_num = current_num
|
||||
|
||||
if current_lines + content_lines > max_lines:
|
||||
target_num = current_num + 1
|
||||
print(f"[!] Exceeds {max_lines} lines, creating {FILE_JOURNAL_PREFIX}{target_num}.md", file=sys.stderr)
|
||||
target_file = create_new_journal_file(dev_dir, target_num, developer, today, max_lines)
|
||||
print(f"Created: {target_file}", file=sys.stderr)
|
||||
|
||||
# Append session content
|
||||
if target_file:
|
||||
with target_file.open("a", encoding="utf-8") as f:
|
||||
f.write(session_content)
|
||||
print(f"[OK] Appended session to {target_file.name}", file=sys.stderr)
|
||||
|
||||
print("", file=sys.stderr)
|
||||
|
||||
# Update index.md
|
||||
active_file = f"{FILE_JOURNAL_PREFIX}{target_num}.md"
|
||||
if not update_index(
|
||||
index_file,
|
||||
dev_dir,
|
||||
title,
|
||||
commit,
|
||||
new_session,
|
||||
active_file,
|
||||
today,
|
||||
branch,
|
||||
):
|
||||
return 1
|
||||
|
||||
print("", file=sys.stderr)
|
||||
print("========================================", file=sys.stderr)
|
||||
print(f"[OK] Session {new_session} added successfully!", file=sys.stderr)
|
||||
print("========================================", file=sys.stderr)
|
||||
print("", file=sys.stderr)
|
||||
print("Files updated:", file=sys.stderr)
|
||||
print(f" - {target_file.name if target_file else 'journal'}", file=sys.stderr)
|
||||
print(" - index.md", file=sys.stderr)
|
||||
|
||||
# Auto-commit workspace changes
|
||||
if auto_commit:
|
||||
print("", file=sys.stderr)
|
||||
_auto_commit_workspace(repo_root)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Entry
|
||||
# =============================================================================
|
||||
|
||||
def main() -> int:
|
||||
"""CLI entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Add a new session to journal file and update index.md"
|
||||
)
|
||||
parser.add_argument("--title", required=True, help="Session title")
|
||||
parser.add_argument("--commit", default="-", help="Comma-separated commit hashes")
|
||||
parser.add_argument("--summary", default="(Add summary)", help="Brief summary")
|
||||
parser.add_argument("--content-file", help="Path to file with detailed content")
|
||||
parser.add_argument("--package", help="Package name tag (e.g., cli, docs-site)")
|
||||
parser.add_argument("--branch", help="Branch name (auto-detected if omitted)")
|
||||
parser.add_argument("--no-commit", action="store_true",
|
||||
help="Skip auto-commit of workspace changes")
|
||||
parser.add_argument("--stdin", action="store_true",
|
||||
help="Read extra content from stdin (explicit opt-in)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
extra_content = "(Add details)"
|
||||
if args.content_file:
|
||||
content_path = Path(args.content_file)
|
||||
if content_path.is_file():
|
||||
extra_content = content_path.read_text(encoding="utf-8")
|
||||
elif args.stdin:
|
||||
extra_content = sys.stdin.read()
|
||||
|
||||
# Load active task once — shared by package and branch resolution
|
||||
repo_root = get_repo_root()
|
||||
current = get_current_task(repo_root)
|
||||
task_data = load_task(repo_root / current) if current else None
|
||||
|
||||
package = args.package
|
||||
if package:
|
||||
# CLI source: fail-fast in monorepo, ignore in single-repo
|
||||
if not is_monorepo(repo_root):
|
||||
print("Warning: --package ignored in single-repo project", file=sys.stderr)
|
||||
package = None
|
||||
elif not validate_package(package, repo_root):
|
||||
packages = get_packages(repo_root)
|
||||
available = ", ".join(sorted(packages.keys())) if packages else "(none)"
|
||||
print(f"Error: unknown package '{package}'. Available: {available}", file=sys.stderr)
|
||||
return 1
|
||||
else:
|
||||
# Inferred: active task's task.json.package → default_package → None
|
||||
task_package = task_data.package if task_data else None
|
||||
package = resolve_package(task_package, repo_root)
|
||||
|
||||
# Resolve branch: CLI → task.json → git auto-detect → None
|
||||
branch = args.branch
|
||||
|
||||
if not branch:
|
||||
if task_data and task_data.raw.get("branch"):
|
||||
branch = task_data.raw["branch"]
|
||||
else:
|
||||
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
|
||||
detected = branch_out.strip()
|
||||
if detected:
|
||||
branch = detected
|
||||
|
||||
return add_session(
|
||||
args.title, args.commit, args.summary, extra_content,
|
||||
auto_commit=not args.no_commit,
|
||||
package=package,
|
||||
branch=branch,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
92
.trellis/scripts/common/__init__.py
Executable file
92
.trellis/scripts/common/__init__.py
Executable file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Common utilities for Trellis workflow scripts.
|
||||
|
||||
This module provides shared functionality used by other Trellis scripts.
|
||||
"""
|
||||
|
||||
import io
|
||||
import sys
|
||||
|
||||
# =============================================================================
|
||||
# Windows Encoding Fix (MUST be at top, before any other output)
|
||||
# =============================================================================
|
||||
# On Windows, stdout defaults to the system code page (often GBK/CP936).
|
||||
# This causes UnicodeEncodeError when printing non-ASCII characters.
|
||||
#
|
||||
# Any script that imports from common will automatically get this fix.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _configure_stream(stream: object) -> object:
|
||||
"""Configure a stream for UTF-8 encoding on Windows."""
|
||||
# Try reconfigure() first (Python 3.7+, more reliable)
|
||||
if hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
||||
return stream
|
||||
# Fallback: detach and rewrap with TextIOWrapper
|
||||
elif hasattr(stream, "detach"):
|
||||
return io.TextIOWrapper(
|
||||
stream.detach(), # type: ignore[union-attr]
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
return stream
|
||||
|
||||
|
||||
if sys.platform == "win32":
|
||||
sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment]
|
||||
sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment]
|
||||
sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment]
|
||||
|
||||
|
||||
def configure_encoding() -> None:
|
||||
"""
|
||||
Configure stdout/stderr/stdin for UTF-8 encoding on Windows.
|
||||
|
||||
This is automatically called when importing from common,
|
||||
but can be called manually for scripts that don't import common.
|
||||
|
||||
Safe to call multiple times.
|
||||
"""
|
||||
global sys
|
||||
if sys.platform == "win32":
|
||||
sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment]
|
||||
sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment]
|
||||
sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment]
|
||||
|
||||
|
||||
from .paths import (
|
||||
DIR_WORKFLOW,
|
||||
DIR_WORKSPACE,
|
||||
DIR_TASKS,
|
||||
DIR_ARCHIVE,
|
||||
DIR_SPEC,
|
||||
DIR_SCRIPTS,
|
||||
FILE_DEVELOPER,
|
||||
FILE_CURRENT_TASK,
|
||||
FILE_TASK_JSON,
|
||||
FILE_JOURNAL_PREFIX,
|
||||
get_repo_root,
|
||||
get_developer,
|
||||
check_developer,
|
||||
get_tasks_dir,
|
||||
get_workspace_dir,
|
||||
get_active_journal_file,
|
||||
count_lines,
|
||||
get_current_task,
|
||||
get_current_task_abs,
|
||||
normalize_task_ref,
|
||||
resolve_task_ref,
|
||||
set_current_task,
|
||||
clear_current_task,
|
||||
has_current_task,
|
||||
generate_task_date_prefix,
|
||||
)
|
||||
|
||||
from .active_task import (
|
||||
ActiveTask,
|
||||
clear_active_task,
|
||||
resolve_active_task,
|
||||
resolve_context_key,
|
||||
set_active_task,
|
||||
)
|
||||
628
.trellis/scripts/common/active_task.py
Executable file
628
.trellis/scripts/common/active_task.py
Executable file
@@ -0,0 +1,628 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Session-scoped active task resolution.
|
||||
|
||||
The user-facing concept is a single "active task". Trellis stores that pointer
|
||||
per AI session/window under `.trellis/.runtime/sessions/`; without a stable
|
||||
session key there is no active task.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
DIR_WORKFLOW = ".trellis"
|
||||
DIR_TASKS = "tasks"
|
||||
DIR_RUNTIME = ".runtime"
|
||||
DIR_SESSIONS = "sessions"
|
||||
DIR_CURSOR_SHELL = "cursor-shell"
|
||||
CURSOR_SHELL_TICKET_TTL_SECONDS = 30
|
||||
TASK_SESSION_COMMANDS = {"start", "current", "finish"}
|
||||
|
||||
_SESSION_KEYS = ("session_id", "sessionId", "sessionID")
|
||||
_CONVERSATION_KEYS = ("conversation_id", "conversationId", "conversationID")
|
||||
_TRANSCRIPT_KEYS = ("transcript_path", "transcriptPath", "transcript")
|
||||
_NESTED_KEYS = ("input", "properties", "event", "hook_input", "hookInput")
|
||||
_KNOWN_PLATFORMS = {
|
||||
"claude",
|
||||
"codex",
|
||||
"cursor",
|
||||
"opencode",
|
||||
"gemini",
|
||||
"droid",
|
||||
"qoder",
|
||||
"codebuddy",
|
||||
"kiro",
|
||||
"copilot",
|
||||
"pi",
|
||||
"trae",
|
||||
}
|
||||
|
||||
_ENV_SESSION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("claude", ("CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID")),
|
||||
("codex", ("CODEX_SESSION_ID", "CODEX_THREAD_ID")),
|
||||
("cursor", ("CURSOR_SESSION_ID",)),
|
||||
("opencode", ("OPENCODE_SESSION_ID", "OPENCODE_SESSIONID", "OPENCODE_RUN_ID")),
|
||||
("gemini", ("GEMINI_SESSION_ID",)),
|
||||
("droid", ("FACTORY_SESSION_ID", "DROID_SESSION_ID")),
|
||||
("qoder", ("QODER_SESSION_ID",)),
|
||||
("codebuddy", ("CODEBUDDY_SESSION_ID",)),
|
||||
("kiro", ("KIRO_SESSION_ID",)),
|
||||
("copilot", ("COPILOT_SESSION_ID", "COPILOT_SESSIONID")),
|
||||
("pi", ("PI_SESSION_ID", "PI_SESSIONID")),
|
||||
("trae", ("TRAE_SESSION_ID",)),
|
||||
)
|
||||
_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
|
||||
851
.trellis/scripts/common/cli_adapter.py
Executable file
851
.trellis/scripts/common/cli_adapter.py
Executable file
@@ -0,0 +1,851 @@
|
||||
"""
|
||||
CLI Adapter for Multi-Platform Support.
|
||||
|
||||
Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, Devin, Qoder, CodeBuddy, GitHub Copilot, Factory Droid, and Pi Agent interfaces.
|
||||
|
||||
Supported platforms:
|
||||
- claude: Claude Code (default)
|
||||
- opencode: OpenCode
|
||||
- cursor: Cursor IDE
|
||||
- iflow: iFlow CLI
|
||||
- codex: Codex CLI (skills-based)
|
||||
- kilo: Kilo CLI
|
||||
- kiro: Kiro Code (skills-based)
|
||||
- gemini: Gemini CLI
|
||||
- antigravity: Antigravity (workflow-based)
|
||||
- devin: Devin (formerly Windsurf; workflow-based)
|
||||
- qoder: Qoder
|
||||
- codebuddy: CodeBuddy
|
||||
- copilot: GitHub Copilot (VS Code)
|
||||
- droid: Factory Droid (commands-based)
|
||||
- pi: Pi Agent (extension-backed)
|
||||
- trae: Trae IDE (IDE-only, hooks-based)
|
||||
|
||||
Usage:
|
||||
from common.cli_adapter import CLIAdapter
|
||||
|
||||
adapter = CLIAdapter("opencode")
|
||||
cmd = adapter.build_run_command(
|
||||
agent="dispatch",
|
||||
session_id="abc123",
|
||||
prompt="Start the pipeline"
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
Platform = Literal[
|
||||
"claude",
|
||||
"opencode",
|
||||
"cursor",
|
||||
"iflow",
|
||||
"codex",
|
||||
"kilo",
|
||||
"kiro",
|
||||
"gemini",
|
||||
"antigravity",
|
||||
"devin",
|
||||
"qoder",
|
||||
"codebuddy",
|
||||
"copilot",
|
||||
"droid",
|
||||
"pi",
|
||||
"trae",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CLIAdapter:
|
||||
"""Adapter for different AI coding CLI tools."""
|
||||
|
||||
platform: Platform
|
||||
|
||||
# =========================================================================
|
||||
# Agent Name Mapping
|
||||
# =========================================================================
|
||||
|
||||
# OpenCode has built-in agents that cannot be overridden
|
||||
# See: https://github.com/sst/opencode/issues/4271
|
||||
# Note: Class-level constant, not a dataclass field
|
||||
_AGENT_NAME_MAP: ClassVar[dict[Platform, dict[str, str]]] = {
|
||||
"claude": {}, # No mapping needed
|
||||
"opencode": {
|
||||
"plan": "trellis-plan", # 'plan' is built-in in OpenCode
|
||||
},
|
||||
}
|
||||
|
||||
def get_agent_name(self, agent: str) -> str:
|
||||
"""Get platform-specific agent name.
|
||||
|
||||
Args:
|
||||
agent: Original agent name (e.g., 'plan', 'dispatch')
|
||||
|
||||
Returns:
|
||||
Platform-specific agent name (e.g., 'trellis-plan' for OpenCode)
|
||||
"""
|
||||
mapping = self._AGENT_NAME_MAP.get(self.platform, {})
|
||||
return mapping.get(agent, agent)
|
||||
|
||||
# =========================================================================
|
||||
# Agent Path
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def config_dir_name(self) -> str:
|
||||
"""Get platform-specific config directory name.
|
||||
|
||||
Returns:
|
||||
Directory name ('.claude', '.opencode', '.cursor', '.iflow', '.codex', '.kilocode', '.kiro', '.gemini', '.agent', '.devin', '.qoder', '.codebuddy', '.github/copilot', '.factory', '.pi', or '.trae')
|
||||
"""
|
||||
if self.platform == "opencode":
|
||||
return ".opencode"
|
||||
elif self.platform == "cursor":
|
||||
return ".cursor"
|
||||
elif self.platform == "iflow":
|
||||
return ".iflow"
|
||||
elif self.platform == "codex":
|
||||
return ".codex"
|
||||
elif self.platform == "kilo":
|
||||
return ".kilocode"
|
||||
elif self.platform == "kiro":
|
||||
return ".kiro"
|
||||
elif self.platform == "gemini":
|
||||
return ".gemini"
|
||||
elif self.platform == "antigravity":
|
||||
return ".agent"
|
||||
elif self.platform == "devin":
|
||||
return ".devin"
|
||||
elif self.platform == "qoder":
|
||||
return ".qoder"
|
||||
elif self.platform == "codebuddy":
|
||||
return ".codebuddy"
|
||||
elif self.platform == "copilot":
|
||||
return ".github/copilot"
|
||||
elif self.platform == "droid":
|
||||
return ".factory"
|
||||
elif self.platform == "pi":
|
||||
return ".pi"
|
||||
elif self.platform == "trae":
|
||||
return ".trae"
|
||||
else:
|
||||
return ".claude"
|
||||
|
||||
def get_config_dir(self, project_root: Path) -> Path:
|
||||
"""Get platform-specific config directory.
|
||||
|
||||
Args:
|
||||
project_root: Project root directory
|
||||
|
||||
Returns:
|
||||
Path to config directory (.claude, .opencode, .cursor, .iflow, .codex, .kilocode, .kiro, .gemini, .agent, .devin, .qoder, .codebuddy, .github/copilot, .factory, .pi, or .trae)
|
||||
"""
|
||||
return project_root / self.config_dir_name
|
||||
|
||||
def get_agent_path(self, agent: str, project_root: Path) -> Path:
|
||||
"""Get path to agent definition file.
|
||||
|
||||
Args:
|
||||
agent: Agent name (original, before mapping)
|
||||
project_root: Project root directory
|
||||
|
||||
Returns:
|
||||
Path to agent definition file (.md for most platforms, .toml for Codex)
|
||||
"""
|
||||
mapped_name = self.get_agent_name(agent)
|
||||
if self.platform == "codex":
|
||||
return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.toml"
|
||||
return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.md"
|
||||
|
||||
def get_commands_path(self, project_root: Path, *parts: str) -> Path:
|
||||
"""Get path to commands directory or specific command file.
|
||||
|
||||
Args:
|
||||
project_root: Project root directory
|
||||
*parts: Additional path parts (e.g., 'trellis', 'finish-work.md')
|
||||
|
||||
Returns:
|
||||
Path to commands directory or file
|
||||
|
||||
Note:
|
||||
Cursor uses prefix naming: .cursor/commands/trellis-<name>.md
|
||||
Antigravity uses workflow directory: .agent/workflows/<name>.md
|
||||
Devin uses workflow directory: .devin/workflows/trellis-<name>.md
|
||||
Copilot uses prompt files: .github/prompts/<name>.prompt.md
|
||||
Pi uses prompt templates: .pi/prompts/trellis-<name>.md
|
||||
Claude/OpenCode use subdirectory: .claude/commands/trellis/<name>.md
|
||||
"""
|
||||
if self.platform == "pi":
|
||||
prompts_dir = self.get_config_dir(project_root) / "prompts"
|
||||
if not parts:
|
||||
return prompts_dir
|
||||
if len(parts) >= 2 and parts[0] == "trellis":
|
||||
filename = parts[-1]
|
||||
if filename.endswith(".md"):
|
||||
filename = filename[:-3]
|
||||
return prompts_dir / f"trellis-{filename}.md"
|
||||
return prompts_dir / Path(*parts)
|
||||
|
||||
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 {}
|
||||
elif self.platform == "trae":
|
||||
return {}
|
||||
else:
|
||||
return {"CLAUDE_NON_INTERACTIVE": "1"}
|
||||
|
||||
# =========================================================================
|
||||
# CLI Command Building
|
||||
# =========================================================================
|
||||
|
||||
def build_run_command(
|
||||
self,
|
||||
agent: str,
|
||||
prompt: str,
|
||||
session_id: str | None = None,
|
||||
skip_permissions: bool = True,
|
||||
verbose: bool = True,
|
||||
json_output: bool = True,
|
||||
) -> list[str]:
|
||||
"""Build CLI command for running an agent.
|
||||
|
||||
Args:
|
||||
agent: Agent name (will be mapped if needed)
|
||||
prompt: Prompt to send to the agent
|
||||
session_id: Optional session ID (Claude Code only for creation)
|
||||
skip_permissions: Whether to skip permission prompts
|
||||
verbose: Whether to enable verbose output
|
||||
json_output: Whether to use JSON output format
|
||||
|
||||
Returns:
|
||||
List of command arguments
|
||||
"""
|
||||
mapped_agent = self.get_agent_name(agent)
|
||||
|
||||
if self.platform == "opencode":
|
||||
cmd = ["opencode", "run"]
|
||||
cmd.extend(["--agent", mapped_agent])
|
||||
|
||||
# Note: OpenCode 'run' mode is non-interactive by default
|
||||
# No equivalent to Claude Code's --dangerously-skip-permissions
|
||||
# See: https://github.com/anomalyco/opencode/issues/9070
|
||||
|
||||
if json_output:
|
||||
cmd.extend(["--format", "json"])
|
||||
|
||||
if verbose:
|
||||
cmd.extend(["--log-level", "DEBUG", "--print-logs"])
|
||||
|
||||
# Note: OpenCode doesn't support --session-id on creation
|
||||
# Session ID must be extracted from logs after startup
|
||||
|
||||
cmd.append(prompt)
|
||||
|
||||
elif self.platform == "iflow":
|
||||
cmd = ["iflow", "-y", "-p"]
|
||||
cmd.append(f"${mapped_agent} {prompt}")
|
||||
elif self.platform == "codex":
|
||||
cmd = ["codex", "exec"]
|
||||
cmd.append(prompt)
|
||||
elif self.platform == "kiro":
|
||||
cmd = ["kiro", "run", prompt]
|
||||
elif self.platform == "gemini":
|
||||
cmd = ["gemini"]
|
||||
cmd.append(prompt)
|
||||
elif self.platform == "antigravity":
|
||||
raise ValueError(
|
||||
"Antigravity workflows are UI slash commands; CLI agent run is not supported."
|
||||
)
|
||||
elif self.platform == "devin":
|
||||
raise ValueError(
|
||||
"Devin workflows are UI slash commands; CLI agent run is not supported."
|
||||
)
|
||||
elif self.platform == "qoder":
|
||||
cmd = ["qodercli", "-p", prompt]
|
||||
elif self.platform == "codebuddy":
|
||||
raise ValueError(
|
||||
"CodeBuddy does not support non-interactive mode (no CLI agent)"
|
||||
)
|
||||
elif self.platform == "copilot":
|
||||
raise ValueError(
|
||||
"GitHub Copilot is IDE-only; CLI agent run is not supported."
|
||||
)
|
||||
elif self.platform == "droid":
|
||||
raise ValueError(
|
||||
"Factory Droid CLI agent run is not yet supported."
|
||||
)
|
||||
elif self.platform == "pi":
|
||||
cmd = ["pi", "-p", prompt]
|
||||
elif self.platform == "trae":
|
||||
raise ValueError(
|
||||
"Trae is IDE-only; CLI agent run is not supported."
|
||||
)
|
||||
|
||||
else: # claude
|
||||
cmd = ["claude", "-p"]
|
||||
cmd.extend(["--agent", mapped_agent])
|
||||
|
||||
if session_id:
|
||||
cmd.extend(["--session-id", session_id])
|
||||
|
||||
if skip_permissions:
|
||||
cmd.append("--dangerously-skip-permissions")
|
||||
|
||||
if json_output:
|
||||
cmd.extend(["--output-format", "stream-json"])
|
||||
|
||||
if verbose:
|
||||
cmd.append("--verbose")
|
||||
|
||||
cmd.append(prompt)
|
||||
|
||||
return cmd
|
||||
|
||||
def build_resume_command(self, session_id: str) -> list[str]:
|
||||
"""Build CLI command for resuming a session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to resume (ignored for iFlow)
|
||||
|
||||
Returns:
|
||||
List of command arguments
|
||||
"""
|
||||
if self.platform == "opencode":
|
||||
return ["opencode", "run", "--session", session_id]
|
||||
elif self.platform == "iflow":
|
||||
# iFlow uses -c to continue most recent conversation
|
||||
# session_id is ignored as iFlow doesn't support session IDs
|
||||
return ["iflow", "-c"]
|
||||
elif self.platform == "codex":
|
||||
return ["codex", "resume", session_id]
|
||||
elif self.platform == "kiro":
|
||||
return ["kiro", "resume", session_id]
|
||||
elif self.platform == "gemini":
|
||||
return ["gemini", "--resume", session_id]
|
||||
elif self.platform == "antigravity":
|
||||
raise ValueError(
|
||||
"Antigravity workflows are UI slash commands; CLI resume is not supported."
|
||||
)
|
||||
elif self.platform == "devin":
|
||||
raise ValueError(
|
||||
"Devin workflows are UI slash commands; CLI resume is not supported."
|
||||
)
|
||||
elif self.platform == "qoder":
|
||||
return ["qodercli", "--resume", session_id]
|
||||
elif self.platform == "codebuddy":
|
||||
raise ValueError(
|
||||
"CodeBuddy does not support non-interactive mode (no CLI agent)"
|
||||
)
|
||||
elif self.platform == "copilot":
|
||||
raise ValueError(
|
||||
"GitHub Copilot is IDE-only; CLI resume is not supported."
|
||||
)
|
||||
elif self.platform == "droid":
|
||||
raise ValueError(
|
||||
"Factory Droid CLI resume is not yet supported."
|
||||
)
|
||||
elif self.platform == "pi":
|
||||
return ["pi", "-c", session_id]
|
||||
elif self.platform == "trae":
|
||||
raise ValueError(
|
||||
"Trae is IDE-only; CLI resume is not supported."
|
||||
)
|
||||
else:
|
||||
return ["claude", "--resume", session_id]
|
||||
|
||||
def get_resume_command_str(self, session_id: str, cwd: str | None = None) -> str:
|
||||
"""Get human-readable resume command string.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to resume
|
||||
cwd: Optional working directory to cd into
|
||||
|
||||
Returns:
|
||||
Command string for display
|
||||
"""
|
||||
cmd = self.build_resume_command(session_id)
|
||||
cmd_str = " ".join(cmd)
|
||||
|
||||
if cwd:
|
||||
return f"cd {cwd} && {cmd_str}"
|
||||
return cmd_str
|
||||
|
||||
# =========================================================================
|
||||
# Platform Detection Helpers
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def is_opencode(self) -> bool:
|
||||
"""Check if platform is OpenCode."""
|
||||
return self.platform == "opencode"
|
||||
|
||||
@property
|
||||
def is_claude(self) -> bool:
|
||||
"""Check if platform is Claude Code."""
|
||||
return self.platform == "claude"
|
||||
|
||||
@property
|
||||
def is_cursor(self) -> bool:
|
||||
"""Check if platform is Cursor."""
|
||||
return self.platform == "cursor"
|
||||
|
||||
@property
|
||||
def is_iflow(self) -> bool:
|
||||
"""Check if platform is iFlow CLI."""
|
||||
return self.platform == "iflow"
|
||||
|
||||
@property
|
||||
def cli_name(self) -> str:
|
||||
"""Get CLI executable name.
|
||||
|
||||
Note: Cursor doesn't have a CLI tool, returns None-like value.
|
||||
"""
|
||||
if self.is_opencode:
|
||||
return "opencode"
|
||||
elif self.is_cursor:
|
||||
return "cursor" # Note: Cursor is IDE-only, no CLI
|
||||
elif self.platform == "iflow":
|
||||
return "iflow"
|
||||
elif self.platform == "kiro":
|
||||
return "kiro"
|
||||
elif self.platform == "gemini":
|
||||
return "gemini"
|
||||
elif self.platform == "antigravity":
|
||||
return "agy"
|
||||
elif self.platform == "devin":
|
||||
return "devin"
|
||||
elif self.platform == "qoder":
|
||||
return "qodercli"
|
||||
elif self.platform == "codebuddy":
|
||||
return "codebuddy"
|
||||
elif self.platform == "copilot":
|
||||
return "copilot"
|
||||
elif self.platform == "droid":
|
||||
return "droid"
|
||||
elif self.platform == "pi":
|
||||
return "pi"
|
||||
elif self.platform == "trae":
|
||||
return "trae"
|
||||
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', 'pi', or 'trae')
|
||||
|
||||
Returns:
|
||||
CLIAdapter instance
|
||||
|
||||
Raises:
|
||||
ValueError: If platform is not supported
|
||||
|
||||
Note:
|
||||
'windsurf' is accepted as a deprecated alias for 'devin' (Windsurf was
|
||||
renamed to Devin) and normalized before validation.
|
||||
"""
|
||||
# Deprecated alias: Windsurf was renamed to Devin.
|
||||
if platform == "windsurf":
|
||||
platform = "devin"
|
||||
if platform not in (
|
||||
"claude",
|
||||
"opencode",
|
||||
"cursor",
|
||||
"iflow",
|
||||
"codex",
|
||||
"kilo",
|
||||
"kiro",
|
||||
"gemini",
|
||||
"antigravity",
|
||||
"devin",
|
||||
"qoder",
|
||||
"codebuddy",
|
||||
"copilot",
|
||||
"droid",
|
||||
"pi",
|
||||
"trae",
|
||||
):
|
||||
raise ValueError(
|
||||
f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'devin', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', or 'trae')"
|
||||
)
|
||||
|
||||
return CLIAdapter(platform=platform) # type: ignore
|
||||
|
||||
|
||||
_ALL_PLATFORM_CONFIG_DIRS = (
|
||||
".claude",
|
||||
".cursor",
|
||||
".iflow",
|
||||
".opencode",
|
||||
".codex",
|
||||
".kilocode",
|
||||
".kiro",
|
||||
".gemini",
|
||||
".agent",
|
||||
".devin",
|
||||
".windsurf", # deprecated: pre-rename Devin config dir (still a platform signal)
|
||||
".qoder",
|
||||
".codebuddy",
|
||||
".github/copilot",
|
||||
".factory",
|
||||
".pi",
|
||||
".trae",
|
||||
)
|
||||
"""Platform-specific config directory names used by detect_platform exclusion
|
||||
checks. `.agents/skills/` is NOT listed here: it is a shared cross-platform
|
||||
layer (written by Codex, also consumed by Amp/Cline/Warp/etc. via the
|
||||
agentskills.io standard), not a single-platform signal. Its presence must not
|
||||
block detection of Kiro, Antigravity, Devin, or other platforms."""
|
||||
|
||||
|
||||
def _has_other_platform_dir(project_root: Path, exclude: set[str]) -> bool:
|
||||
"""Check if any platform config dir exists besides those in *exclude*."""
|
||||
return any(
|
||||
(project_root / d).is_dir()
|
||||
for d in _ALL_PLATFORM_CONFIG_DIRS
|
||||
if d not in exclude
|
||||
)
|
||||
|
||||
|
||||
def detect_platform(project_root: Path) -> Platform:
|
||||
"""Auto-detect platform based on existing config directories.
|
||||
|
||||
Detection order:
|
||||
1. TRELLIS_PLATFORM environment variable (if set)
|
||||
2. .opencode directory exists → opencode
|
||||
3. .iflow directory exists → iflow
|
||||
4. .cursor directory exists (without .claude) → cursor
|
||||
5. .gemini directory exists → gemini
|
||||
6. .codex exists and no other platform dirs → codex
|
||||
7. .kilocode directory exists → kilo
|
||||
8. .kiro/skills exists and no other platform dirs → kiro
|
||||
9. .agent/workflows exists and no other platform dirs → antigravity
|
||||
10. .devin/workflows (or legacy .windsurf/workflows) exists and no other platform dirs → devin
|
||||
11. .codebuddy directory exists → codebuddy
|
||||
12. .qoder directory exists → qoder
|
||||
13. .github/copilot directory exists → copilot
|
||||
14. .factory directory exists → droid
|
||||
15. .pi directory exists → pi
|
||||
16. .trae directory exists → trae
|
||||
17. Default → claude
|
||||
|
||||
Args:
|
||||
project_root: Project root directory
|
||||
|
||||
Returns:
|
||||
Detected platform ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'devin', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', 'trae', or default 'claude')
|
||||
"""
|
||||
import os
|
||||
|
||||
# Check environment variable first
|
||||
env_platform = os.environ.get("TRELLIS_PLATFORM", "").lower()
|
||||
# Deprecated alias: Windsurf was renamed to Devin.
|
||||
if env_platform == "windsurf":
|
||||
env_platform = "devin"
|
||||
if env_platform in (
|
||||
"claude",
|
||||
"opencode",
|
||||
"cursor",
|
||||
"iflow",
|
||||
"codex",
|
||||
"kilo",
|
||||
"kiro",
|
||||
"gemini",
|
||||
"antigravity",
|
||||
"devin",
|
||||
"qoder",
|
||||
"codebuddy",
|
||||
"copilot",
|
||||
"droid",
|
||||
"pi",
|
||||
"trae",
|
||||
):
|
||||
return env_platform # type: ignore
|
||||
|
||||
# Check for .opencode directory (OpenCode-specific)
|
||||
if (project_root / ".opencode").is_dir():
|
||||
return "opencode"
|
||||
|
||||
# Check for .iflow directory (iFlow-specific)
|
||||
if (project_root / ".iflow").is_dir():
|
||||
return "iflow"
|
||||
|
||||
# Check for .cursor directory (Cursor-specific)
|
||||
# Only detect as cursor if .claude doesn't exist (to avoid confusion)
|
||||
if (project_root / ".cursor").is_dir() and not (project_root / ".claude").is_dir():
|
||||
return "cursor"
|
||||
|
||||
# Check for .gemini directory (Gemini CLI-specific)
|
||||
if (project_root / ".gemini").is_dir():
|
||||
return "gemini"
|
||||
|
||||
# Check for .codex directory (Codex-specific)
|
||||
# .agents/skills/ alone does NOT trigger codex detection (it's a shared standard)
|
||||
if (project_root / ".codex").is_dir() and not _has_other_platform_dir(
|
||||
project_root, {".codex", ".agents"}
|
||||
):
|
||||
return "codex"
|
||||
|
||||
# Check for .kilocode directory (Kilo-specific)
|
||||
if (project_root / ".kilocode").is_dir():
|
||||
return "kilo"
|
||||
|
||||
# Check for Kiro skills directory only when no other platform config exists
|
||||
if (project_root / ".kiro" / "skills").is_dir() and not _has_other_platform_dir(
|
||||
project_root, {".kiro"}
|
||||
):
|
||||
return "kiro"
|
||||
|
||||
# Check for Antigravity workflow directory only when no other platform config exists
|
||||
if (
|
||||
project_root / ".agent" / "workflows"
|
||||
).is_dir() and not _has_other_platform_dir(
|
||||
project_root, {".agent", ".gemini"}
|
||||
):
|
||||
return "antigravity"
|
||||
|
||||
# Check for Devin workflow directory only when no other platform config
|
||||
# exists. `.windsurf/workflows` is the legacy pre-rename path (still detected
|
||||
# as devin for back-compat until users migrate via `trellis update --migrate`).
|
||||
if (
|
||||
(project_root / ".devin" / "workflows").is_dir()
|
||||
or (project_root / ".windsurf" / "workflows").is_dir()
|
||||
) and not _has_other_platform_dir(
|
||||
project_root, {".devin", ".windsurf"}
|
||||
):
|
||||
return "devin"
|
||||
|
||||
# Check for .codebuddy directory (CodeBuddy-specific)
|
||||
if (project_root / ".codebuddy").is_dir():
|
||||
return "codebuddy"
|
||||
|
||||
# Check for .qoder directory (Qoder-specific)
|
||||
if (project_root / ".qoder").is_dir():
|
||||
return "qoder"
|
||||
|
||||
# Check for .github/copilot directory (GitHub Copilot-specific)
|
||||
if (project_root / ".github" / "copilot").is_dir():
|
||||
return "copilot"
|
||||
|
||||
# Check for .factory directory (Factory Droid-specific)
|
||||
if (project_root / ".factory").is_dir():
|
||||
return "droid"
|
||||
|
||||
# Check for .pi directory (Pi Agent-specific)
|
||||
if (project_root / ".pi").is_dir():
|
||||
return "pi"
|
||||
|
||||
# Check for .trae directory (Trae IDE-specific)
|
||||
if (project_root / ".trae").is_dir():
|
||||
return "trae"
|
||||
|
||||
# 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)
|
||||
445
.trellis/scripts/common/config.py
Executable file
445
.trellis/scripts/common/config.py
Executable file
@@ -0,0 +1,445 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Trellis configuration reader.
|
||||
|
||||
Reads settings from .trellis/config.yaml with sensible defaults.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import DIR_WORKFLOW, get_repo_root
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# YAML Simple Parser (no dependencies)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _unquote(s: str) -> str:
|
||||
"""Remove exactly one layer of matching surrounding quotes.
|
||||
|
||||
Unlike str.strip('"'), this only removes the outermost pair,
|
||||
preserving any nested quotes inside the value.
|
||||
|
||||
Examples:
|
||||
_unquote('"hello"') -> 'hello'
|
||||
_unquote("'hello'") -> 'hello'
|
||||
_unquote('"echo \\'hi\\'"') -> "echo 'hi'"
|
||||
_unquote('hello') -> 'hello'
|
||||
_unquote('"hello\\'') -> '"hello\\'' (mismatched, unchanged)
|
||||
"""
|
||||
if len(s) >= 2 and s[0] == s[-1] and s[0] in ('"', "'"):
|
||||
return s[1:-1]
|
||||
return s
|
||||
|
||||
|
||||
def _strip_inline_comment(value: str) -> str:
|
||||
"""Strip ` # …` inline comments while preserving `#` inside quoted strings.
|
||||
|
||||
YAML treats ` #` (space-hash) as a comment opener; bare `#` inside a token
|
||||
is part of the value. Quoted strings are immune.
|
||||
|
||||
Mirrors :func:`common.trellis_config._strip_inline_comment` so both
|
||||
parsers handle ``key: value # comment`` identically.
|
||||
"""
|
||||
in_quote: str | None = None
|
||||
for idx, ch in enumerate(value):
|
||||
if in_quote:
|
||||
if ch == in_quote:
|
||||
in_quote = None
|
||||
continue
|
||||
if ch in ('"', "'"):
|
||||
in_quote = ch
|
||||
continue
|
||||
if ch == "#" and (idx == 0 or value[idx - 1].isspace()):
|
||||
return value[:idx]
|
||||
return value
|
||||
|
||||
|
||||
def parse_simple_yaml(content: str) -> dict:
|
||||
"""Parse simple YAML with nested dict support (no dependencies).
|
||||
|
||||
Supports:
|
||||
- key: value (string)
|
||||
- key: (followed by list items)
|
||||
- item1
|
||||
- item2
|
||||
- key: (followed by nested dict)
|
||||
nested_key: value
|
||||
nested_key2:
|
||||
- item
|
||||
|
||||
Uses indentation to detect nesting (2+ spaces deeper = child).
|
||||
|
||||
Args:
|
||||
content: YAML content string.
|
||||
|
||||
Returns:
|
||||
Parsed dict (values can be str, list[str], or dict).
|
||||
"""
|
||||
lines = content.splitlines()
|
||||
result: dict = {}
|
||||
_parse_yaml_block(lines, 0, 0, result)
|
||||
return result
|
||||
|
||||
|
||||
def _parse_yaml_block(
|
||||
lines: list[str], start: int, min_indent: int, target: dict
|
||||
) -> int:
|
||||
"""Parse a YAML block into target dict, returning next line index."""
|
||||
i = start
|
||||
current_list: list | None = None
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped = line.strip()
|
||||
|
||||
# Skip empty lines and comments
|
||||
if not stripped or stripped.startswith("#"):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Calculate indentation
|
||||
indent = len(line) - len(line.lstrip())
|
||||
|
||||
# If dedented past our block, we're done
|
||||
if indent < min_indent:
|
||||
break
|
||||
|
||||
if stripped.startswith("- "):
|
||||
if current_list is not None:
|
||||
current_list.append(_unquote(stripped[2:].strip()))
|
||||
i += 1
|
||||
elif ":" in stripped:
|
||||
key, _, value = stripped.partition(":")
|
||||
key = key.strip()
|
||||
value = _strip_inline_comment(value).strip()
|
||||
value = _unquote(value)
|
||||
current_list = None
|
||||
|
||||
if value:
|
||||
# key: value
|
||||
target[key] = value
|
||||
i += 1
|
||||
else:
|
||||
# key: (no value) — peek ahead to determine list vs nested dict
|
||||
next_i, next_line = _next_content_line(lines, i + 1)
|
||||
if next_i >= len(lines):
|
||||
target[key] = {}
|
||||
i = next_i
|
||||
elif next_line.strip().startswith("- "):
|
||||
# It's a list
|
||||
current_list = []
|
||||
target[key] = current_list
|
||||
i += 1
|
||||
else:
|
||||
next_indent = len(next_line) - len(next_line.lstrip())
|
||||
if next_indent > indent:
|
||||
# It's a nested dict
|
||||
nested: dict = {}
|
||||
target[key] = nested
|
||||
i = _parse_yaml_block(lines, i + 1, next_indent, nested)
|
||||
else:
|
||||
# Empty value, same or less indent follows
|
||||
target[key] = {}
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
return i
|
||||
|
||||
|
||||
def _next_content_line(lines: list[str], start: int) -> tuple[int, str]:
|
||||
"""Find the next non-empty, non-comment line."""
|
||||
i = start
|
||||
while i < len(lines):
|
||||
stripped = lines[i].strip()
|
||||
if stripped and not stripped.startswith("#"):
|
||||
return i, lines[i]
|
||||
i += 1
|
||||
return i, ""
|
||||
|
||||
|
||||
# Defaults
|
||||
DEFAULT_SESSION_COMMIT_MESSAGE = "chore: record journal"
|
||||
DEFAULT_MAX_JOURNAL_LINES = 2000
|
||||
DEFAULT_SESSION_AUTO_COMMIT = True
|
||||
|
||||
CONFIG_FILE = "config.yaml"
|
||||
|
||||
|
||||
def _is_true_config_value(value: object) -> bool:
|
||||
"""Return True when a config value represents an enabled flag."""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() == "true"
|
||||
return False
|
||||
|
||||
|
||||
def _get_config_path(repo_root: Path | None = None) -> Path:
|
||||
"""Get path to config.yaml."""
|
||||
root = repo_root or get_repo_root()
|
||||
return root / DIR_WORKFLOW / CONFIG_FILE
|
||||
|
||||
|
||||
def _load_config(repo_root: Path | None = None) -> dict:
|
||||
"""Load and parse config.yaml. Returns empty dict on any error."""
|
||||
config_file = _get_config_path(repo_root)
|
||||
try:
|
||||
content = config_file.read_text(encoding="utf-8")
|
||||
return parse_simple_yaml(content)
|
||||
except (OSError, IOError):
|
||||
return {}
|
||||
|
||||
|
||||
def get_session_commit_message(repo_root: Path | None = None) -> str:
|
||||
"""Get the commit message for auto-committing session records."""
|
||||
config = _load_config(repo_root)
|
||||
return config.get("session_commit_message", DEFAULT_SESSION_COMMIT_MESSAGE)
|
||||
|
||||
|
||||
def get_max_journal_lines(repo_root: Path | None = None) -> int:
|
||||
"""Get the maximum lines per journal file."""
|
||||
config = _load_config(repo_root)
|
||||
value = config.get("max_journal_lines", DEFAULT_MAX_JOURNAL_LINES)
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return DEFAULT_MAX_JOURNAL_LINES
|
||||
|
||||
|
||||
def get_session_auto_commit(repo_root: Path | None = None) -> bool:
|
||||
"""Whether scripts should auto-stage + auto-commit session/task changes.
|
||||
|
||||
Governs both ``add_session.py:_auto_commit_workspace`` and
|
||||
``task_store.py:_auto_commit_archive``.
|
||||
|
||||
Default: ``True`` (existing behavior — auto-stage + auto-commit).
|
||||
Set ``session_auto_commit: false`` in ``.trellis/config.yaml`` to skip
|
||||
auto-staging entirely; the journal/archive files are still written to
|
||||
disk, but the user manages ``git add`` / ``git commit`` themselves.
|
||||
|
||||
Accepts native YAML booleans (``true`` / ``false``) and the string
|
||||
aliases ``true / false / yes / no / 1 / 0 / on / off`` (case-insensitive).
|
||||
Invalid values fall back to ``True`` with a stderr warning.
|
||||
"""
|
||||
config = _load_config(repo_root)
|
||||
raw = config.get("session_auto_commit", DEFAULT_SESSION_AUTO_COMMIT)
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
s = str(raw).strip().lower()
|
||||
if s in ("true", "yes", "1", "on"):
|
||||
return True
|
||||
if s in ("false", "no", "0", "off"):
|
||||
return False
|
||||
print(
|
||||
f"[WARN] invalid session_auto_commit value: {raw!r}; using true (default)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return DEFAULT_SESSION_AUTO_COMMIT
|
||||
|
||||
|
||||
def get_hooks(event: str, repo_root: Path | None = None) -> list[str]:
|
||||
"""Get hook commands for a lifecycle event.
|
||||
|
||||
Args:
|
||||
event: Event name (e.g. "after_create", "after_archive").
|
||||
repo_root: Repository root path.
|
||||
|
||||
Returns:
|
||||
List of shell commands to execute, empty if none configured.
|
||||
"""
|
||||
config = _load_config(repo_root)
|
||||
hooks = config.get("hooks")
|
||||
if not isinstance(hooks, dict):
|
||||
return []
|
||||
commands = hooks.get(event)
|
||||
if isinstance(commands, list):
|
||||
return [str(c) for c in commands]
|
||||
return []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Monorepo / Packages
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_packages(repo_root: Path | None = None) -> dict[str, dict] | None:
|
||||
"""Get monorepo package declarations.
|
||||
|
||||
Returns:
|
||||
Dict mapping package name to its config (path, type, etc.),
|
||||
or None if not configured (single-repo mode).
|
||||
|
||||
Example return:
|
||||
{"cli": {"path": "packages/cli"}, "docs-site": {"path": "docs-site", "type": "submodule"}}
|
||||
"""
|
||||
config = _load_config(repo_root)
|
||||
packages = config.get("packages")
|
||||
if not isinstance(packages, dict):
|
||||
return None
|
||||
# Ensure each value is a dict (filter out scalar entries)
|
||||
filtered = {k: v for k, v in packages.items() if isinstance(v, dict)}
|
||||
if not filtered:
|
||||
return None
|
||||
return filtered
|
||||
|
||||
|
||||
def get_default_package(repo_root: Path | None = None) -> str | None:
|
||||
"""Get the default package name from config.
|
||||
|
||||
Returns:
|
||||
Package name string, or None if not configured.
|
||||
"""
|
||||
config = _load_config(repo_root)
|
||||
value = config.get("default_package")
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
def get_submodule_packages(repo_root: Path | None = None) -> dict[str, str]:
|
||||
"""Get packages that are git submodules.
|
||||
|
||||
Returns:
|
||||
Dict mapping package name to its path for submodule-type packages.
|
||||
Empty dict if none configured.
|
||||
|
||||
Example return:
|
||||
{"docs-site": "docs-site"}
|
||||
"""
|
||||
packages = get_packages(repo_root)
|
||||
if packages is None:
|
||||
return {}
|
||||
return {
|
||||
name: cfg.get("path", name)
|
||||
for name, cfg in packages.items()
|
||||
if cfg.get("type") == "submodule"
|
||||
}
|
||||
|
||||
|
||||
def get_git_packages(repo_root: Path | None = None) -> dict[str, str]:
|
||||
"""Get packages that have their own independent git repository.
|
||||
|
||||
These are sub-directories with their own .git (not submodules),
|
||||
marked with ``git: true`` in config.yaml.
|
||||
|
||||
Returns:
|
||||
Dict mapping package name to its path for git-repo packages.
|
||||
Empty dict if none configured.
|
||||
|
||||
Example config::
|
||||
|
||||
packages:
|
||||
backend:
|
||||
path: iqs
|
||||
git: true
|
||||
|
||||
Example return::
|
||||
|
||||
{"backend": "iqs"}
|
||||
"""
|
||||
packages = get_packages(repo_root)
|
||||
if packages is None:
|
||||
return {}
|
||||
return {
|
||||
name: cfg.get("path", name)
|
||||
for name, cfg in packages.items()
|
||||
if _is_true_config_value(cfg.get("git"))
|
||||
}
|
||||
|
||||
|
||||
def is_monorepo(repo_root: Path | None = None) -> bool:
|
||||
"""Check if the project is configured as a monorepo (has packages in config)."""
|
||||
return get_packages(repo_root) is not None
|
||||
|
||||
|
||||
def get_spec_base(package: str | None = None, repo_root: Path | None = None) -> str:
|
||||
"""Get the spec directory base path relative to .trellis/.
|
||||
|
||||
Single-repo: returns "spec"
|
||||
Monorepo with package: returns "spec/<package>"
|
||||
Monorepo without package: returns "spec" (caller should specify package)
|
||||
"""
|
||||
if package and is_monorepo(repo_root):
|
||||
return f"spec/{package}"
|
||||
return "spec"
|
||||
|
||||
|
||||
def validate_package(package: str, repo_root: Path | None = None) -> bool:
|
||||
"""Check if a package name is valid in this project.
|
||||
|
||||
Single-repo (no packages configured): always returns True.
|
||||
Monorepo: returns True only if package exists in config.yaml packages.
|
||||
"""
|
||||
packages = get_packages(repo_root)
|
||||
if packages is None:
|
||||
return True # Single-repo, no validation needed
|
||||
return package in packages
|
||||
|
||||
|
||||
def resolve_package(
|
||||
task_package: str | None = None,
|
||||
repo_root: Path | None = None,
|
||||
) -> str | None:
|
||||
"""Resolve package from inferred sources with validation.
|
||||
|
||||
Checks in order: task_package → default_package.
|
||||
Invalid inferred values print a warning to stderr and are skipped.
|
||||
|
||||
Returns:
|
||||
Resolved package name, or None if no valid package found.
|
||||
|
||||
Note:
|
||||
CLI --package should be validated separately by the caller
|
||||
(fail-fast with available packages list on error).
|
||||
"""
|
||||
packages = get_packages(repo_root)
|
||||
if packages is None:
|
||||
return None # Single-repo, no package needed
|
||||
|
||||
# Try task_package (guard against non-string values from malformed JSON)
|
||||
if task_package and isinstance(task_package, str):
|
||||
if task_package in packages:
|
||||
return task_package
|
||||
print(
|
||||
f"Warning: task.json package '{task_package}' not found in config, skipping",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Try default_package
|
||||
default = get_default_package(repo_root)
|
||||
if default:
|
||||
if default in packages:
|
||||
return default
|
||||
print(
|
||||
f"Warning: default_package '{default}' not found in config, skipping",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_spec_scope(repo_root: Path | None = None) -> list[str] | str | None:
|
||||
"""Get session.spec_scope configuration.
|
||||
|
||||
Returns:
|
||||
list[str]: Package names to include in spec scanning.
|
||||
str: "active_task" to use current task's package.
|
||||
None: No scope configured (scan all packages).
|
||||
"""
|
||||
config = _load_config(repo_root)
|
||||
session = config.get("session")
|
||||
if not isinstance(session, dict):
|
||||
return None
|
||||
|
||||
scope = session.get("spec_scope")
|
||||
if scope is None:
|
||||
return None
|
||||
if isinstance(scope, str):
|
||||
return scope # e.g. "active_task"
|
||||
if isinstance(scope, list):
|
||||
return [str(s) for s in scope]
|
||||
return None
|
||||
190
.trellis/scripts/common/developer.py
Executable file
190
.trellis/scripts/common/developer.py
Executable file
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Developer management utilities.
|
||||
|
||||
Provides:
|
||||
init_developer - Initialize developer
|
||||
ensure_developer - Ensure developer is initialized (exit if not)
|
||||
show_developer_info - Show developer information
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import (
|
||||
DIR_WORKFLOW,
|
||||
DIR_WORKSPACE,
|
||||
DIR_TASKS,
|
||||
FILE_DEVELOPER,
|
||||
FILE_JOURNAL_PREFIX,
|
||||
get_repo_root,
|
||||
get_developer,
|
||||
check_developer,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Developer Initialization
|
||||
# =============================================================================
|
||||
|
||||
def init_developer(name: str, repo_root: Path | None = None) -> bool:
|
||||
"""Initialize developer.
|
||||
|
||||
Creates:
|
||||
- .trellis/.developer file with developer info
|
||||
- .trellis/workspace/<name>/ directory structure
|
||||
- Initial journal file and index.md
|
||||
|
||||
Args:
|
||||
name: Developer name.
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
True on success, False on error.
|
||||
"""
|
||||
if not name:
|
||||
print("Error: developer name is required", file=sys.stderr)
|
||||
return False
|
||||
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
|
||||
workspace_dir = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / name
|
||||
|
||||
# Create .developer file
|
||||
initialized_at = datetime.now().isoformat()
|
||||
try:
|
||||
dev_file.write_text(
|
||||
f"name={name}\ninitialized_at={initialized_at}\n",
|
||||
encoding="utf-8"
|
||||
)
|
||||
except (OSError, IOError) as e:
|
||||
print(f"Error: Failed to create .developer file: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Create workspace directory structure
|
||||
try:
|
||||
workspace_dir.mkdir(parents=True, exist_ok=True)
|
||||
except (OSError, IOError) as e:
|
||||
print(f"Error: Failed to create workspace directory: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Create initial journal file
|
||||
journal_file = workspace_dir / f"{FILE_JOURNAL_PREFIX}1.md"
|
||||
if not journal_file.exists():
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
journal_content = f"""# Journal - {name} (Part 1)
|
||||
|
||||
> AI development session journal
|
||||
> Started: {today}
|
||||
|
||||
---
|
||||
|
||||
"""
|
||||
try:
|
||||
journal_file.write_text(journal_content, encoding="utf-8")
|
||||
except (OSError, IOError) as e:
|
||||
print(f"Error: Failed to create journal file: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Create index.md with markers for auto-update
|
||||
index_file = workspace_dir / "index.md"
|
||||
if not index_file.exists():
|
||||
index_content = f"""# Workspace Index - {name}
|
||||
|
||||
> Journal tracking for AI development sessions.
|
||||
|
||||
---
|
||||
|
||||
## Current Status
|
||||
|
||||
<!-- @@@auto:current-status -->
|
||||
- **Active File**: `journal-1.md`
|
||||
- **Total Sessions**: 0
|
||||
- **Last Active**: -
|
||||
<!-- @@@/auto:current-status -->
|
||||
|
||||
---
|
||||
|
||||
## Active Documents
|
||||
|
||||
<!-- @@@auto:active-documents -->
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| `journal-1.md` | ~0 | Active |
|
||||
<!-- @@@/auto:active-documents -->
|
||||
|
||||
---
|
||||
|
||||
## Session History
|
||||
|
||||
<!-- @@@auto:session-history -->
|
||||
| # | Date | Title | Commits | Branch |
|
||||
|---|------|-------|---------|--------|
|
||||
<!-- @@@/auto:session-history -->
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Sessions are appended to journal files
|
||||
- New journal file created when current exceeds 2000 lines
|
||||
- Use `add_session.py` to record sessions
|
||||
"""
|
||||
try:
|
||||
index_file.write_text(index_content, encoding="utf-8")
|
||||
except (OSError, IOError) as e:
|
||||
print(f"Error: Failed to create index.md: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
print(f"Developer initialized: {name}")
|
||||
print(f" .developer file: {dev_file}")
|
||||
print(f" Workspace dir: {workspace_dir}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def ensure_developer(repo_root: Path | None = None) -> None:
|
||||
"""Ensure developer is initialized, exit if not.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
if not check_developer(repo_root):
|
||||
print("Error: Developer not initialized.", file=sys.stderr)
|
||||
print(f"Run: python3 ./{DIR_WORKFLOW}/scripts/init_developer.py <your-name>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def show_developer_info(repo_root: Path | None = None) -> None:
|
||||
"""Show developer information.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
|
||||
if not developer:
|
||||
print("Developer: (not initialized)")
|
||||
else:
|
||||
print(f"Developer: {developer}")
|
||||
print(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/")
|
||||
print(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Entry (for testing)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
show_developer_info()
|
||||
31
.trellis/scripts/common/git.py
Executable file
31
.trellis/scripts/common/git.py
Executable file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Git command execution utility.
|
||||
|
||||
Single source of truth for running git commands across all Trellis scripts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_git(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]:
|
||||
"""Run a git command and return (returncode, stdout, stderr).
|
||||
|
||||
Uses UTF-8 encoding with -c i18n.logOutputEncoding=UTF-8 to ensure
|
||||
consistent output across all platforms (Windows, macOS, Linux).
|
||||
"""
|
||||
try:
|
||||
git_args = ["git", "-c", "i18n.logOutputEncoding=UTF-8"] + args
|
||||
result = subprocess.run(
|
||||
git_args,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
except Exception as e:
|
||||
return 1, "", str(e)
|
||||
106
.trellis/scripts/common/git_context.py
Executable file
106
.trellis/scripts/common/git_context.py
Executable file
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Git and Session Context utilities.
|
||||
|
||||
Entry shim — delegates to session_context and packages_context.
|
||||
|
||||
Provides:
|
||||
output_json - Output context in JSON format
|
||||
output_text - Output context in text format
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from .git import run_git
|
||||
from .session_context import (
|
||||
get_context_json,
|
||||
get_context_text,
|
||||
get_context_record_json,
|
||||
get_context_text_record,
|
||||
output_json,
|
||||
output_text,
|
||||
)
|
||||
from .packages_context import (
|
||||
get_context_packages_text,
|
||||
get_context_packages_json,
|
||||
)
|
||||
from .trellis_config import read_trellis_config
|
||||
from .workflow_phase import (
|
||||
filter_platform,
|
||||
get_phase_index,
|
||||
get_step,
|
||||
resolve_effective_platform,
|
||||
)
|
||||
|
||||
# Backward-compatible alias — external modules import this name
|
||||
_run_git_command = run_git
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Entry
|
||||
# =============================================================================
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Get Session Context for AI Agent")
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
"-j",
|
||||
action="store_true",
|
||||
help="Output in JSON format (works with any --mode)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
"-m",
|
||||
choices=["default", "record", "packages", "phase"],
|
||||
default="default",
|
||||
help="Output mode: default (full context), record (for record-session), packages (package info only), phase (workflow step extraction)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--step",
|
||||
help="Step id for --mode phase, e.g. 1.1, 2.2. Omit to get the Phase Index.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--platform",
|
||||
help="Platform name for --mode phase, e.g. cursor, claude-code. Filters platform-tagged blocks.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.mode == "record":
|
||||
if args.json:
|
||||
print(json.dumps(get_context_record_json(), indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(get_context_text_record())
|
||||
elif args.mode == "packages":
|
||||
if args.json:
|
||||
print(json.dumps(get_context_packages_json(), indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(get_context_packages_text())
|
||||
elif args.mode == "phase":
|
||||
content = get_step(args.step) if args.step else get_phase_index()
|
||||
if not content.strip():
|
||||
if args.step:
|
||||
parser.exit(2, f"Step not found: {args.step}\n")
|
||||
else:
|
||||
parser.exit(2, "Phase Index section not found in workflow.md\n")
|
||||
if args.platform:
|
||||
effective = resolve_effective_platform(
|
||||
args.platform, read_trellis_config()
|
||||
)
|
||||
content = filter_platform(content, effective)
|
||||
print(content, end="")
|
||||
else:
|
||||
if args.json:
|
||||
output_json()
|
||||
else:
|
||||
output_text()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
37
.trellis/scripts/common/io.py
Executable file
37
.trellis/scripts/common/io.py
Executable file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
JSON file I/O utilities.
|
||||
|
||||
Provides read_json and write_json as the single source of truth
|
||||
for JSON file operations across all Trellis scripts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict | None:
|
||||
"""Read and parse a JSON file.
|
||||
|
||||
Returns None if the file doesn't exist, is invalid JSON, or can't be read.
|
||||
"""
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def write_json(path: Path, data: dict) -> bool:
|
||||
"""Write dict to JSON file with pretty formatting.
|
||||
|
||||
Returns True on success, False on error.
|
||||
"""
|
||||
try:
|
||||
path.write_text(
|
||||
json.dumps(data, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return True
|
||||
except (OSError, IOError):
|
||||
return False
|
||||
45
.trellis/scripts/common/log.py
Executable file
45
.trellis/scripts/common/log.py
Executable file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Terminal output utilities: colors and structured logging.
|
||||
|
||||
Single source of truth for Colors and log_* functions
|
||||
used across all Trellis scripts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class Colors:
|
||||
"""ANSI color codes for terminal output."""
|
||||
|
||||
RED = "\033[0;31m"
|
||||
GREEN = "\033[0;32m"
|
||||
YELLOW = "\033[1;33m"
|
||||
BLUE = "\033[0;34m"
|
||||
CYAN = "\033[0;36m"
|
||||
DIM = "\033[2m"
|
||||
NC = "\033[0m" # No Color / Reset
|
||||
|
||||
|
||||
def colored(text: str, color: str) -> str:
|
||||
"""Apply ANSI color to text."""
|
||||
return f"{color}{text}{Colors.NC}"
|
||||
|
||||
|
||||
def log_info(msg: str) -> None:
|
||||
"""Print info-level message with [INFO] prefix."""
|
||||
print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}")
|
||||
|
||||
|
||||
def log_success(msg: str) -> None:
|
||||
"""Print success message with [SUCCESS] prefix."""
|
||||
print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}")
|
||||
|
||||
|
||||
def log_warn(msg: str) -> None:
|
||||
"""Print warning message with [WARN] prefix."""
|
||||
print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}")
|
||||
|
||||
|
||||
def log_error(msg: str) -> None:
|
||||
"""Print error message with [ERROR] prefix."""
|
||||
print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}")
|
||||
238
.trellis/scripts/common/packages_context.py
Executable file
238
.trellis/scripts/common/packages_context.py
Executable file
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Package discovery and context output.
|
||||
|
||||
Provides:
|
||||
get_packages_info - Get structured package info
|
||||
get_packages_section - Build PACKAGES text section
|
||||
get_context_packages_text - Full packages text output (--mode packages)
|
||||
get_context_packages_json - Full packages JSON output (--mode packages --json)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .config import _is_true_config_value, get_default_package, get_packages, get_spec_scope
|
||||
from .paths import (
|
||||
DIR_SPEC,
|
||||
DIR_WORKFLOW,
|
||||
get_current_task,
|
||||
get_repo_root,
|
||||
)
|
||||
from .tasks import load_task
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internal Helpers
|
||||
# =============================================================================
|
||||
|
||||
def _scan_spec_layers(spec_dir: Path, package: str | None = None) -> list[str]:
|
||||
"""Scan spec directory for available layers (subdirectories).
|
||||
|
||||
For monorepo: scans spec/<package>/
|
||||
For single-repo: scans spec/
|
||||
"""
|
||||
target = spec_dir / package if package else spec_dir
|
||||
if not target.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
d.name for d in target.iterdir() if d.is_dir() and d.name != "guides"
|
||||
)
|
||||
|
||||
|
||||
def _get_active_task_package(repo_root: Path) -> str | None:
|
||||
"""Get the package field from the active task's task.json."""
|
||||
current = get_current_task(repo_root)
|
||||
if not current:
|
||||
return None
|
||||
ct = load_task(repo_root / current)
|
||||
return ct.package if ct and ct.package else None
|
||||
|
||||
|
||||
def _resolve_scope_set(
|
||||
packages: dict,
|
||||
spec_scope,
|
||||
task_pkg: str | None,
|
||||
default_pkg: str | None,
|
||||
) -> set | None:
|
||||
"""Resolve spec_scope to a set of allowed package names, or None for full scan."""
|
||||
if not packages:
|
||||
return None
|
||||
|
||||
if spec_scope is None:
|
||||
return None
|
||||
|
||||
if isinstance(spec_scope, str) and spec_scope == "active_task":
|
||||
if task_pkg and task_pkg in packages:
|
||||
return {task_pkg}
|
||||
if default_pkg and default_pkg in packages:
|
||||
return {default_pkg}
|
||||
return None
|
||||
|
||||
if isinstance(spec_scope, list):
|
||||
valid = {e for e in spec_scope if e in packages}
|
||||
if valid:
|
||||
return valid
|
||||
# All invalid: fallback
|
||||
if task_pkg and task_pkg in packages:
|
||||
return {task_pkg}
|
||||
if default_pkg and default_pkg in packages:
|
||||
return {default_pkg}
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Public Functions
|
||||
# =============================================================================
|
||||
|
||||
def get_packages_info(repo_root: Path) -> list[dict]:
|
||||
"""Get structured package info for monorepo projects.
|
||||
|
||||
Returns list of dicts with keys: name, path, type, default, specLayers,
|
||||
isSubmodule, isGitRepo.
|
||||
Returns empty list for single-repo projects.
|
||||
"""
|
||||
packages = get_packages(repo_root)
|
||||
if not packages:
|
||||
return []
|
||||
|
||||
default_pkg = get_default_package(repo_root)
|
||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||
result = []
|
||||
|
||||
for pkg_name, pkg_config in packages.items():
|
||||
pkg_path = pkg_config.get("path", pkg_name) if isinstance(pkg_config, dict) else str(pkg_config)
|
||||
pkg_type = pkg_config.get("type", "local") if isinstance(pkg_config, dict) else "local"
|
||||
pkg_git = pkg_config.get("git", False) if isinstance(pkg_config, dict) else False
|
||||
layers = _scan_spec_layers(spec_dir, pkg_name)
|
||||
|
||||
result.append({
|
||||
"name": pkg_name,
|
||||
"path": pkg_path,
|
||||
"type": pkg_type,
|
||||
"default": pkg_name == default_pkg,
|
||||
"specLayers": layers,
|
||||
"isSubmodule": pkg_type == "submodule",
|
||||
"isGitRepo": _is_true_config_value(pkg_git),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_packages_section(repo_root: Path) -> str:
|
||||
"""Build the PACKAGES section for text output."""
|
||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||
pkg_info = get_packages_info(repo_root)
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("## PACKAGES")
|
||||
|
||||
if not pkg_info:
|
||||
lines.append("(single-repo mode)")
|
||||
layers = _scan_spec_layers(spec_dir)
|
||||
if layers:
|
||||
lines.append(f"Spec layers: {', '.join(layers)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
default_pkg = get_default_package(repo_root)
|
||||
|
||||
for pkg in pkg_info:
|
||||
layers_str = f" [{', '.join(pkg['specLayers'])}]" if pkg["specLayers"] else ""
|
||||
submodule_tag = " (submodule)" if pkg["isSubmodule"] else ""
|
||||
git_repo_tag = " (git repo)" if pkg["isGitRepo"] else ""
|
||||
default_tag = " *" if pkg["default"] else ""
|
||||
lines.append(
|
||||
f"- {pkg['name']:<16} {pkg['path']:<20}{layers_str}{submodule_tag}{git_repo_tag}{default_tag}"
|
||||
)
|
||||
|
||||
if default_pkg:
|
||||
lines.append(f"Default package: {default_pkg}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_context_packages_text(repo_root: Path | None = None) -> str:
|
||||
"""Get packages context as formatted text (for --mode packages)."""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
pkg_info = get_packages_info(repo_root)
|
||||
lines: list[str] = []
|
||||
|
||||
if not pkg_info:
|
||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||
lines.append("Single-repo project (no packages configured)")
|
||||
lines.append("")
|
||||
layers = _scan_spec_layers(spec_dir)
|
||||
if layers:
|
||||
lines.append(f"Spec layers: {', '.join(layers)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
# Resolve scope for annotations
|
||||
packages_dict = get_packages(repo_root) or {}
|
||||
default_pkg = get_default_package(repo_root)
|
||||
spec_scope = get_spec_scope(repo_root)
|
||||
task_pkg = _get_active_task_package(repo_root)
|
||||
scope_set = _resolve_scope_set(packages_dict, spec_scope, task_pkg, default_pkg)
|
||||
|
||||
lines.append("## PACKAGES")
|
||||
lines.append("")
|
||||
for pkg in pkg_info:
|
||||
default_tag = " (default)" if pkg["default"] else ""
|
||||
type_tag = f" [{pkg['type']}]" if pkg["type"] != "local" else ""
|
||||
git_tag = " [git repo]" if pkg["isGitRepo"] else ""
|
||||
|
||||
# Scope annotation
|
||||
scope_tag = ""
|
||||
if scope_set is not None and pkg["name"] not in scope_set:
|
||||
scope_tag = " (out of scope)"
|
||||
|
||||
lines.append(f"### {pkg['name']}{default_tag}{type_tag}{git_tag}{scope_tag}")
|
||||
lines.append(f"Path: {pkg['path']}")
|
||||
if pkg["specLayers"]:
|
||||
lines.append(f"Spec layers: {', '.join(pkg['specLayers'])}")
|
||||
for layer in pkg["specLayers"]:
|
||||
lines.append(f" - .trellis/spec/{pkg['name']}/{layer}/index.md")
|
||||
else:
|
||||
lines.append("Spec: not configured")
|
||||
lines.append("")
|
||||
|
||||
# Also show shared guides
|
||||
guides_dir = repo_root / DIR_WORKFLOW / DIR_SPEC / "guides"
|
||||
if guides_dir.is_dir():
|
||||
lines.append("### Shared Guides (always included)")
|
||||
lines.append("Path: .trellis/spec/guides/index.md")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_context_packages_json(repo_root: Path | None = None) -> dict:
|
||||
"""Get packages context as a dictionary (for --mode packages --json)."""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
pkg_info = get_packages_info(repo_root)
|
||||
|
||||
if not pkg_info:
|
||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||
layers = _scan_spec_layers(spec_dir)
|
||||
return {
|
||||
"mode": "single-repo",
|
||||
"specLayers": layers,
|
||||
}
|
||||
|
||||
default_pkg = get_default_package(repo_root)
|
||||
spec_scope = get_spec_scope(repo_root)
|
||||
task_pkg = _get_active_task_package(repo_root)
|
||||
|
||||
return {
|
||||
"mode": "monorepo",
|
||||
"packages": pkg_info,
|
||||
"defaultPackage": default_pkg,
|
||||
"specScope": spec_scope,
|
||||
"activeTaskPackage": task_pkg,
|
||||
}
|
||||
447
.trellis/scripts/common/paths.py
Executable file
447
.trellis/scripts/common/paths.py
Executable file
@@ -0,0 +1,447 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Common path utilities for Trellis workflow.
|
||||
|
||||
Provides:
|
||||
get_repo_root - Get repository root directory
|
||||
get_developer - Get developer name
|
||||
get_workspace_dir - Get developer workspace directory
|
||||
get_tasks_dir - Get tasks directory
|
||||
get_active_journal_file - Get current journal file
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Path Constants (change here to rename directories)
|
||||
# =============================================================================
|
||||
|
||||
# Directory names
|
||||
DIR_WORKFLOW = ".trellis"
|
||||
DIR_WORKSPACE = "workspace"
|
||||
DIR_TASKS = "tasks"
|
||||
DIR_ARCHIVE = "archive"
|
||||
DIR_SPEC = "spec"
|
||||
DIR_SCRIPTS = "scripts"
|
||||
|
||||
# File names
|
||||
FILE_DEVELOPER = ".developer"
|
||||
FILE_CURRENT_TASK = ".current-task"
|
||||
FILE_TASK_JSON = "task.json"
|
||||
FILE_JOURNAL_PREFIX = "journal-"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Repository Root
|
||||
# =============================================================================
|
||||
|
||||
def get_repo_root(start_path: Path | None = None) -> Path:
|
||||
"""Find the nearest directory containing .trellis/ folder.
|
||||
|
||||
This handles nested git repos correctly (e.g., test project inside another repo).
|
||||
|
||||
Args:
|
||||
start_path: Starting directory to search from. Defaults to current directory.
|
||||
|
||||
Returns:
|
||||
Path to repository root, or current directory if no .trellis/ found.
|
||||
"""
|
||||
current = (start_path or Path.cwd()).resolve()
|
||||
|
||||
while current != current.parent:
|
||||
if (current / DIR_WORKFLOW).is_dir():
|
||||
return current
|
||||
current = current.parent
|
||||
|
||||
# Fallback to current directory if no .trellis/ found
|
||||
return Path.cwd().resolve()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Developer
|
||||
# =============================================================================
|
||||
|
||||
def get_developer(repo_root: Path | None = None) -> str | None:
|
||||
"""Get developer name from .developer file.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Developer name or None if not initialized.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
|
||||
|
||||
if not dev_file.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
content = dev_file.read_text(encoding="utf-8")
|
||||
for line in content.splitlines():
|
||||
if line.startswith("name="):
|
||||
return line.split("=", 1)[1].strip()
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def check_developer(repo_root: Path | None = None) -> bool:
|
||||
"""Check if developer is initialized.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
True if developer is initialized.
|
||||
"""
|
||||
return get_developer(repo_root) is not None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tasks Directory
|
||||
# =============================================================================
|
||||
|
||||
def get_tasks_dir(repo_root: Path | None = None) -> Path:
|
||||
"""Get tasks directory path.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Path to tasks directory.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
return repo_root / DIR_WORKFLOW / DIR_TASKS
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Workspace Directory
|
||||
# =============================================================================
|
||||
|
||||
def get_workspace_dir(repo_root: Path | None = None) -> Path | None:
|
||||
"""Get developer workspace directory.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Path to workspace directory or None if developer not set.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
if developer:
|
||||
return repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Journal File
|
||||
# =============================================================================
|
||||
|
||||
def get_active_journal_file(repo_root: Path | None = None) -> Path | None:
|
||||
"""Get the current active journal file.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Path to active journal file or None if not found.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
workspace_dir = get_workspace_dir(repo_root)
|
||||
if workspace_dir is None or not workspace_dir.is_dir():
|
||||
return None
|
||||
|
||||
latest: Path | None = None
|
||||
highest = 0
|
||||
|
||||
for f in workspace_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
|
||||
# Extract number from filename
|
||||
name = f.stem # e.g., "journal-1"
|
||||
match = re.search(r"(\d+)$", name)
|
||||
if match:
|
||||
num = int(match.group(1))
|
||||
if num > highest:
|
||||
highest = num
|
||||
latest = f
|
||||
|
||||
return latest
|
||||
|
||||
|
||||
def count_lines(file_path: Path) -> int:
|
||||
"""Count lines in a file.
|
||||
|
||||
Args:
|
||||
file_path: Path to file.
|
||||
|
||||
Returns:
|
||||
Number of lines, or 0 if file doesn't exist.
|
||||
"""
|
||||
if not file_path.is_file():
|
||||
return 0
|
||||
|
||||
try:
|
||||
return len(file_path.read_text(encoding="utf-8").splitlines())
|
||||
except (OSError, IOError):
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Current Task Management
|
||||
# =============================================================================
|
||||
|
||||
def normalize_task_ref(task_ref: str) -> str:
|
||||
"""Normalize a task ref for stable runtime storage.
|
||||
|
||||
Stored refs should prefer repo-relative POSIX paths like
|
||||
`.trellis/tasks/03-27-my-task`, even on Windows. Absolute paths are preserved
|
||||
unless they can later be converted back to repo-relative form by callers.
|
||||
"""
|
||||
normalized = task_ref.strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
|
||||
path_obj = Path(normalized)
|
||||
if path_obj.is_absolute():
|
||||
return str(path_obj)
|
||||
|
||||
normalized = normalized.replace("\\", "/")
|
||||
while normalized.startswith("./"):
|
||||
normalized = normalized[2:]
|
||||
|
||||
if normalized.startswith(f"{DIR_TASKS}/"):
|
||||
return f"{DIR_WORKFLOW}/{normalized}"
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def resolve_task_ref(task_ref: str, repo_root: Path | None = None) -> Path | None:
|
||||
"""Resolve a task ref to an absolute task directory path."""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
normalized = normalize_task_ref(task_ref)
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
path_obj = Path(normalized)
|
||||
if path_obj.is_absolute():
|
||||
return path_obj
|
||||
|
||||
if normalized.startswith(f"{DIR_WORKFLOW}/"):
|
||||
return repo_root / path_obj
|
||||
|
||||
return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj
|
||||
|
||||
|
||||
def get_current_task(
|
||||
repo_root: Path | None = None,
|
||||
platform_input: dict | None = None,
|
||||
platform: str | None = None,
|
||||
) -> str | None:
|
||||
"""Get current task directory path (relative to repo_root).
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Relative path to current task directory or None.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
from .active_task import resolve_active_task
|
||||
|
||||
return resolve_active_task(repo_root, platform_input, platform).task_path
|
||||
|
||||
|
||||
def get_current_task_abs(
|
||||
repo_root: Path | None = None,
|
||||
platform_input: dict | None = None,
|
||||
platform: str | None = None,
|
||||
) -> Path | None:
|
||||
"""Get current task directory absolute path.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Absolute path to current task directory or None.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
relative = get_current_task(repo_root, platform_input, platform)
|
||||
if relative:
|
||||
return resolve_task_ref(relative, repo_root)
|
||||
return None
|
||||
|
||||
|
||||
def get_current_task_source(
|
||||
repo_root: Path | None = None,
|
||||
platform_input: dict | None = None,
|
||||
platform: str | None = None,
|
||||
) -> tuple[str, str | None, str | None]:
|
||||
"""Get active task source as (`source`, `context_key`, `task_path`)."""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
from .active_task import get_current_task_source as _get_source
|
||||
|
||||
return _get_source(repo_root, platform_input, platform)
|
||||
|
||||
|
||||
def set_current_task(
|
||||
task_path: str,
|
||||
repo_root: Path | None = None,
|
||||
platform_input: dict | None = None,
|
||||
platform: str | None = None,
|
||||
) -> bool:
|
||||
"""Set current task in session scope.
|
||||
|
||||
Args:
|
||||
task_path: Task directory path (relative to repo_root).
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
True on success, False on error.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
from .active_task import set_active_task
|
||||
|
||||
return set_active_task(
|
||||
task_path,
|
||||
repo_root,
|
||||
platform_input=platform_input,
|
||||
platform=platform,
|
||||
) is not None
|
||||
|
||||
|
||||
def clear_current_task(
|
||||
repo_root: Path | None = None,
|
||||
platform_input: dict | None = None,
|
||||
platform: str | None = None,
|
||||
) -> bool:
|
||||
"""Clear current task in session scope.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
True on success.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
from .active_task import clear_active_task
|
||||
|
||||
clear_active_task(
|
||||
repo_root,
|
||||
platform_input=platform_input,
|
||||
platform=platform,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def has_current_task(repo_root: Path | None = None) -> bool:
|
||||
"""Check if has current task.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
True if current task is set.
|
||||
"""
|
||||
return get_current_task(repo_root) is not None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Task ID Generation
|
||||
# =============================================================================
|
||||
|
||||
def generate_task_date_prefix() -> str:
|
||||
"""Generate task ID based on date (MM-DD format).
|
||||
|
||||
Returns:
|
||||
Date prefix string (e.g., "01-21").
|
||||
"""
|
||||
return datetime.now().strftime("%m-%d")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Monorepo / Package Paths
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_spec_dir(package: str | None = None, repo_root: Path | None = None) -> Path:
|
||||
"""Get the spec directory path.
|
||||
|
||||
Single-repo: .trellis/spec
|
||||
Monorepo with package: .trellis/spec/<package>
|
||||
|
||||
Uses lazy import to avoid circular dependency with config.py.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
from .config import get_spec_base
|
||||
|
||||
base = get_spec_base(package, repo_root)
|
||||
return repo_root / DIR_WORKFLOW / base
|
||||
|
||||
|
||||
def get_package_path(package: str, repo_root: Path | None = None) -> Path | None:
|
||||
"""Get a package's source directory absolute path from config.
|
||||
|
||||
Returns:
|
||||
Absolute path to the package directory, or None if not found.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
from .config import get_packages
|
||||
|
||||
packages = get_packages(repo_root)
|
||||
if not packages or package not in packages:
|
||||
return None
|
||||
|
||||
info = packages[package]
|
||||
if isinstance(info, dict):
|
||||
rel_path = info.get("path", package)
|
||||
else:
|
||||
rel_path = str(info)
|
||||
|
||||
return repo_root / rel_path
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Entry (for testing)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
repo = get_repo_root()
|
||||
print(f"Repository root: {repo}")
|
||||
print(f"Developer: {get_developer(repo)}")
|
||||
print(f"Tasks dir: {get_tasks_dir(repo)}")
|
||||
print(f"Workspace dir: {get_workspace_dir(repo)}")
|
||||
print(f"Journal file: {get_active_journal_file(repo)}")
|
||||
print(f"Current task: {get_current_task(repo)}")
|
||||
315
.trellis/scripts/common/safe_commit.py
Executable file
315
.trellis/scripts/common/safe_commit.py
Executable file
@@ -0,0 +1,315 @@
|
||||
"""
|
||||
Safe git-add helpers for Trellis-owned paths.
|
||||
|
||||
Why this module exists
|
||||
----------------------
|
||||
A real user incident: a project's `.gitignore` listed `.trellis/` (company-wide
|
||||
template / personal habit). When `add_session.py` and `task.py archive` ran
|
||||
their auto-commit and `git add` failed with `ignored by .gitignore`, the AI
|
||||
agent driving the workflow "fixed" it by retrying with
|
||||
`git add -f .trellis/` — which fan-out-included every ignored subtree
|
||||
(`.trellis/.backup-*/`, `.trellis/worktrees/`, `.trellis/.template-hashes.json`,
|
||||
`.trellis/.runtime/`), committing 548 files / 83474 lines of caches/backups.
|
||||
|
||||
Design
|
||||
------
|
||||
- Scripts only stage SPECIFIC product paths (journal files, index.md, the
|
||||
current task dir, the archive dir). Never the whole `.trellis/` tree.
|
||||
- If plain `git add <specific>` fails with "ignored by", DO NOT retry with
|
||||
``-f``. The presence of `.trellis/` in `.gitignore` is treated as user
|
||||
intent ("keep .trellis/ local-only"). The script warns and skips the
|
||||
auto-commit; users who want auto-staging can either fix their `.gitignore`
|
||||
or set ``session_auto_commit: false`` and manage git themselves.
|
||||
- The warning includes a negative example: ``Do NOT use `git add -f .trellis/` ...``
|
||||
so any AI rereading the log doesn't reinvent the bug.
|
||||
|
||||
History note: 0.5.10 introduced an automatic ``git add -f`` retry on the
|
||||
specific paths. That was reverted in 0.5.11 — auto-forcing into a tree the
|
||||
user had gitignored violates user intent even when the path list is narrow.
|
||||
The wider-grain forbidden command stays forbidden, and the narrow-grain auto
|
||||
``-f`` is gone too.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .git import run_git
|
||||
from .paths import (
|
||||
DIR_ARCHIVE,
|
||||
DIR_TASKS,
|
||||
DIR_WORKFLOW,
|
||||
DIR_WORKSPACE,
|
||||
FILE_JOURNAL_PREFIX,
|
||||
get_developer,
|
||||
)
|
||||
|
||||
|
||||
# Paths under .trellis/ that must NEVER be auto-staged. Listed here so the
|
||||
# warning to the user can show concrete subpaths to ignore individually
|
||||
# instead of ignoring the whole `.trellis/` tree.
|
||||
TRELLIS_IGNORED_SUBPATHS = (
|
||||
".trellis/.backup-*",
|
||||
".trellis/worktrees/",
|
||||
".trellis/.template-hashes.json",
|
||||
".trellis/.runtime/",
|
||||
".trellis/.cache/",
|
||||
)
|
||||
|
||||
|
||||
def safe_trellis_paths_to_add(
|
||||
repo_root: Path,
|
||||
task_name: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Return the list of repo-relative paths the auto-commit should stage.
|
||||
|
||||
Only includes paths that exist on disk so callers don't pass non-existent
|
||||
arguments to git. The caller is responsible for `git diff --cached`
|
||||
checking afterwards.
|
||||
|
||||
Included:
|
||||
- .trellis/workspace/<developer>/journal-*.md
|
||||
- .trellis/workspace/<developer>/index.md
|
||||
- .trellis/tasks/<task_name>/ (ONLY the current task dir when
|
||||
``task_name`` is passed; plus its archive location if the task
|
||||
already lives under archive/)
|
||||
|
||||
Excluded (intentionally — these must not be staged):
|
||||
- .trellis/.backup-*, .trellis/worktrees/,
|
||||
.trellis/.template-hashes.json, .trellis/.runtime/, .trellis/.cache/
|
||||
|
||||
Scope contract (see #303 / break-loop analysis): when ``task_name`` is
|
||||
passed, the task segment stages ONLY that task directory — it never walks
|
||||
``tasks_dir.iterdir()`` over all active tasks. This mirrors
|
||||
:func:`safe_archive_paths_to_add` and prevents dirty changes in OTHER
|
||||
parallel-window task dirs from being bundled into the session auto-commit.
|
||||
|
||||
Backwards-compat: with no ``task_name``, the function walks every active
|
||||
task directory (+ the archive subtree) the old wide way. New callers
|
||||
should always pass ``task_name``.
|
||||
"""
|
||||
paths: list[str] = []
|
||||
|
||||
# Workspace journal files + index.md
|
||||
developer = get_developer(repo_root)
|
||||
if developer:
|
||||
ws = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer
|
||||
if ws.is_dir():
|
||||
for f in sorted(ws.glob(f"{FILE_JOURNAL_PREFIX}*.md")):
|
||||
if f.is_file():
|
||||
paths.append(
|
||||
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{f.name}"
|
||||
)
|
||||
index_md = ws / "index.md"
|
||||
if index_md.is_file():
|
||||
paths.append(
|
||||
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/index.md"
|
||||
)
|
||||
|
||||
tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS
|
||||
if not tasks_dir.is_dir():
|
||||
return paths
|
||||
|
||||
if task_name is not None:
|
||||
# Narrow scope — ONLY the current task directory (active or archived).
|
||||
# Never iterdir() all tasks: parallel-window dirty task dirs must not
|
||||
# leak into the session auto-commit.
|
||||
active_task = tasks_dir / task_name
|
||||
if active_task.is_dir():
|
||||
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}")
|
||||
archived_task = tasks_dir / DIR_ARCHIVE / task_name
|
||||
if archived_task.is_dir():
|
||||
paths.append(
|
||||
f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{task_name}"
|
||||
)
|
||||
return paths
|
||||
|
||||
# Legacy wide scope (no task_name): each direct child of tasks/ that is a
|
||||
# directory and not the archive root, plus the whole archive subtree.
|
||||
for child in sorted(tasks_dir.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
if child.name == DIR_ARCHIVE:
|
||||
continue
|
||||
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child.name}")
|
||||
|
||||
archive_dir = tasks_dir / DIR_ARCHIVE
|
||||
if archive_dir.is_dir():
|
||||
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}")
|
||||
|
||||
return paths
|
||||
|
||||
|
||||
def safe_archive_paths_to_add(
|
||||
repo_root: Path,
|
||||
task_name: str | None = None,
|
||||
modified_children: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Return paths to stage after `task.py archive`.
|
||||
|
||||
Scoped to ONLY the paths the archive operation actually touched:
|
||||
|
||||
- the archive subtree (where the freshly-moved task lives)
|
||||
- the source task directory (for source-side deletes; caller pairs
|
||||
this with `git rm --cached` since `git add` won't stage deletes
|
||||
for a path that no longer exists in the working tree)
|
||||
- any child task directories whose `task.json` was edited to drop
|
||||
the archived parent (parent-children relationship update)
|
||||
|
||||
This narrow scope avoids "scope creep" — dirty changes in OTHER
|
||||
active task dirs (parallel-window edits) are NOT bundled into the
|
||||
archive commit. Callers handle each kind of change in its own
|
||||
commit boundary.
|
||||
|
||||
Backwards-compat: with no arguments, the function walks the whole
|
||||
`.trellis/tasks/` subtree the old way (active tasks + archive). New
|
||||
callers should always pass `task_name`.
|
||||
"""
|
||||
paths: list[str] = []
|
||||
tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS
|
||||
if not tasks_dir.is_dir():
|
||||
return paths
|
||||
|
||||
archive_dir = tasks_dir / DIR_ARCHIVE
|
||||
|
||||
if task_name is not None:
|
||||
# Narrow scope — only paths that still exist on disk (so
|
||||
# `git add` doesn't choke on the moved-away source). The caller
|
||||
# handles the source-side deletes via `git rm --cached`
|
||||
# explicitly.
|
||||
if archive_dir.is_dir():
|
||||
paths.append(
|
||||
f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}"
|
||||
)
|
||||
for child_name in modified_children or []:
|
||||
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child_name}")
|
||||
return paths
|
||||
|
||||
# Legacy wide scope (no task_name): preserve old behavior so callers
|
||||
# that have not been updated keep working.
|
||||
if archive_dir.is_dir():
|
||||
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}")
|
||||
for child in sorted(tasks_dir.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
if child.name == DIR_ARCHIVE:
|
||||
continue
|
||||
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child.name}")
|
||||
return paths
|
||||
|
||||
|
||||
def _stderr_indicates_ignored(stderr: str) -> bool:
|
||||
"""git add error indicates the path is excluded by .gitignore."""
|
||||
if not stderr:
|
||||
return False
|
||||
lowered = stderr.lower()
|
||||
return "ignored by" in lowered
|
||||
|
||||
|
||||
def safe_git_add(
|
||||
paths: list[str], repo_root: Path
|
||||
) -> tuple[bool, bool, str]:
|
||||
"""Run `git add` on specific paths; never retry with -f.
|
||||
|
||||
Returns ``(success, used_force, stderr)``. The ``used_force`` field is
|
||||
kept for signature compatibility with the 0.5.10 implementation but is
|
||||
always ``False`` — we never auto-force.
|
||||
|
||||
Behavior:
|
||||
- No paths passed → success, no force, empty stderr.
|
||||
- Plain ``git add -- <paths>`` succeeds → return success.
|
||||
- Plain fails (any reason — ignored or otherwise) → return failure with
|
||||
the stderr. Callers should inspect the stderr (see
|
||||
:func:`print_gitignore_warning`) and skip the auto-commit.
|
||||
"""
|
||||
if not paths:
|
||||
return True, False, ""
|
||||
|
||||
rc, _, err = run_git(["add", "--", *paths], cwd=repo_root)
|
||||
if rc == 0:
|
||||
return True, False, ""
|
||||
return False, False, err
|
||||
|
||||
|
||||
def print_gitignore_warning(paths: list[str]) -> None:
|
||||
"""Explain to the user (and any AI reading the log) what to do.
|
||||
|
||||
CRITICAL: includes the negative example
|
||||
``Do NOT use `git add -f .trellis/``` — agents reading the warning are
|
||||
known to invent that command, which fans out to ignored caches/backups.
|
||||
"""
|
||||
print(
|
||||
"[WARN] git add failed because .trellis/ paths are ignored by your .gitignore.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] Skipping auto-commit. The journal/task files were still written to disk;",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] git was not touched.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("[WARN]", file=sys.stderr)
|
||||
print(
|
||||
"[WARN] Trellis manages these specific paths and they should be tracked:",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if paths:
|
||||
for p in paths:
|
||||
print(f"[WARN] {p}", file=sys.stderr)
|
||||
else:
|
||||
print(
|
||||
"[WARN] .trellis/workspace/<developer>/{journal-*.md,index.md}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] .trellis/tasks/<task-dir>/",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] .trellis/tasks/archive/",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("[WARN]", file=sys.stderr)
|
||||
print(
|
||||
"[WARN] Recommended: change your .gitignore from `.trellis/` to specific",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] subpaths that should remain ignored, e.g.:",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for sub in TRELLIS_IGNORED_SUBPATHS:
|
||||
print(f"[WARN] {sub}", file=sys.stderr)
|
||||
print("[WARN]", file=sys.stderr)
|
||||
print(
|
||||
"[WARN] Or, if you intentionally keep .trellis/ local-only, set in",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] .trellis/config.yaml:",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] session_auto_commit: false",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] so the scripts skip git entirely and you can review / commit",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] manually with `git status` / `git add` / `git commit`.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("[WARN]", file=sys.stderr)
|
||||
print(
|
||||
"[WARN] Do NOT use `git add -f .trellis/` — it pulls in backups, worktrees,",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] and runtime caches that should never be committed.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
821
.trellis/scripts/common/session_context.py
Executable file
821
.trellis/scripts/common/session_context.py
Executable file
@@ -0,0 +1,821 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Session context generation (default + record modes).
|
||||
|
||||
Provides:
|
||||
get_context_json - JSON output for default mode
|
||||
get_context_text - Text output for default mode
|
||||
get_context_record_json - JSON for record mode
|
||||
get_context_text_record - Text for record mode
|
||||
output_json - Print JSON
|
||||
output_text - Print text
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from .active_task import resolve_context_key
|
||||
from .config import get_git_packages
|
||||
from .git import run_git
|
||||
from .packages_context import get_packages_section
|
||||
from .tasks import iter_active_tasks, load_task, get_all_statuses, children_progress
|
||||
from .paths import (
|
||||
DIR_SCRIPTS,
|
||||
DIR_SPEC,
|
||||
DIR_TASKS,
|
||||
DIR_WORKFLOW,
|
||||
DIR_WORKSPACE,
|
||||
count_lines,
|
||||
get_active_journal_file,
|
||||
get_current_task,
|
||||
get_current_task_source,
|
||||
get_developer,
|
||||
get_repo_root,
|
||||
get_tasks_dir,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helpers
|
||||
# =============================================================================
|
||||
|
||||
_PACKAGE_NAME = "@mindfoldhq/trellis"
|
||||
_UPDATE_CHECK_TIMEOUT_SECONDS = 1.0
|
||||
_VERSION_RE = re.compile(
|
||||
r"^\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?\s*$"
|
||||
)
|
||||
_VERSION_TOKEN_RE = re.compile(r"\b\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?\b")
|
||||
_POLYREPO_IGNORED_DIRS = {
|
||||
"node_modules",
|
||||
"target",
|
||||
"dist",
|
||||
"build",
|
||||
"out",
|
||||
"bin",
|
||||
"obj",
|
||||
"vendor",
|
||||
"coverage",
|
||||
"tmp",
|
||||
"__pycache__",
|
||||
}
|
||||
_POLYREPO_SCAN_MAX_DEPTH = 2
|
||||
|
||||
|
||||
def _is_git_worktree(path: Path) -> bool:
|
||||
"""Return True when path is inside a Git worktree."""
|
||||
rc, out, _ = run_git(["rev-parse", "--is-inside-work-tree"], cwd=path)
|
||||
return rc == 0 and out.strip().lower() == "true"
|
||||
|
||||
|
||||
def _parse_recent_commits(log_output: str) -> list[dict]:
|
||||
"""Parse `git log --oneline` output into structured commit entries."""
|
||||
commits = []
|
||||
for line in log_output.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split(" ", 1)
|
||||
if len(parts) >= 2:
|
||||
commits.append({"hash": parts[0], "message": parts[1]})
|
||||
elif len(parts) == 1:
|
||||
commits.append({"hash": parts[0], "message": ""})
|
||||
return commits
|
||||
|
||||
|
||||
def _collect_git_repo_info(name: str, rel_path: str, repo_dir: Path) -> dict | None:
|
||||
"""Collect Git status for one known repository directory."""
|
||||
if not (repo_dir / ".git").exists():
|
||||
return None
|
||||
|
||||
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_dir)
|
||||
branch = branch_out.strip() or "unknown"
|
||||
|
||||
_, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_dir)
|
||||
changes = len([l for l in status_out.splitlines() if l.strip()])
|
||||
|
||||
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_dir)
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"path": rel_path,
|
||||
"branch": branch,
|
||||
"isClean": changes == 0,
|
||||
"uncommittedChanges": changes,
|
||||
"recentCommits": _parse_recent_commits(log_out),
|
||||
}
|
||||
|
||||
|
||||
def _collect_root_git_info(repo_root: Path) -> dict:
|
||||
"""Collect root Git info without pretending a non-Git root is clean."""
|
||||
if not _is_git_worktree(repo_root):
|
||||
return {
|
||||
"isRepo": False,
|
||||
"branch": "",
|
||||
"isClean": False,
|
||||
"uncommittedChanges": 0,
|
||||
"recentCommits": [],
|
||||
}
|
||||
|
||||
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
|
||||
branch = branch_out.strip() or "unknown"
|
||||
|
||||
_, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root)
|
||||
status_lines = [line for line in status_out.splitlines() if line.strip()]
|
||||
|
||||
_, short_out, _ = run_git(["status", "--short"], cwd=repo_root)
|
||||
|
||||
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root)
|
||||
|
||||
return {
|
||||
"isRepo": True,
|
||||
"branch": branch,
|
||||
"isClean": len(status_lines) == 0,
|
||||
"uncommittedChanges": len(status_lines),
|
||||
"statusShort": short_out.splitlines(),
|
||||
"recentCommits": _parse_recent_commits(log_out),
|
||||
}
|
||||
|
||||
|
||||
def _discover_child_git_repos(repo_root: Path) -> list[tuple[str, str]]:
|
||||
"""Discover child Git repositories using the init-time polyrepo heuristic."""
|
||||
found: list[str] = []
|
||||
|
||||
def is_candidate_dir(path: Path) -> bool:
|
||||
name = path.name
|
||||
return not name.startswith(".") and name not in _POLYREPO_IGNORED_DIRS
|
||||
|
||||
def scan(rel_dir: Path, depth: int) -> None:
|
||||
if depth >= _POLYREPO_SCAN_MAX_DEPTH:
|
||||
return
|
||||
abs_dir = repo_root / rel_dir
|
||||
try:
|
||||
children = sorted(abs_dir.iterdir(), key=lambda p: p.name)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
for child in children:
|
||||
if not child.is_dir() or not is_candidate_dir(child):
|
||||
continue
|
||||
|
||||
child_rel = (
|
||||
rel_dir / child.name if rel_dir != Path(".") else Path(child.name)
|
||||
)
|
||||
if (child / ".git").exists():
|
||||
found.append(child_rel.as_posix())
|
||||
continue
|
||||
scan(child_rel, depth + 1)
|
||||
|
||||
scan(Path("."), 0)
|
||||
if len(found) < 2:
|
||||
return []
|
||||
return [(path.replace("/", "_"), path) for path in sorted(found)]
|
||||
|
||||
|
||||
def _collect_package_git_info(
|
||||
repo_root: Path,
|
||||
discover_unconfigured: bool = False,
|
||||
) -> list[dict]:
|
||||
"""Collect Git status for independent package repositories.
|
||||
|
||||
Packages marked with ``git: true`` in config.yaml are authoritative.
|
||||
When the Trellis root is not a Git repo and no configured package repos are
|
||||
available, optionally fall back to the bounded polyrepo child scan.
|
||||
|
||||
Returns:
|
||||
List of dicts with keys: name, path, branch, isClean,
|
||||
uncommittedChanges, recentCommits.
|
||||
Empty list if no git-repo packages are configured.
|
||||
"""
|
||||
git_pkgs = get_git_packages(repo_root)
|
||||
result = []
|
||||
for pkg_name, pkg_path in git_pkgs.items():
|
||||
pkg_dir = repo_root / pkg_path
|
||||
info = _collect_git_repo_info(pkg_name, pkg_path, pkg_dir)
|
||||
if info is not None:
|
||||
result.append(info)
|
||||
|
||||
if result or not discover_unconfigured:
|
||||
return result
|
||||
|
||||
discovered = []
|
||||
for pkg_name, pkg_path in _discover_child_git_repos(repo_root):
|
||||
info = _collect_git_repo_info(pkg_name, pkg_path, repo_root / pkg_path)
|
||||
if info is not None:
|
||||
discovered.append(info)
|
||||
return discovered
|
||||
|
||||
|
||||
def _append_root_git_context(lines: list[str], root_git_info: dict) -> None:
|
||||
"""Append root Git status without misleading non-Git roots."""
|
||||
lines.append("## GIT STATUS")
|
||||
if not root_git_info["isRepo"]:
|
||||
lines.append("Root is not a Git repository.")
|
||||
lines.append("Run Git commands from the package repository paths listed below.")
|
||||
else:
|
||||
lines.append(f"Branch: {root_git_info['branch']}")
|
||||
if root_git_info["isClean"]:
|
||||
lines.append("Working directory: Clean")
|
||||
else:
|
||||
lines.append(
|
||||
f"Working directory: {root_git_info['uncommittedChanges']} "
|
||||
"uncommitted change(s)"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("Changes:")
|
||||
for line in root_git_info.get("statusShort", [])[:10]:
|
||||
lines.append(line)
|
||||
lines.append("")
|
||||
|
||||
lines.append("## RECENT COMMITS")
|
||||
if not root_git_info["isRepo"]:
|
||||
lines.append(
|
||||
"Root has no Git commit history because it is not a Git repository."
|
||||
)
|
||||
elif root_git_info["recentCommits"]:
|
||||
for commit in root_git_info["recentCommits"]:
|
||||
lines.append(f"{commit['hash']} {commit['message']}")
|
||||
else:
|
||||
lines.append("(no commits)")
|
||||
lines.append("")
|
||||
|
||||
|
||||
def _append_package_git_context(lines: list[str], package_git_info: list[dict]) -> None:
|
||||
"""Append Git status and recent commits for package repositories."""
|
||||
for pkg in package_git_info:
|
||||
lines.append(f"## GIT STATUS ({pkg['name']}: {pkg['path']})")
|
||||
lines.append(f"Branch: {pkg['branch']}")
|
||||
if pkg["isClean"]:
|
||||
lines.append("Working directory: Clean")
|
||||
else:
|
||||
lines.append(
|
||||
f"Working directory: {pkg['uncommittedChanges']} uncommitted change(s)"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(f"## RECENT COMMITS ({pkg['name']}: {pkg['path']})")
|
||||
if pkg["recentCommits"]:
|
||||
for commit in pkg["recentCommits"]:
|
||||
lines.append(f"{commit['hash']} {commit['message']}")
|
||||
else:
|
||||
lines.append("(no commits)")
|
||||
lines.append("")
|
||||
|
||||
|
||||
def _read_project_version(repo_root: Path) -> str | None:
|
||||
try:
|
||||
version = (repo_root / DIR_WORKFLOW / ".version").read_text(
|
||||
encoding="utf-8"
|
||||
).strip()
|
||||
except OSError:
|
||||
return None
|
||||
return version or None
|
||||
|
||||
|
||||
def _fetch_trellis_version_output() -> str | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["trellis", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=_UPDATE_CHECK_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError, TimeoutError):
|
||||
return None
|
||||
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
output = f"{result.stdout}\n{result.stderr}".strip()
|
||||
return output or None
|
||||
|
||||
|
||||
def _extract_available_update_version(output: str) -> str | None:
|
||||
update_match = re.search(
|
||||
r"Trellis update available:\s*"
|
||||
r"(?P<current>\S+)\s*(?:→|->)\s*(?P<latest>\S+)",
|
||||
output,
|
||||
)
|
||||
if update_match:
|
||||
return update_match.group("latest").strip()
|
||||
candidates = _VERSION_TOKEN_RE.findall(output)
|
||||
return candidates[-1] if candidates else None
|
||||
|
||||
|
||||
def _resolve_available_update_version() -> str | None:
|
||||
output = _fetch_trellis_version_output()
|
||||
if not output:
|
||||
return None
|
||||
return _extract_available_update_version(output)
|
||||
|
||||
|
||||
def _parse_version(version: str) -> tuple[tuple[int, int, int], tuple[str, ...] | None] | None:
|
||||
match = _VERSION_RE.match(version)
|
||||
if not match:
|
||||
return None
|
||||
major, minor, patch, prerelease = match.groups()
|
||||
numbers = (int(major), int(minor or "0"), int(patch or "0"))
|
||||
prerelease_parts = tuple(prerelease.split(".")) if prerelease else None
|
||||
return numbers, prerelease_parts
|
||||
|
||||
|
||||
def _compare_prerelease(
|
||||
left: tuple[str, ...] | None,
|
||||
right: tuple[str, ...] | None,
|
||||
) -> int:
|
||||
if left is None and right is None:
|
||||
return 0
|
||||
if left is None:
|
||||
return 1
|
||||
if right is None:
|
||||
return -1
|
||||
|
||||
for left_part, right_part in zip(left, right):
|
||||
if left_part == right_part:
|
||||
continue
|
||||
left_numeric = left_part.isdigit()
|
||||
right_numeric = right_part.isdigit()
|
||||
if left_numeric and right_numeric:
|
||||
left_int = int(left_part)
|
||||
right_int = int(right_part)
|
||||
return (left_int > right_int) - (left_int < right_int)
|
||||
if left_numeric:
|
||||
return -1
|
||||
if right_numeric:
|
||||
return 1
|
||||
return (left_part > right_part) - (left_part < right_part)
|
||||
|
||||
return (len(left) > len(right)) - (len(left) < len(right))
|
||||
|
||||
|
||||
def _compare_versions(left: str, right: str) -> int | None:
|
||||
parsed_left = _parse_version(left)
|
||||
parsed_right = _parse_version(right)
|
||||
if parsed_left is None or parsed_right is None:
|
||||
return None
|
||||
|
||||
left_numbers, left_prerelease = parsed_left
|
||||
right_numbers, right_prerelease = parsed_right
|
||||
if left_numbers != right_numbers:
|
||||
return (left_numbers > right_numbers) - (left_numbers < right_numbers)
|
||||
return _compare_prerelease(left_prerelease, right_prerelease)
|
||||
|
||||
|
||||
def _update_marker_path(repo_root: Path) -> Path:
|
||||
context_key = resolve_context_key()
|
||||
if not context_key:
|
||||
terminal_key = os.environ.get("TERM_SESSION_ID", "").strip()
|
||||
context_key = terminal_key or f"ppid-{os.getppid()}"
|
||||
safe_key = re.sub(r"[^A-Za-z0-9._-]+", "_", context_key).strip("._-")
|
||||
if not safe_key:
|
||||
safe_key = "session"
|
||||
return (
|
||||
repo_root
|
||||
/ DIR_WORKFLOW
|
||||
/ ".runtime"
|
||||
/ f"update-check-{safe_key[:160]}.marker"
|
||||
)
|
||||
|
||||
|
||||
def _mark_update_check_attempted(repo_root: Path) -> bool:
|
||||
marker_path = _update_marker_path(repo_root)
|
||||
if marker_path.exists():
|
||||
return False
|
||||
try:
|
||||
marker_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
marker_path.write_text("checked\n", encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def _get_update_hint(repo_root: Path) -> str | None:
|
||||
marker_path = _update_marker_path(repo_root)
|
||||
if marker_path.exists():
|
||||
return None
|
||||
|
||||
current_version = _read_project_version(repo_root)
|
||||
if not current_version:
|
||||
return None
|
||||
|
||||
latest_version = _resolve_available_update_version()
|
||||
if not latest_version:
|
||||
return None
|
||||
|
||||
_mark_update_check_attempted(repo_root)
|
||||
comparison = _compare_versions(current_version, latest_version)
|
||||
if comparison is None or comparison >= 0:
|
||||
return None
|
||||
|
||||
return (
|
||||
f"Trellis update available: {current_version} -> {latest_version}, "
|
||||
"run trellis upgrade"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# JSON Output
|
||||
# =============================================================================
|
||||
|
||||
def get_context_json(repo_root: Path | None = None) -> dict:
|
||||
"""Get context as a dictionary.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Context dictionary.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
journal_file = get_active_journal_file(repo_root)
|
||||
|
||||
journal_lines = 0
|
||||
journal_relative = ""
|
||||
if journal_file and developer:
|
||||
journal_lines = count_lines(journal_file)
|
||||
journal_relative = (
|
||||
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}"
|
||||
)
|
||||
|
||||
root_git_info = _collect_root_git_info(repo_root)
|
||||
|
||||
# Tasks
|
||||
tasks = [
|
||||
{
|
||||
"dir": t.dir_name,
|
||||
"name": t.name,
|
||||
"status": t.status,
|
||||
"children": list(t.children),
|
||||
"parent": t.parent,
|
||||
}
|
||||
for t in iter_active_tasks(tasks_dir)
|
||||
]
|
||||
|
||||
# Package git repos (independent sub-repositories)
|
||||
pkg_git_info = _collect_package_git_info(
|
||||
repo_root,
|
||||
discover_unconfigured=not root_git_info["isRepo"],
|
||||
)
|
||||
|
||||
result = {
|
||||
"developer": developer or "",
|
||||
"git": {
|
||||
"isRepo": root_git_info["isRepo"],
|
||||
"branch": root_git_info["branch"],
|
||||
"isClean": root_git_info["isClean"],
|
||||
"uncommittedChanges": root_git_info["uncommittedChanges"],
|
||||
"recentCommits": root_git_info["recentCommits"],
|
||||
},
|
||||
"tasks": {
|
||||
"active": tasks,
|
||||
"directory": f"{DIR_WORKFLOW}/{DIR_TASKS}",
|
||||
},
|
||||
"journal": {
|
||||
"file": journal_relative,
|
||||
"lines": journal_lines,
|
||||
"nearLimit": journal_lines > 1800,
|
||||
},
|
||||
}
|
||||
|
||||
if pkg_git_info:
|
||||
result["packageGit"] = pkg_git_info
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def output_json(repo_root: Path | None = None) -> None:
|
||||
"""Output context in JSON format.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
"""
|
||||
context = get_context_json(repo_root)
|
||||
print(json.dumps(context, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Text Output
|
||||
# =============================================================================
|
||||
|
||||
def get_context_text(repo_root: Path | None = None) -> str:
|
||||
"""Get context as formatted text.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Formatted text output.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
lines = []
|
||||
lines.append("========================================")
|
||||
lines.append("SESSION CONTEXT")
|
||||
lines.append("========================================")
|
||||
lines.append("")
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
|
||||
# Developer section
|
||||
lines.append("## DEVELOPER")
|
||||
if not developer:
|
||||
lines.append(
|
||||
f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
lines.append(f"Name: {developer}")
|
||||
lines.append("")
|
||||
|
||||
root_git_info = _collect_root_git_info(repo_root)
|
||||
_append_root_git_context(lines, root_git_info)
|
||||
|
||||
# Package git repos — independent sub-repositories
|
||||
_append_package_git_context(
|
||||
lines,
|
||||
_collect_package_git_info(
|
||||
repo_root,
|
||||
discover_unconfigured=not root_git_info["isRepo"],
|
||||
),
|
||||
)
|
||||
|
||||
# Current task
|
||||
lines.append("## CURRENT TASK")
|
||||
current_task = get_current_task(repo_root)
|
||||
if current_task:
|
||||
current_task_dir = repo_root / current_task
|
||||
source_type, context_key, _ = get_current_task_source(repo_root)
|
||||
lines.append(f"Path: {current_task}")
|
||||
lines.append(
|
||||
f"Source: {source_type}" + (f":{context_key}" if context_key else "")
|
||||
)
|
||||
|
||||
ct = load_task(current_task_dir)
|
||||
if ct:
|
||||
lines.append(f"Name: {ct.name}")
|
||||
lines.append(f"Status: {ct.status}")
|
||||
lines.append(f"Created: {ct.raw.get('createdAt', 'unknown')}")
|
||||
if ct.description:
|
||||
lines.append(f"Description: {ct.description}")
|
||||
|
||||
# Check for prd.md
|
||||
prd_file = current_task_dir / "prd.md"
|
||||
if prd_file.is_file():
|
||||
lines.append("")
|
||||
lines.append("[!] This task has prd.md - read it for task details")
|
||||
else:
|
||||
lines.append("(none)")
|
||||
lines.append("")
|
||||
|
||||
# Active tasks
|
||||
lines.append("## ACTIVE TASKS")
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
task_count = 0
|
||||
|
||||
# Collect all task data for hierarchy display
|
||||
all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)}
|
||||
all_statuses = {name: t.status for name, t in all_tasks.items()}
|
||||
|
||||
def _print_task_tree(name: str, indent: int = 0) -> None:
|
||||
nonlocal task_count
|
||||
t = all_tasks[name]
|
||||
progress = children_progress(t.children, all_statuses)
|
||||
prefix = " " * indent
|
||||
lines.append(f"{prefix}- {name}/ ({t.status}){progress} @{t.assignee or '-'}")
|
||||
task_count += 1
|
||||
for child in t.children:
|
||||
if child in all_tasks:
|
||||
_print_task_tree(child, indent + 1)
|
||||
|
||||
for dir_name in sorted(all_tasks.keys()):
|
||||
if not all_tasks[dir_name].parent:
|
||||
_print_task_tree(dir_name)
|
||||
|
||||
if task_count == 0:
|
||||
lines.append("(no active tasks)")
|
||||
lines.append(f"Total: {task_count} active task(s)")
|
||||
lines.append("")
|
||||
|
||||
# My tasks
|
||||
lines.append("## MY TASKS (Assigned to me)")
|
||||
my_task_count = 0
|
||||
|
||||
for t in all_tasks.values():
|
||||
if t.assignee == developer and t.status != "done":
|
||||
progress = children_progress(t.children, all_statuses)
|
||||
lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress}")
|
||||
my_task_count += 1
|
||||
|
||||
if my_task_count == 0:
|
||||
lines.append("(no tasks assigned to you)")
|
||||
lines.append("")
|
||||
|
||||
# Journal file
|
||||
lines.append("## JOURNAL FILE")
|
||||
journal_file = get_active_journal_file(repo_root)
|
||||
if journal_file:
|
||||
journal_lines = count_lines(journal_file)
|
||||
relative = f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}"
|
||||
lines.append(f"Active file: {relative}")
|
||||
lines.append(f"Line count: {journal_lines} / 2000")
|
||||
if journal_lines > 1800:
|
||||
lines.append("[!] WARNING: Approaching 2000 line limit!")
|
||||
else:
|
||||
lines.append("No journal file found")
|
||||
lines.append("")
|
||||
|
||||
# Packages
|
||||
packages_text = get_packages_section(repo_root)
|
||||
if packages_text:
|
||||
lines.append(packages_text)
|
||||
lines.append("")
|
||||
|
||||
# Paths
|
||||
lines.append("## PATHS")
|
||||
lines.append(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/")
|
||||
lines.append(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/")
|
||||
lines.append(f"Spec: {DIR_WORKFLOW}/{DIR_SPEC}/")
|
||||
lines.append("")
|
||||
|
||||
lines.append("========================================")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Record Mode
|
||||
# =============================================================================
|
||||
|
||||
def get_context_record_json(repo_root: Path | None = None) -> dict:
|
||||
"""Get record-mode context as a dictionary.
|
||||
|
||||
Focused on: my active tasks, git status, current task.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
|
||||
root_git_info = _collect_root_git_info(repo_root)
|
||||
|
||||
# My tasks (single pass — collect statuses and filter by assignee)
|
||||
all_tasks_list = list(iter_active_tasks(tasks_dir))
|
||||
all_statuses = {t.dir_name: t.status for t in all_tasks_list}
|
||||
|
||||
my_tasks = []
|
||||
for t in all_tasks_list:
|
||||
if t.assignee == developer:
|
||||
done = sum(
|
||||
1 for c in t.children
|
||||
if all_statuses.get(c) in ("completed", "done")
|
||||
)
|
||||
my_tasks.append({
|
||||
"dir": t.dir_name,
|
||||
"title": t.title,
|
||||
"status": t.status,
|
||||
"priority": t.priority,
|
||||
"children": list(t.children),
|
||||
"childrenDone": done,
|
||||
"parent": t.parent,
|
||||
"meta": t.meta,
|
||||
})
|
||||
|
||||
# Current task
|
||||
current_task_info = None
|
||||
current_task = get_current_task(repo_root)
|
||||
if current_task:
|
||||
source_type, context_key, _ = get_current_task_source(repo_root)
|
||||
ct = load_task(repo_root / current_task)
|
||||
if ct:
|
||||
current_task_info = {
|
||||
"path": current_task,
|
||||
"name": ct.name,
|
||||
"status": ct.status,
|
||||
"source": source_type,
|
||||
"contextKey": context_key,
|
||||
}
|
||||
|
||||
# Package git repos
|
||||
pkg_git_info = _collect_package_git_info(
|
||||
repo_root,
|
||||
discover_unconfigured=not root_git_info["isRepo"],
|
||||
)
|
||||
|
||||
result = {
|
||||
"developer": developer or "",
|
||||
"git": {
|
||||
"isRepo": root_git_info["isRepo"],
|
||||
"branch": root_git_info["branch"],
|
||||
"isClean": root_git_info["isClean"],
|
||||
"uncommittedChanges": root_git_info["uncommittedChanges"],
|
||||
"recentCommits": root_git_info["recentCommits"],
|
||||
},
|
||||
"myTasks": my_tasks,
|
||||
"currentTask": current_task_info,
|
||||
}
|
||||
|
||||
if pkg_git_info:
|
||||
result["packageGit"] = pkg_git_info
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_context_text_record(repo_root: Path | None = None) -> str:
|
||||
"""Get context as formatted text for record-session mode.
|
||||
|
||||
Focused output: MY ACTIVE TASKS first (with [!!!] emphasis),
|
||||
then GIT STATUS, RECENT COMMITS, CURRENT TASK.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("========================================")
|
||||
lines.append("SESSION CONTEXT (RECORD MODE)")
|
||||
lines.append("========================================")
|
||||
lines.append("")
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
if not developer:
|
||||
lines.append(
|
||||
f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
# MY ACTIVE TASKS — first and prominent
|
||||
lines.append(f"## [!!!] MY ACTIVE TASKS (Assigned to {developer})")
|
||||
lines.append("[!] Review whether any should be archived before recording this session.")
|
||||
lines.append("")
|
||||
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
my_task_count = 0
|
||||
|
||||
# Single pass — collect all tasks and filter by assignee
|
||||
all_statuses = get_all_statuses(tasks_dir)
|
||||
|
||||
for t in iter_active_tasks(tasks_dir):
|
||||
if t.assignee == developer:
|
||||
progress = children_progress(t.children, all_statuses)
|
||||
lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress} — {t.dir_name}")
|
||||
my_task_count += 1
|
||||
|
||||
if my_task_count == 0:
|
||||
lines.append("(no active tasks assigned to you)")
|
||||
lines.append("")
|
||||
|
||||
root_git_info = _collect_root_git_info(repo_root)
|
||||
_append_root_git_context(lines, root_git_info)
|
||||
|
||||
# Package git repos — independent sub-repositories
|
||||
_append_package_git_context(
|
||||
lines,
|
||||
_collect_package_git_info(
|
||||
repo_root,
|
||||
discover_unconfigured=not root_git_info["isRepo"],
|
||||
),
|
||||
)
|
||||
|
||||
# CURRENT TASK
|
||||
lines.append("## CURRENT TASK")
|
||||
current_task = get_current_task(repo_root)
|
||||
if current_task:
|
||||
source_type, context_key, _ = get_current_task_source(repo_root)
|
||||
lines.append(f"Path: {current_task}")
|
||||
lines.append(
|
||||
f"Source: {source_type}" + (f":{context_key}" if context_key else "")
|
||||
)
|
||||
ct = load_task(repo_root / current_task)
|
||||
if ct:
|
||||
lines.append(f"Name: {ct.name}")
|
||||
lines.append(f"Status: {ct.status}")
|
||||
else:
|
||||
lines.append("(none)")
|
||||
lines.append("")
|
||||
|
||||
lines.append("========================================")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def output_text(repo_root: Path | None = None) -> None:
|
||||
"""Output context in text format.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
update_hint = _get_update_hint(repo_root)
|
||||
if update_hint:
|
||||
print(update_hint)
|
||||
print("")
|
||||
print(get_context_text(repo_root))
|
||||
223
.trellis/scripts/common/task_context.py
Executable file
223
.trellis/scripts/common/task_context.py
Executable file
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Task JSONL context management.
|
||||
|
||||
Provides:
|
||||
cmd_add_context - Add entry to JSONL context file
|
||||
cmd_validate - Validate JSONL context files
|
||||
cmd_list_context - List JSONL context entries
|
||||
|
||||
Note:
|
||||
``cmd_init_context`` was removed in v0.5.0-beta.12. JSONL context files
|
||||
are now seeded at ``task.py create`` time with a self-describing
|
||||
``_example`` line; the AI agent curates real entries during planning when
|
||||
the task needs sub-agent/spec context. See ``.trellis/workflow.md`` for the
|
||||
current planning artifact contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .log import Colors, colored
|
||||
from .paths import get_repo_root
|
||||
from .task_utils import resolve_task_dir
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: add-context
|
||||
# =============================================================================
|
||||
|
||||
def cmd_add_context(args: argparse.Namespace) -> int:
|
||||
"""Add entry to JSONL context file."""
|
||||
repo_root = get_repo_root()
|
||||
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||
|
||||
jsonl_name = args.file
|
||||
path = args.path
|
||||
reason = args.reason or "Added manually"
|
||||
|
||||
if not target_dir.is_dir():
|
||||
print(colored(f"Error: Directory not found: {target_dir}", Colors.RED))
|
||||
return 1
|
||||
|
||||
# Support shorthand
|
||||
if not jsonl_name.endswith(".jsonl"):
|
||||
jsonl_name = f"{jsonl_name}.jsonl"
|
||||
|
||||
jsonl_file = target_dir / jsonl_name
|
||||
full_path = repo_root / path
|
||||
|
||||
entry_type = "file"
|
||||
if full_path.is_dir():
|
||||
entry_type = "directory"
|
||||
if not path.endswith("/"):
|
||||
path = f"{path}/"
|
||||
elif not full_path.is_file():
|
||||
print(colored(f"Error: Path not found: {path}", Colors.RED))
|
||||
return 1
|
||||
|
||||
# Check if already exists
|
||||
if jsonl_file.is_file():
|
||||
content = jsonl_file.read_text(encoding="utf-8")
|
||||
if f'"{path}"' in content:
|
||||
print(colored(f"Warning: Entry already exists for {path}", Colors.YELLOW))
|
||||
return 0
|
||||
|
||||
# Add entry
|
||||
entry: dict
|
||||
if entry_type == "directory":
|
||||
entry = {"file": path, "type": "directory", "reason": reason}
|
||||
else:
|
||||
entry = {"file": path, "reason": reason}
|
||||
|
||||
with jsonl_file.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
|
||||
print(colored(f"Added {entry_type}: {path}", Colors.GREEN))
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: validate
|
||||
# =============================================================================
|
||||
|
||||
def cmd_validate(args: argparse.Namespace) -> int:
|
||||
"""Validate JSONL context files."""
|
||||
repo_root = get_repo_root()
|
||||
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||
|
||||
if not target_dir.is_dir():
|
||||
print(colored("Error: task directory required", Colors.RED))
|
||||
return 1
|
||||
|
||||
print(colored("=== Validating Context Files ===", Colors.BLUE))
|
||||
print(f"Target dir: {target_dir}")
|
||||
print()
|
||||
|
||||
total_errors = 0
|
||||
for jsonl_name in ["implement.jsonl", "check.jsonl"]:
|
||||
jsonl_file = target_dir / jsonl_name
|
||||
errors = _validate_jsonl(jsonl_file, repo_root)
|
||||
total_errors += errors
|
||||
|
||||
print()
|
||||
if total_errors == 0:
|
||||
print(colored("✓ All validations passed", Colors.GREEN))
|
||||
return 0
|
||||
else:
|
||||
print(colored(f"✗ Validation failed ({total_errors} errors)", Colors.RED))
|
||||
return 1
|
||||
|
||||
|
||||
def _validate_jsonl(jsonl_file: Path, repo_root: Path) -> int:
|
||||
"""Validate a single JSONL file.
|
||||
|
||||
Seed rows (no ``file`` field — typically ``{"_example": "..."}``) are
|
||||
skipped silently; they are self-describing comments, not real entries.
|
||||
"""
|
||||
file_name = jsonl_file.name
|
||||
errors = 0
|
||||
|
||||
if not jsonl_file.is_file():
|
||||
print(f" {colored(f'{file_name}: not found (skipped)', Colors.YELLOW)}")
|
||||
return 0
|
||||
|
||||
line_num = 0
|
||||
real_entries = 0
|
||||
for line in jsonl_file.read_text(encoding="utf-8").splitlines():
|
||||
line_num += 1
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
print(f" {colored(f'{file_name}:{line_num}: Invalid JSON', Colors.RED)}")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
file_path = data.get("file")
|
||||
entry_type = data.get("type", "file")
|
||||
|
||||
if not file_path:
|
||||
# Seed / comment row — skip silently
|
||||
continue
|
||||
|
||||
real_entries += 1
|
||||
full_path = repo_root / file_path
|
||||
if entry_type == "directory":
|
||||
if not full_path.is_dir():
|
||||
print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}")
|
||||
errors += 1
|
||||
else:
|
||||
if not full_path.is_file():
|
||||
print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}")
|
||||
errors += 1
|
||||
|
||||
if errors == 0:
|
||||
print(f" {colored(f'{file_name}: ✓ ({real_entries} entries)', Colors.GREEN)}")
|
||||
else:
|
||||
print(f" {colored(f'{file_name}: ✗ ({errors} errors)', Colors.RED)}")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: list-context
|
||||
# =============================================================================
|
||||
|
||||
def cmd_list_context(args: argparse.Namespace) -> int:
|
||||
"""List JSONL context entries."""
|
||||
repo_root = get_repo_root()
|
||||
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||
|
||||
if not target_dir.is_dir():
|
||||
print(colored("Error: task directory required", Colors.RED))
|
||||
return 1
|
||||
|
||||
print(colored("=== Context Files ===", Colors.BLUE))
|
||||
print()
|
||||
|
||||
for jsonl_name in ["implement.jsonl", "check.jsonl"]:
|
||||
jsonl_file = target_dir / jsonl_name
|
||||
if not jsonl_file.is_file():
|
||||
continue
|
||||
|
||||
print(colored(f"[{jsonl_name}]", Colors.CYAN))
|
||||
|
||||
count = 0
|
||||
seed_only = True
|
||||
for line in jsonl_file.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
file_path = data.get("file")
|
||||
if not file_path:
|
||||
# Seed / comment row — don't count as a real entry
|
||||
continue
|
||||
seed_only = False
|
||||
|
||||
count += 1
|
||||
entry_type = data.get("type", "file")
|
||||
reason = data.get("reason", "-")
|
||||
|
||||
if entry_type == "directory":
|
||||
print(f" {colored(f'{count}.', Colors.GREEN)} [DIR] {file_path}")
|
||||
else:
|
||||
print(f" {colored(f'{count}.', Colors.GREEN)} {file_path}")
|
||||
print(f" {colored('→', Colors.YELLOW)} {reason}")
|
||||
|
||||
if seed_only:
|
||||
print(f" {colored('(no curated entries yet — only seed row)', Colors.YELLOW)}")
|
||||
|
||||
print()
|
||||
|
||||
return 0
|
||||
188
.trellis/scripts/common/task_queue.py
Executable file
188
.trellis/scripts/common/task_queue.py
Executable file
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Task queue utility functions.
|
||||
|
||||
Provides:
|
||||
list_tasks_by_status - List tasks by status
|
||||
list_pending_tasks - List tasks with pending status
|
||||
list_tasks_by_assignee - List tasks by assignee
|
||||
list_my_tasks - List tasks assigned to current developer
|
||||
get_task_stats - Get P0/P1/P2/P3 counts
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import (
|
||||
get_repo_root,
|
||||
get_developer,
|
||||
get_tasks_dir,
|
||||
)
|
||||
from .tasks import iter_active_tasks
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internal helper
|
||||
# =============================================================================
|
||||
|
||||
def _task_to_dict(t) -> dict:
|
||||
"""Convert TaskInfo to the dict format callers expect."""
|
||||
return {
|
||||
"priority": t.priority,
|
||||
"id": t.raw.get("id", ""),
|
||||
"title": t.title,
|
||||
"status": t.status,
|
||||
"assignee": t.assignee or "-",
|
||||
"dir": t.dir_name,
|
||||
"children": list(t.children),
|
||||
"parent": t.parent,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Public Functions
|
||||
# =============================================================================
|
||||
|
||||
def list_tasks_by_status(
|
||||
filter_status: str | None = None,
|
||||
repo_root: Path | None = None
|
||||
) -> list[dict]:
|
||||
"""List tasks by status.
|
||||
|
||||
Args:
|
||||
filter_status: Optional status filter.
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
List of task info dicts with keys: priority, id, title, status, assignee.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
results = []
|
||||
|
||||
for t in iter_active_tasks(tasks_dir):
|
||||
if filter_status and t.status != filter_status:
|
||||
continue
|
||||
results.append(_task_to_dict(t))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def list_pending_tasks(repo_root: Path | None = None) -> list[dict]:
|
||||
"""List pending tasks.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
List of task info dicts.
|
||||
"""
|
||||
return list_tasks_by_status("planning", repo_root)
|
||||
|
||||
|
||||
def list_tasks_by_assignee(
|
||||
assignee: str,
|
||||
filter_status: str | None = None,
|
||||
repo_root: Path | None = None
|
||||
) -> list[dict]:
|
||||
"""List tasks assigned to a specific developer.
|
||||
|
||||
Args:
|
||||
assignee: Developer name.
|
||||
filter_status: Optional status filter.
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
List of task info dicts.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
results = []
|
||||
|
||||
for t in iter_active_tasks(tasks_dir):
|
||||
if (t.assignee or "-") != assignee:
|
||||
continue
|
||||
if filter_status and t.status != filter_status:
|
||||
continue
|
||||
results.append(_task_to_dict(t))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def list_my_tasks(
|
||||
filter_status: str | None = None,
|
||||
repo_root: Path | None = None
|
||||
) -> list[dict]:
|
||||
"""List tasks assigned to current developer.
|
||||
|
||||
Args:
|
||||
filter_status: Optional status filter.
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
List of task info dicts.
|
||||
|
||||
Raises:
|
||||
ValueError: If developer not set.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
if not developer:
|
||||
raise ValueError("Developer not set")
|
||||
|
||||
return list_tasks_by_assignee(developer, filter_status, repo_root)
|
||||
|
||||
|
||||
def get_task_stats(repo_root: Path | None = None) -> dict[str, int]:
|
||||
"""Get task statistics.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Dict with keys: P0, P1, P2, P3, Total.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
stats = {"P0": 0, "P1": 0, "P2": 0, "P3": 0, "Total": 0}
|
||||
|
||||
for t in iter_active_tasks(tasks_dir):
|
||||
if t.priority in stats:
|
||||
stats[t.priority] += 1
|
||||
stats["Total"] += 1
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def format_task_stats(stats: dict[str, int]) -> str:
|
||||
"""Format task stats as string.
|
||||
|
||||
Args:
|
||||
stats: Stats dict from get_task_stats.
|
||||
|
||||
Returns:
|
||||
Formatted string like "P0:0 P1:1 P2:2 P3:0 Total:3".
|
||||
"""
|
||||
return f"P0:{stats['P0']} P1:{stats['P1']} P2:{stats['P2']} P3:{stats['P3']} Total:{stats['Total']}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Entry (for testing)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
stats = get_task_stats()
|
||||
print(format_task_stats(stats))
|
||||
print()
|
||||
print("Pending tasks:")
|
||||
for task in list_pending_tasks():
|
||||
print(f" {task['priority']}|{task['id']}|{task['title']}|{task['status']}|{task['assignee']}")
|
||||
747
.trellis/scripts/common/task_store.py
Executable file
747
.trellis/scripts/common/task_store.py
Executable file
@@ -0,0 +1,747 @@
|
||||
#!/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
|
||||
".trae", # Trae IDE
|
||||
)
|
||||
|
||||
_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
|
||||
274
.trellis/scripts/common/task_utils.py
Executable file
274
.trellis/scripts/common/task_utils.py
Executable file
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Task utility functions.
|
||||
|
||||
Provides:
|
||||
is_safe_task_path - Validate task path is safe to operate on
|
||||
find_task_by_name - Find task directory by name
|
||||
resolve_task_dir - Resolve task directory from name, relative, or absolute path
|
||||
archive_task_dir - Archive task to monthly directory
|
||||
run_task_hooks - Run lifecycle hooks for task events
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import get_repo_root, get_tasks_dir
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Path Safety
|
||||
# =============================================================================
|
||||
|
||||
def is_safe_task_path(task_path: str, repo_root: Path | None = None) -> bool:
|
||||
"""Check if a relative task path is safe to operate on.
|
||||
|
||||
Args:
|
||||
task_path: Task path (relative to repo_root).
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
True if safe, False if dangerous.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
normalized = task_path.replace("\\", "/")
|
||||
|
||||
# Check empty or null
|
||||
if not normalized or normalized == "null":
|
||||
print("Error: empty or null task path", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Reject absolute paths
|
||||
if Path(task_path).is_absolute():
|
||||
print(f"Error: absolute path not allowed: {task_path}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Reject ".", "..", paths starting with "./" or "../", or containing ".."
|
||||
if normalized in (".", "..") or normalized.startswith("./") or normalized.startswith("../") or ".." in normalized:
|
||||
print(f"Error: path traversal not allowed: {task_path}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Final check: ensure resolved path is not the repo root
|
||||
abs_path = repo_root / Path(normalized)
|
||||
if abs_path.exists():
|
||||
try:
|
||||
resolved = abs_path.resolve()
|
||||
root_resolved = repo_root.resolve()
|
||||
if resolved == root_resolved:
|
||||
print(f"Error: path resolves to repo root: {task_path}", file=sys.stderr)
|
||||
return False
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Task Lookup
|
||||
# =============================================================================
|
||||
|
||||
def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None:
|
||||
"""Find task directory by name (exact or suffix match).
|
||||
|
||||
Args:
|
||||
task_name: Task name to find.
|
||||
tasks_dir: Tasks directory path.
|
||||
|
||||
Returns:
|
||||
Absolute path to task directory, or None if not found.
|
||||
"""
|
||||
if not task_name or not tasks_dir or not tasks_dir.is_dir():
|
||||
return None
|
||||
|
||||
# Try exact match first
|
||||
exact_match = tasks_dir / task_name
|
||||
if exact_match.is_dir():
|
||||
return exact_match
|
||||
|
||||
# Try suffix match (e.g., "my-task" matches "01-21-my-task")
|
||||
for d in tasks_dir.iterdir():
|
||||
if d.is_dir() and d.name.endswith(f"-{task_name}"):
|
||||
return d
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Archive Operations
|
||||
# =============================================================================
|
||||
|
||||
def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path | None:
|
||||
"""Archive a task directory to archive/{YYYY-MM}/.
|
||||
|
||||
Args:
|
||||
task_dir_abs: Absolute path to task directory.
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Path to archived directory, or None on error.
|
||||
"""
|
||||
if not task_dir_abs.is_dir():
|
||||
print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
# Get tasks directory (parent of the task)
|
||||
tasks_dir = task_dir_abs.parent
|
||||
archive_dir = tasks_dir / "archive"
|
||||
year_month = datetime.now().strftime("%Y-%m")
|
||||
month_dir = archive_dir / year_month
|
||||
|
||||
# Create archive directory
|
||||
try:
|
||||
month_dir.mkdir(parents=True, exist_ok=True)
|
||||
except (OSError, IOError) as e:
|
||||
print(f"Error: Failed to create archive directory: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
# Move task to archive
|
||||
task_name = task_dir_abs.name
|
||||
dest = month_dir / task_name
|
||||
|
||||
try:
|
||||
shutil.move(str(task_dir_abs), str(dest))
|
||||
except (OSError, IOError, shutil.Error) as e:
|
||||
print(f"Error: Failed to move task to archive: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
return dest
|
||||
|
||||
|
||||
def archive_task_complete(
|
||||
task_dir_abs: Path,
|
||||
repo_root: Path | None = None
|
||||
) -> dict[str, str]:
|
||||
"""Complete archive workflow: archive directory.
|
||||
|
||||
Args:
|
||||
task_dir_abs: Absolute path to task directory.
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Dict with archive result info.
|
||||
"""
|
||||
if not task_dir_abs.is_dir():
|
||||
print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr)
|
||||
return {}
|
||||
|
||||
archive_dest = archive_task_dir(task_dir_abs, repo_root)
|
||||
if archive_dest:
|
||||
return {"archived_to": str(archive_dest)}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Task Directory Resolution
|
||||
# =============================================================================
|
||||
|
||||
def resolve_task_dir(target_dir: str, repo_root: Path) -> Path:
|
||||
"""Resolve task directory to absolute path.
|
||||
|
||||
Supports:
|
||||
- Absolute path: /path/to/task
|
||||
- Relative path: .trellis/tasks/01-31-my-task
|
||||
- Task name: my-task (uses find_task_by_name for lookup)
|
||||
|
||||
Args:
|
||||
target_dir: Task directory specification.
|
||||
repo_root: Repository root path.
|
||||
|
||||
Returns:
|
||||
Resolved absolute path.
|
||||
"""
|
||||
if not target_dir:
|
||||
return Path()
|
||||
|
||||
normalized = target_dir.replace("\\", "/")
|
||||
while normalized.startswith("./"):
|
||||
normalized = normalized[2:]
|
||||
|
||||
# Absolute path
|
||||
if Path(target_dir).is_absolute():
|
||||
return Path(target_dir)
|
||||
|
||||
# Relative path (contains path separator or starts with .trellis)
|
||||
if "/" in normalized or normalized.startswith(".trellis"):
|
||||
return repo_root / Path(normalized)
|
||||
|
||||
# Task name - try to find in tasks directory
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
found = find_task_by_name(target_dir, tasks_dir)
|
||||
if found:
|
||||
return found
|
||||
|
||||
# Fallback to treating as relative path
|
||||
return repo_root / Path(normalized)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Lifecycle Hooks
|
||||
# =============================================================================
|
||||
|
||||
def run_task_hooks(event: str, task_json_path: Path, repo_root: Path) -> None:
|
||||
"""Run lifecycle hooks for a task event.
|
||||
|
||||
Args:
|
||||
event: Event name (e.g. "after_create").
|
||||
task_json_path: Absolute path to the task's task.json.
|
||||
repo_root: Repository root for cwd and config lookup.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from .config import get_hooks
|
||||
from .log import Colors, colored
|
||||
|
||||
commands = get_hooks(event, repo_root)
|
||||
if not commands:
|
||||
return
|
||||
|
||||
env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)}
|
||||
|
||||
for cmd in commands:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
cwd=repo_root,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(
|
||||
colored(f"[WARN] Hook failed ({event}): {cmd}", Colors.YELLOW),
|
||||
file=sys.stderr,
|
||||
)
|
||||
if result.stderr.strip():
|
||||
print(f" {result.stderr.strip()}", file=sys.stderr)
|
||||
except Exception as e:
|
||||
print(
|
||||
colored(f"[WARN] Hook error ({event}): {cmd} — {e}", Colors.YELLOW),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Entry (for testing)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
repo = get_repo_root()
|
||||
tasks = get_tasks_dir(repo)
|
||||
|
||||
print(f"Tasks dir: {tasks}")
|
||||
print(f"is_safe_task_path('.trellis/tasks/test'): {is_safe_task_path('.trellis/tasks/test', repo)}")
|
||||
print(f"is_safe_task_path('../test'): {is_safe_task_path('../test', repo)}")
|
||||
112
.trellis/scripts/common/tasks.py
Executable file
112
.trellis/scripts/common/tasks.py
Executable file
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Task data access layer.
|
||||
|
||||
Single source of truth for loading and iterating task directories.
|
||||
Replaces scattered task.json parsing across 9+ files.
|
||||
|
||||
Provides:
|
||||
load_task — Load a single task by directory path
|
||||
iter_active_tasks — Iterate all non-archived tasks (sorted)
|
||||
get_all_statuses — Get {dir_name: status} map for children progress
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
from .io import read_json
|
||||
from .paths import FILE_TASK_JSON
|
||||
from .types import TaskInfo
|
||||
|
||||
|
||||
def load_task(task_dir: Path) -> TaskInfo | None:
|
||||
"""Load task from a directory containing task.json.
|
||||
|
||||
Args:
|
||||
task_dir: Absolute path to the task directory.
|
||||
|
||||
Returns:
|
||||
TaskInfo if task.json exists and is valid, None otherwise.
|
||||
"""
|
||||
task_json = task_dir / FILE_TASK_JSON
|
||||
if not task_json.is_file():
|
||||
return None
|
||||
|
||||
data = read_json(task_json)
|
||||
if not data:
|
||||
return None
|
||||
|
||||
return TaskInfo(
|
||||
dir_name=task_dir.name,
|
||||
directory=task_dir,
|
||||
title=data.get("title") or data.get("name") or "unknown",
|
||||
status=data.get("status", "unknown"),
|
||||
assignee=data.get("assignee", ""),
|
||||
priority=data.get("priority", "P2"),
|
||||
children=tuple(data.get("children", [])),
|
||||
parent=data.get("parent"),
|
||||
package=data.get("package"),
|
||||
raw=data,
|
||||
)
|
||||
|
||||
|
||||
def iter_active_tasks(tasks_dir: Path) -> Iterator[TaskInfo]:
|
||||
"""Iterate all active (non-archived) tasks, sorted by directory name.
|
||||
|
||||
Skips the "archive" directory and directories without valid task.json.
|
||||
|
||||
Args:
|
||||
tasks_dir: Path to the tasks directory.
|
||||
|
||||
Yields:
|
||||
TaskInfo for each valid task.
|
||||
"""
|
||||
if not tasks_dir.is_dir():
|
||||
return
|
||||
|
||||
for d in sorted(tasks_dir.iterdir()):
|
||||
if not d.is_dir() or d.name == "archive":
|
||||
continue
|
||||
info = load_task(d)
|
||||
if info is not None:
|
||||
yield info
|
||||
|
||||
|
||||
def get_all_statuses(tasks_dir: Path) -> dict[str, str]:
|
||||
"""Get a {dir_name: status} mapping for all active tasks.
|
||||
|
||||
Useful for computing children progress without loading full TaskInfo.
|
||||
|
||||
Args:
|
||||
tasks_dir: Path to the tasks directory.
|
||||
|
||||
Returns:
|
||||
Dict mapping directory names to status strings.
|
||||
"""
|
||||
return {t.dir_name: t.status for t in iter_active_tasks(tasks_dir)}
|
||||
|
||||
|
||||
def children_progress(
|
||||
children: tuple[str, ...] | list[str],
|
||||
all_statuses: dict[str, str],
|
||||
) -> str:
|
||||
"""Format children progress string like " [2/3 done]".
|
||||
|
||||
Args:
|
||||
children: List of child directory names.
|
||||
all_statuses: Status map from get_all_statuses().
|
||||
|
||||
Returns:
|
||||
Formatted string, or "" if no children.
|
||||
"""
|
||||
if not children:
|
||||
return ""
|
||||
# A child missing from active statuses has been archived (cmd_archive
|
||||
# sets status=completed before moving the dir). Count it as done so
|
||||
# parent progress doesn't regress when children are archived.
|
||||
done = sum(
|
||||
1 for c in children
|
||||
if c not in all_statuses or all_statuses.get(c) in ("completed", "done")
|
||||
)
|
||||
return f" [{done}/{len(children)} done]"
|
||||
131
.trellis/scripts/common/trellis_config.py
Executable file
131
.trellis/scripts/common/trellis_config.py
Executable file
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Standalone reader for .trellis/config.yaml.
|
||||
|
||||
Mirrors a minimal subset of common.config so callers (hooks, workflow_phase)
|
||||
can read configuration without importing the full task/repo helpers. Returns
|
||||
an empty dict on missing/malformed files so callers stay simple.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
CONFIG_REL_PATH = ".trellis/config.yaml"
|
||||
|
||||
|
||||
def _unquote(value: str) -> str:
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
|
||||
return value[1:-1]
|
||||
return value
|
||||
|
||||
|
||||
def _strip_inline_comment(value: str) -> str:
|
||||
"""Strip ` # …` inline comments while preserving `#` inside quoted strings.
|
||||
|
||||
YAML treats ` #` (space-hash) as a comment opener; bare `#` inside a token
|
||||
is part of the value. Quoted strings are immune.
|
||||
"""
|
||||
in_quote: str | None = None
|
||||
for idx, ch in enumerate(value):
|
||||
if in_quote:
|
||||
if ch == in_quote:
|
||||
in_quote = None
|
||||
continue
|
||||
if ch in ('"', "'"):
|
||||
in_quote = ch
|
||||
continue
|
||||
if ch == "#" and (idx == 0 or value[idx - 1].isspace()):
|
||||
return value[:idx]
|
||||
return value
|
||||
|
||||
|
||||
def _next_content_line(lines: list[str], start: int) -> tuple[int, str]:
|
||||
i = start
|
||||
while i < len(lines):
|
||||
stripped = lines[i].strip()
|
||||
if stripped and not stripped.startswith("#"):
|
||||
return i, lines[i]
|
||||
i += 1
|
||||
return i, ""
|
||||
|
||||
|
||||
def _parse_yaml_block(
|
||||
lines: list[str], start: int, min_indent: int, target: dict
|
||||
) -> int:
|
||||
i = start
|
||||
current_list: list | None = None
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped = line.strip()
|
||||
|
||||
if not stripped or stripped.startswith("#"):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
indent = len(line) - len(line.lstrip())
|
||||
if indent < min_indent:
|
||||
break
|
||||
|
||||
if stripped.startswith("- "):
|
||||
if current_list is not None:
|
||||
current_list.append(_unquote(stripped[2:].strip()))
|
||||
i += 1
|
||||
elif ":" in stripped:
|
||||
key, _, value = stripped.partition(":")
|
||||
key = key.strip()
|
||||
value = _strip_inline_comment(value).strip()
|
||||
value = _unquote(value)
|
||||
current_list = None
|
||||
|
||||
if value:
|
||||
target[key] = value
|
||||
i += 1
|
||||
else:
|
||||
next_i, next_line = _next_content_line(lines, i + 1)
|
||||
if next_i >= len(lines):
|
||||
target[key] = {}
|
||||
i = next_i
|
||||
elif next_line.strip().startswith("- "):
|
||||
current_list = []
|
||||
target[key] = current_list
|
||||
i += 1
|
||||
else:
|
||||
next_indent = len(next_line) - len(next_line.lstrip())
|
||||
if next_indent > indent:
|
||||
nested: dict = {}
|
||||
target[key] = nested
|
||||
i = _parse_yaml_block(lines, i + 1, next_indent, nested)
|
||||
else:
|
||||
target[key] = {}
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
return i
|
||||
|
||||
|
||||
def parse_simple_yaml(content: str) -> dict:
|
||||
"""Parse a small subset of YAML. See common.config for full doc."""
|
||||
lines = content.splitlines()
|
||||
result: dict = {}
|
||||
_parse_yaml_block(lines, 0, 0, result)
|
||||
return result
|
||||
|
||||
|
||||
def read_trellis_config(repo_root: Optional[Path] = None) -> dict:
|
||||
"""Read .trellis/config.yaml. Returns {} on missing or malformed file."""
|
||||
root = repo_root or Path.cwd()
|
||||
config_file = root / CONFIG_REL_PATH
|
||||
try:
|
||||
content = config_file.read_text(encoding="utf-8")
|
||||
except (FileNotFoundError, OSError):
|
||||
return {}
|
||||
try:
|
||||
parsed = parse_simple_yaml(content)
|
||||
except Exception:
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
110
.trellis/scripts/common/types.py
Executable file
110
.trellis/scripts/common/types.py
Executable file
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Core type definitions for Trellis task data.
|
||||
|
||||
Provides:
|
||||
TaskData — TypedDict for task.json shape (read-path type hints only)
|
||||
TaskInfo — Frozen dataclass for loaded task (the public API type)
|
||||
AgentRecord — TypedDict for registry.json agent entries
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# task.json shape (TypedDict — used only for read-path type hints)
|
||||
# =============================================================================
|
||||
|
||||
class TaskData(TypedDict, total=False):
|
||||
"""Shape of task.json on disk.
|
||||
|
||||
Used only for type annotations when reading task.json.
|
||||
Writes must use the original dict to avoid losing unknown fields.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
title: str
|
||||
description: str
|
||||
status: str
|
||||
dev_type: str
|
||||
scope: str | None
|
||||
package: str | None
|
||||
priority: str
|
||||
creator: str
|
||||
assignee: str
|
||||
createdAt: str
|
||||
completedAt: str | None
|
||||
branch: str | None
|
||||
base_branch: str | None
|
||||
worktree_path: str | None
|
||||
commit: str | None
|
||||
pr_url: str | None
|
||||
subtasks: list[str]
|
||||
children: list[str]
|
||||
parent: str | None
|
||||
relatedFiles: list[str]
|
||||
notes: str
|
||||
meta: dict
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Loaded task object (frozen dataclass — the public API type)
|
||||
# =============================================================================
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskInfo:
|
||||
"""Immutable view of a loaded task.
|
||||
|
||||
Created by load_task() / iter_active_tasks().
|
||||
Contains the commonly accessed fields; the original dict
|
||||
is preserved in `raw` for write-back and uncommon field access.
|
||||
"""
|
||||
|
||||
dir_name: str
|
||||
directory: Path
|
||||
title: str
|
||||
status: str
|
||||
assignee: str
|
||||
priority: str
|
||||
children: tuple[str, ...]
|
||||
parent: str | None
|
||||
package: str | None
|
||||
raw: dict # original dict — use for writes and uncommon fields
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Task name (id or name field)."""
|
||||
return self.raw.get("name") or self.raw.get("id") or self.dir_name
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return self.raw.get("description", "")
|
||||
|
||||
@property
|
||||
def branch(self) -> str | None:
|
||||
return self.raw.get("branch")
|
||||
|
||||
@property
|
||||
def meta(self) -> dict:
|
||||
return self.raw.get("meta", {})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# registry.json agent entry
|
||||
# =============================================================================
|
||||
|
||||
class AgentRecord(TypedDict, total=False):
|
||||
"""Shape of an agent entry in registry.json."""
|
||||
|
||||
id: str
|
||||
pid: int
|
||||
task_dir: str
|
||||
worktree_path: str
|
||||
branch: str
|
||||
platform: str
|
||||
started_at: str
|
||||
status: str
|
||||
212
.trellis/scripts/common/workflow_phase.py
Executable file
212
.trellis/scripts/common/workflow_phase.py
Executable file
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Workflow Phase Extraction.
|
||||
|
||||
Extracts step-level content from .trellis/workflow.md and optionally filters
|
||||
platform-specific blocks.
|
||||
|
||||
Platform marker syntax in workflow.md:
|
||||
|
||||
[Claude Code, Cursor, ...]
|
||||
agent-capable content
|
||||
[/Claude Code, Cursor, ...]
|
||||
|
||||
Provides:
|
||||
get_phase_index - Extract the Phase Index section (no --step)
|
||||
get_step - Extract a single step (#### X.X) section
|
||||
filter_platform - Strip platform blocks that don't include the given name
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .paths import DIR_WORKFLOW, get_repo_root
|
||||
|
||||
|
||||
def _workflow_md_path():
|
||||
return get_repo_root() / DIR_WORKFLOW / "workflow.md"
|
||||
|
||||
# Match a line that *is* a platform marker: "[A, B, C]" or "[/A, B, C]"
|
||||
_MARKER_RE = re.compile(r"^\[(/?)([A-Za-z][^\[\]]*)\]\s*$")
|
||||
|
||||
# Step heading: "#### 1.0 Title" or "#### 1.0 ..."
|
||||
_STEP_HEADING_RE = re.compile(r"^####\s+(\d+\.\d+)\b.*$")
|
||||
|
||||
# Phase Index starts here; Phase 1/2/3 step bodies follow; ends at Breadcrumbs.
|
||||
_PHASE_INDEX_HEADING = "## Phase Index"
|
||||
|
||||
|
||||
def _read_workflow() -> str:
|
||||
path = _workflow_md_path()
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"workflow.md not found: {path}")
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _parse_marker(line: str) -> tuple[bool, list[str]] | None:
|
||||
"""Parse a platform marker line.
|
||||
|
||||
Returns:
|
||||
(is_closing, [platform_names]) if line is a marker, else None.
|
||||
"""
|
||||
m = _MARKER_RE.match(line)
|
||||
if not m:
|
||||
return None
|
||||
is_closing = m.group(1) == "/"
|
||||
names = [p.strip() for p in m.group(2).split(",") if p.strip()]
|
||||
return is_closing, names
|
||||
|
||||
|
||||
def get_phase_index() -> str:
|
||||
"""Return the compact Phase Index summary from workflow.md.
|
||||
|
||||
SessionStart and no-step phase context use this small summary as their
|
||||
orientation payload. Detailed Phase 1/2/3 instructions are loaded with
|
||||
``get_step`` on demand. ``[workflow-state:STATUS]`` tag blocks are
|
||||
consumed by the per-turn hook, so they're stripped from this output.
|
||||
"""
|
||||
text = _read_workflow()
|
||||
lines = text.splitlines()
|
||||
|
||||
start: int | None = None
|
||||
end: int | None = None
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if start is None and stripped == _PHASE_INDEX_HEADING:
|
||||
start = i
|
||||
continue
|
||||
if start is not None and stripped == "## Phase 1: Plan":
|
||||
end = i
|
||||
break
|
||||
|
||||
if start is None:
|
||||
return ""
|
||||
if end is None:
|
||||
end = len(lines)
|
||||
|
||||
section = "\n".join(lines[start:end]).rstrip()
|
||||
# Strip [workflow-state:STATUS]...[/workflow-state:STATUS] blocks since
|
||||
# they're injected separately by inject-workflow-state.py per-turn.
|
||||
import re as _re
|
||||
tag_re = _re.compile(
|
||||
r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n.*?\n\s*\[/workflow-state:\1\]\n?",
|
||||
_re.DOTALL,
|
||||
)
|
||||
return tag_re.sub("", section).rstrip() + "\n"
|
||||
|
||||
|
||||
def get_step(step_id: str) -> str:
|
||||
"""Return the `#### X.X` section matching step_id (header + body).
|
||||
|
||||
Body ends at the next `####` or `---` or `##` heading (whichever comes first).
|
||||
"""
|
||||
text = _read_workflow()
|
||||
lines = text.splitlines()
|
||||
|
||||
start: int | None = None
|
||||
for i, line in enumerate(lines):
|
||||
m = _STEP_HEADING_RE.match(line)
|
||||
if m and m.group(1) == step_id:
|
||||
start = i
|
||||
break
|
||||
if start is None:
|
||||
return ""
|
||||
|
||||
end: int = len(lines)
|
||||
for j in range(start + 1, len(lines)):
|
||||
line = lines[j]
|
||||
if line.startswith("#### "):
|
||||
end = j
|
||||
break
|
||||
if line.startswith("## "):
|
||||
end = j
|
||||
break
|
||||
# Horizontal rule at column 0
|
||||
if line.strip() == "---":
|
||||
end = j
|
||||
break
|
||||
|
||||
return "\n".join(lines[start:end]).rstrip() + "\n"
|
||||
|
||||
|
||||
def _platform_matches(platform: str, block_names: list[str]) -> bool:
|
||||
"""Case-insensitive fuzzy match: accept 'cursor', 'Cursor', 'claude-code', 'Claude Code'."""
|
||||
needle = platform.lower().replace("-", "").replace("_", "").replace(" ", "")
|
||||
for name in block_names:
|
||||
hay = name.lower().replace("-", "").replace("_", "").replace(" ", "")
|
||||
if needle == hay:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def resolve_effective_platform(platform: str, config: dict) -> str:
|
||||
"""Map ``codex`` to a dispatch-mode-namespaced virtual platform name.
|
||||
|
||||
When ``--platform codex`` is passed, return ``"codex-inline"`` (default)
|
||||
or ``"codex-sub-agent"`` based on ``.trellis/config.yaml`` ``codex.dispatch_mode``.
|
||||
``filter_platform`` then surfaces blocks whose marker lists include the
|
||||
namespaced name (e.g. ``[codex-sub-agent, ...]`` or ``[codex-inline, Kilo,
|
||||
Antigravity, Devin]``).
|
||||
|
||||
Default is ``inline`` because Codex sub-agents run with ``fork_turns="none"``
|
||||
isolation and can't inherit the parent session's task context — inline
|
||||
keeps the main agent in charge so context isn't lost. Invalid / missing
|
||||
values also fall back to inline.
|
||||
|
||||
Other platforms are returned unchanged.
|
||||
"""
|
||||
if platform == "codex":
|
||||
mode = "inline"
|
||||
codex_cfg = config.get("codex") if isinstance(config, dict) else None
|
||||
if isinstance(codex_cfg, dict):
|
||||
cfg_mode = codex_cfg.get("dispatch_mode")
|
||||
if cfg_mode in ("inline", "sub-agent"):
|
||||
mode = cfg_mode
|
||||
return f"codex-{mode}"
|
||||
return platform
|
||||
|
||||
|
||||
def filter_platform(content: str, platform: str) -> str:
|
||||
"""Keep lines outside any `[...]` block + lines inside blocks that include platform.
|
||||
|
||||
Marker lines themselves are dropped from the output.
|
||||
"""
|
||||
lines = content.splitlines()
|
||||
out: list[str] = []
|
||||
|
||||
in_block = False
|
||||
keep_block = False
|
||||
|
||||
for line in lines:
|
||||
marker = _parse_marker(line)
|
||||
if marker is not None:
|
||||
is_closing, names = marker
|
||||
if not is_closing:
|
||||
in_block = True
|
||||
keep_block = _platform_matches(platform, names)
|
||||
else:
|
||||
in_block = False
|
||||
keep_block = False
|
||||
continue # drop the marker line itself
|
||||
|
||||
if in_block:
|
||||
if keep_block:
|
||||
out.append(line)
|
||||
continue
|
||||
out.append(line)
|
||||
|
||||
# Collapse runs of 3+ blank lines that may arise from dropped markers
|
||||
collapsed: list[str] = []
|
||||
blank_run = 0
|
||||
for line in out:
|
||||
if line.strip() == "":
|
||||
blank_run += 1
|
||||
if blank_run <= 2:
|
||||
collapsed.append(line)
|
||||
else:
|
||||
blank_run = 0
|
||||
collapsed.append(line)
|
||||
|
||||
return "\n".join(collapsed).rstrip() + "\n"
|
||||
16
.trellis/scripts/get_context.py
Executable file
16
.trellis/scripts/get_context.py
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Get Session Context for AI Agent.
|
||||
|
||||
Usage:
|
||||
python3 get_context.py Output context in text format
|
||||
python3 get_context.py --json Output context in JSON format
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from common.git_context import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
26
.trellis/scripts/get_developer.py
Executable file
26
.trellis/scripts/get_developer.py
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Get current developer name.
|
||||
|
||||
This is a wrapper that uses common/paths.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from common.paths import get_developer
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point."""
|
||||
developer = get_developer()
|
||||
if developer:
|
||||
print(developer)
|
||||
else:
|
||||
print("Developer not initialized", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
243
.trellis/scripts/hooks/linear_sync.py
Executable file
243
.trellis/scripts/hooks/linear_sync.py
Executable file
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Linear sync hook for Trellis task lifecycle.
|
||||
|
||||
Syncs task events to Linear via the `linearis` CLI.
|
||||
|
||||
Usage (called automatically by task.py hooks):
|
||||
python3 .trellis/scripts/hooks/linear_sync.py create
|
||||
python3 .trellis/scripts/hooks/linear_sync.py start
|
||||
python3 .trellis/scripts/hooks/linear_sync.py archive
|
||||
|
||||
Manual usage:
|
||||
TASK_JSON_PATH=.trellis/tasks/<name>/task.json python3 .trellis/scripts/hooks/linear_sync.py sync
|
||||
|
||||
Environment:
|
||||
TASK_JSON_PATH - Absolute path to task.json (set by task.py)
|
||||
|
||||
Configuration:
|
||||
.trellis/hooks.local.json - Local config (gitignored), example:
|
||||
{
|
||||
"linear": {
|
||||
"team": "TEAM_KEY",
|
||||
"project": "Project Name",
|
||||
"assignees": {
|
||||
"dev-name": "linear-user-id"
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ─── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
# Trellis priority → Linear priority (1=Urgent, 2=High, 3=Medium, 4=Low)
|
||||
PRIORITY_MAP = {"P0": 1, "P1": 2, "P2": 3, "P3": 4}
|
||||
|
||||
# Linear status names (must match your team's workflow)
|
||||
STATUS_IN_PROGRESS = "In Progress"
|
||||
STATUS_DONE = "Done"
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
"""Load local hook config from .trellis/hooks.local.json."""
|
||||
task_json_path = os.environ.get("TASK_JSON_PATH", "")
|
||||
if task_json_path:
|
||||
# Walk up from task.json to find .trellis/
|
||||
trellis_dir = Path(task_json_path).parent.parent.parent
|
||||
else:
|
||||
trellis_dir = Path(".trellis")
|
||||
|
||||
config_path = trellis_dir / "hooks.local.json"
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
CONFIG = _load_config()
|
||||
LINEAR_CFG = CONFIG.get("linear", {})
|
||||
|
||||
TEAM = LINEAR_CFG.get("team", "")
|
||||
PROJECT = LINEAR_CFG.get("project", "")
|
||||
ASSIGNEE_MAP = LINEAR_CFG.get("assignees", {})
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _read_task() -> tuple[dict, str]:
|
||||
path = os.environ.get("TASK_JSON_PATH", "")
|
||||
if not path:
|
||||
print("TASK_JSON_PATH not set", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return json.load(f), path
|
||||
|
||||
|
||||
def _write_task(data: dict, path: str) -> None:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
def _linearis(*args: str) -> dict | None:
|
||||
result = subprocess.run(
|
||||
["linearis", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"linearis error: {result.stderr.strip()}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
stdout = result.stdout.strip()
|
||||
if stdout:
|
||||
return json.loads(stdout)
|
||||
return None
|
||||
|
||||
|
||||
def _get_linear_issue(task: dict) -> str | None:
|
||||
meta = task.get("meta")
|
||||
if isinstance(meta, dict):
|
||||
return meta.get("linear_issue")
|
||||
return None
|
||||
|
||||
|
||||
# ─── Actions ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def cmd_create() -> None:
|
||||
if not TEAM:
|
||||
print("No linear.team configured in hooks.local.json", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
task, path = _read_task()
|
||||
|
||||
# Skip if already linked
|
||||
if _get_linear_issue(task):
|
||||
print(f"Already linked: {_get_linear_issue(task)}")
|
||||
return
|
||||
|
||||
title = task.get("title") or task.get("name") or "Untitled"
|
||||
args = ["issues", "create", title, "--team", TEAM]
|
||||
|
||||
# Map priority
|
||||
priority = PRIORITY_MAP.get(task.get("priority", ""), 0)
|
||||
if priority:
|
||||
args.extend(["-p", str(priority)])
|
||||
|
||||
# Set project
|
||||
if PROJECT:
|
||||
args.extend(["--project", PROJECT])
|
||||
|
||||
# Assign to Linear user
|
||||
assignee = task.get("assignee", "")
|
||||
linear_user_id = ASSIGNEE_MAP.get(assignee)
|
||||
if linear_user_id:
|
||||
args.extend(["--assignee", linear_user_id])
|
||||
|
||||
# Link to parent's Linear issue if available
|
||||
parent_issue = _resolve_parent_linear_issue(task)
|
||||
if parent_issue:
|
||||
args.extend(["--parent-ticket", parent_issue])
|
||||
|
||||
result = _linearis(*args)
|
||||
if result and "identifier" in result:
|
||||
if not isinstance(task.get("meta"), dict):
|
||||
task["meta"] = {}
|
||||
task["meta"]["linear_issue"] = result["identifier"]
|
||||
_write_task(task, path)
|
||||
print(f"Created Linear issue: {result['identifier']}")
|
||||
|
||||
|
||||
def cmd_start() -> None:
|
||||
task, _ = _read_task()
|
||||
issue = _get_linear_issue(task)
|
||||
if not issue:
|
||||
return
|
||||
_linearis("issues", "update", issue, "-s", STATUS_IN_PROGRESS)
|
||||
print(f"Updated {issue} -> {STATUS_IN_PROGRESS}")
|
||||
cmd_sync()
|
||||
|
||||
|
||||
def cmd_archive() -> None:
|
||||
task, _ = _read_task()
|
||||
issue = _get_linear_issue(task)
|
||||
if not issue:
|
||||
return
|
||||
_linearis("issues", "update", issue, "-s", STATUS_DONE)
|
||||
print(f"Updated {issue} -> {STATUS_DONE}")
|
||||
|
||||
|
||||
def cmd_sync() -> None:
|
||||
"""Sync prd.md content to Linear issue description."""
|
||||
task, _ = _read_task()
|
||||
issue = _get_linear_issue(task)
|
||||
if not issue:
|
||||
print("No linear_issue in meta, run create first", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Find prd.md next to task.json
|
||||
task_json_path = os.environ.get("TASK_JSON_PATH", "")
|
||||
prd_path = Path(task_json_path).parent / "prd.md"
|
||||
if not prd_path.is_file():
|
||||
print(f"No prd.md found at {prd_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
description = prd_path.read_text(encoding="utf-8").strip()
|
||||
_linearis("issues", "update", issue, "-d", description)
|
||||
print(f"Synced prd.md to {issue} description")
|
||||
|
||||
|
||||
# ─── Parent Issue Resolution ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _resolve_parent_linear_issue(task: dict) -> str | None:
|
||||
"""Find parent task's Linear issue identifier."""
|
||||
parent_name = task.get("parent")
|
||||
if not parent_name:
|
||||
return None
|
||||
|
||||
task_json_path = os.environ.get("TASK_JSON_PATH", "")
|
||||
if not task_json_path:
|
||||
return None
|
||||
|
||||
current_task_dir = Path(task_json_path).parent
|
||||
tasks_dir = current_task_dir.parent
|
||||
parent_json = tasks_dir / parent_name / "task.json"
|
||||
|
||||
if parent_json.exists():
|
||||
try:
|
||||
with open(parent_json, encoding="utf-8") as f:
|
||||
parent_task = json.load(f)
|
||||
return _get_linear_issue(parent_task)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
action = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
actions = {
|
||||
"create": cmd_create,
|
||||
"start": cmd_start,
|
||||
"archive": cmd_archive,
|
||||
"sync": cmd_sync,
|
||||
}
|
||||
fn = actions.get(action)
|
||||
if fn:
|
||||
fn()
|
||||
else:
|
||||
print(f"Unknown action: {action}", file=sys.stderr)
|
||||
print(f"Valid actions: {', '.join(actions)}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
51
.trellis/scripts/init_developer.py
Executable file
51
.trellis/scripts/init_developer.py
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Initialize developer for workflow.
|
||||
|
||||
Usage:
|
||||
python3 init_developer.py <developer-name>
|
||||
|
||||
This creates:
|
||||
- .trellis/.developer file with developer info
|
||||
- .trellis/workspace/<name>/ directory structure
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from common.paths import (
|
||||
DIR_WORKFLOW,
|
||||
FILE_DEVELOPER,
|
||||
get_developer,
|
||||
)
|
||||
from common.developer import init_developer
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point."""
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <developer-name>")
|
||||
print()
|
||||
print("Example:")
|
||||
print(f" {sys.argv[0]} john")
|
||||
sys.exit(1)
|
||||
|
||||
name = sys.argv[1]
|
||||
|
||||
# Check if already initialized
|
||||
existing = get_developer()
|
||||
if existing:
|
||||
print(f"Developer already initialized: {existing}")
|
||||
print()
|
||||
print(f"To reinitialize, remove {DIR_WORKFLOW}/{FILE_DEVELOPER} first")
|
||||
sys.exit(0)
|
||||
|
||||
if init_developer(name):
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
500
.trellis/scripts/task.py
Executable file
500
.trellis/scripts/task.py
Executable file
@@ -0,0 +1,500 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Task Management Script.
|
||||
|
||||
Usage:
|
||||
python3 task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] [--package <pkg>]
|
||||
python3 task.py add-context <dir> <file> <path> [reason] # Add jsonl entry
|
||||
python3 task.py validate <dir> # Validate jsonl files
|
||||
python3 task.py list-context <dir> # List jsonl entries
|
||||
python3 task.py start <dir> # Set active task
|
||||
python3 task.py current [--source] # Show active task
|
||||
python3 task.py finish # Clear active task
|
||||
python3 task.py set-branch <dir> <branch> # Set git branch
|
||||
python3 task.py set-base-branch <dir> <branch> # Set PR target branch
|
||||
python3 task.py set-scope <dir> <scope> # Set scope for PR title
|
||||
python3 task.py archive <task-dir> # Archive completed task
|
||||
python3 task.py list # List active tasks
|
||||
python3 task.py list-archive [month] # List archived tasks
|
||||
python3 task.py add-subtask <parent-dir> <child-dir> # Link child to parent
|
||||
python3 task.py remove-subtask <parent-dir> <child-dir> # Unlink child from parent
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from common.log import Colors, colored
|
||||
from common.paths import (
|
||||
DIR_WORKFLOW,
|
||||
DIR_TASKS,
|
||||
FILE_TASK_JSON,
|
||||
get_repo_root,
|
||||
get_developer,
|
||||
get_tasks_dir,
|
||||
get_current_task,
|
||||
)
|
||||
from common.active_task import (
|
||||
clear_active_task,
|
||||
resolve_active_task,
|
||||
resolve_context_key,
|
||||
set_active_task,
|
||||
)
|
||||
from common.io import read_json, write_json
|
||||
from common.task_utils import resolve_task_dir, run_task_hooks
|
||||
from common.tasks import iter_active_tasks, children_progress
|
||||
|
||||
# Import command handlers from split modules (also re-exports for plan.py compatibility)
|
||||
from common.task_store import (
|
||||
cmd_create,
|
||||
cmd_archive,
|
||||
cmd_set_branch,
|
||||
cmd_set_base_branch,
|
||||
cmd_set_scope,
|
||||
cmd_add_subtask,
|
||||
cmd_remove_subtask,
|
||||
)
|
||||
from common.task_context import (
|
||||
cmd_add_context,
|
||||
cmd_validate,
|
||||
cmd_list_context,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: start / finish
|
||||
# =============================================================================
|
||||
|
||||
def cmd_start(args: argparse.Namespace) -> int:
|
||||
"""Set active task."""
|
||||
repo_root = get_repo_root()
|
||||
task_input = args.dir
|
||||
|
||||
if not task_input:
|
||||
print(colored("Error: task directory or name required", Colors.RED))
|
||||
return 1
|
||||
|
||||
# Resolve task directory (supports task name, relative path, or absolute path)
|
||||
full_path = resolve_task_dir(task_input, repo_root)
|
||||
|
||||
if not full_path.is_dir():
|
||||
print(colored(f"Error: Task not found: {task_input}", Colors.RED))
|
||||
print("Hint: Use task name (e.g., 'my-task') or full path (e.g., '.trellis/tasks/01-31-my-task')")
|
||||
return 1
|
||||
|
||||
# Convert to relative path for storage
|
||||
try:
|
||||
task_dir = full_path.relative_to(repo_root).as_posix()
|
||||
except ValueError:
|
||||
task_dir = str(full_path)
|
||||
|
||||
task_json_path = full_path / FILE_TASK_JSON
|
||||
|
||||
if not resolve_context_key():
|
||||
# Degraded mode: no session identity available.
|
||||
# Hook didn't inject TRELLIS_CONTEXT_ID (common on Windows + Claude Code,
|
||||
# --continue resume path, fork distribution, hooks disabled, etc.). Skip
|
||||
# per-session pointer write; AI continues based on conversation context.
|
||||
print(colored(
|
||||
"ℹ Session identity not available; active-task pointer not persisted "
|
||||
"this session (degraded mode). AI continues based on conversation context.",
|
||||
Colors.YELLOW,
|
||||
))
|
||||
print(colored(
|
||||
"Hint: run inside an AI IDE/session that exposes session identity, "
|
||||
"or set TRELLIS_CONTEXT_ID before running task.py start.",
|
||||
Colors.YELLOW,
|
||||
))
|
||||
|
||||
# Still flip task.json status: planning → in_progress so downstream phases proceed.
|
||||
if task_json_path.is_file():
|
||||
data = read_json(task_json_path)
|
||||
if data and data.get("status") == "planning":
|
||||
data["status"] = "in_progress"
|
||||
if write_json(task_json_path, data):
|
||||
print(colored("✓ Status: planning → in_progress (degraded)", Colors.GREEN))
|
||||
run_task_hooks("after_start", task_json_path, repo_root)
|
||||
return 0
|
||||
|
||||
active = set_active_task(task_dir, repo_root)
|
||||
if active:
|
||||
print(colored(f"✓ Current task set to: {task_dir}", Colors.GREEN))
|
||||
print(f"Source: {active.source}")
|
||||
|
||||
if task_json_path.is_file():
|
||||
data = read_json(task_json_path)
|
||||
if data and data.get("status") == "planning":
|
||||
data["status"] = "in_progress"
|
||||
if write_json(task_json_path, data):
|
||||
print(colored("✓ Status: planning → in_progress", Colors.GREEN))
|
||||
|
||||
print()
|
||||
print(colored("The hook will now inject context from this task's jsonl files.", Colors.BLUE))
|
||||
|
||||
run_task_hooks("after_start", task_json_path, repo_root)
|
||||
return 0
|
||||
else:
|
||||
print(colored("Error: Failed to set current task", Colors.RED))
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_finish(args: argparse.Namespace) -> int:
|
||||
"""Clear active task."""
|
||||
repo_root = get_repo_root()
|
||||
active = clear_active_task(repo_root)
|
||||
current = active.task_path
|
||||
|
||||
if not current:
|
||||
print(colored("No current task set", Colors.YELLOW))
|
||||
return 0
|
||||
|
||||
# Resolve task.json path before clearing
|
||||
task_json_path = repo_root / current / FILE_TASK_JSON
|
||||
|
||||
print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN))
|
||||
print(f"Source: {active.source}")
|
||||
|
||||
if task_json_path.is_file():
|
||||
run_task_hooks("after_finish", task_json_path, repo_root)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_current(args: argparse.Namespace) -> int:
|
||||
"""Show active task."""
|
||||
repo_root = get_repo_root()
|
||||
active = resolve_active_task(repo_root)
|
||||
|
||||
if args.source:
|
||||
print(f"Current task: {active.task_path or '(none)'}")
|
||||
print(f"Source: {active.source}")
|
||||
if active.stale:
|
||||
print("State: stale")
|
||||
return 0 if active.task_path else 1
|
||||
|
||||
if active.task_path:
|
||||
print(active.task_path)
|
||||
return 0
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: list
|
||||
# =============================================================================
|
||||
|
||||
def cmd_list(args: argparse.Namespace) -> int:
|
||||
"""List active tasks."""
|
||||
repo_root = get_repo_root()
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
current_task = get_current_task(repo_root)
|
||||
developer = get_developer(repo_root)
|
||||
filter_mine = args.mine
|
||||
filter_status = args.status
|
||||
|
||||
if filter_mine:
|
||||
if not developer:
|
||||
print(colored("Error: No developer set. Run init_developer.py first", Colors.RED), file=sys.stderr)
|
||||
return 1
|
||||
print(colored(f"My tasks (assignee: {developer}):", Colors.BLUE))
|
||||
else:
|
||||
print(colored("All active tasks:", Colors.BLUE))
|
||||
print()
|
||||
|
||||
# Single pass: collect all tasks via shared iterator
|
||||
all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)}
|
||||
all_statuses = {name: t.status for name, t in all_tasks.items()}
|
||||
|
||||
# Display tasks hierarchically
|
||||
count = 0
|
||||
|
||||
def _print_task(dir_name: str, indent: int = 0) -> None:
|
||||
nonlocal count
|
||||
t = all_tasks[dir_name]
|
||||
|
||||
# Apply --mine filter
|
||||
if filter_mine and (t.assignee or "-") != developer:
|
||||
return
|
||||
|
||||
# Apply --status filter
|
||||
if filter_status and t.status != filter_status:
|
||||
return
|
||||
|
||||
relative_path = f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}"
|
||||
marker = ""
|
||||
if relative_path == current_task:
|
||||
marker = f" {colored('<- current', Colors.GREEN)}"
|
||||
|
||||
# Children progress
|
||||
progress = children_progress(t.children, all_statuses)
|
||||
|
||||
# Package tag
|
||||
pkg_tag = f" @{t.package}" if t.package else ""
|
||||
|
||||
prefix = " " * indent + " - "
|
||||
|
||||
if filter_mine:
|
||||
print(f"{prefix}{dir_name}/ ({t.status}){pkg_tag}{progress}{marker}")
|
||||
else:
|
||||
print(f"{prefix}{dir_name}/ ({t.status}){pkg_tag}{progress} [{colored(t.assignee or '-', Colors.CYAN)}]{marker}")
|
||||
count += 1
|
||||
|
||||
# Print children indented
|
||||
for child_name in t.children:
|
||||
if child_name in all_tasks:
|
||||
_print_task(child_name, indent + 1)
|
||||
|
||||
# Display only top-level tasks (those without a parent)
|
||||
for dir_name in sorted(all_tasks.keys()):
|
||||
if not all_tasks[dir_name].parent:
|
||||
_print_task(dir_name)
|
||||
|
||||
if count == 0:
|
||||
if filter_mine:
|
||||
print(" (no tasks assigned to you)")
|
||||
else:
|
||||
print(" (no active tasks)")
|
||||
|
||||
print()
|
||||
print(f"Total: {count} task(s)")
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: list-archive
|
||||
# =============================================================================
|
||||
|
||||
def cmd_list_archive(args: argparse.Namespace) -> int:
|
||||
"""List archived tasks."""
|
||||
repo_root = get_repo_root()
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
archive_dir = tasks_dir / "archive"
|
||||
month = args.month
|
||||
|
||||
print(colored("Archived tasks:", Colors.BLUE))
|
||||
print()
|
||||
|
||||
if month:
|
||||
month_dir = archive_dir / month
|
||||
if month_dir.is_dir():
|
||||
print(f"[{month}]")
|
||||
for d in sorted(month_dir.iterdir()):
|
||||
if d.is_dir():
|
||||
print(f" - {d.name}/")
|
||||
else:
|
||||
print(f" No archives for {month}")
|
||||
else:
|
||||
if archive_dir.is_dir():
|
||||
for month_dir in sorted(archive_dir.iterdir()):
|
||||
if month_dir.is_dir():
|
||||
month_name = month_dir.name
|
||||
count = sum(1 for d in month_dir.iterdir() if d.is_dir())
|
||||
print(f"[{month_name}] - {count} task(s)")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Help
|
||||
# =============================================================================
|
||||
|
||||
def show_usage() -> None:
|
||||
"""Show usage help."""
|
||||
print("""Task Management Script
|
||||
|
||||
Usage:
|
||||
python3 task.py create <title> Create new task directory
|
||||
python3 task.py create <title> --package <pkg> Create task for a specific package
|
||||
python3 task.py create <title> --parent <dir> Create task as child of parent
|
||||
python3 task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl
|
||||
python3 task.py validate <dir> Validate jsonl files
|
||||
python3 task.py list-context <dir> List jsonl entries
|
||||
python3 task.py start <dir> Set active task
|
||||
python3 task.py current [--source] Show active task
|
||||
python3 task.py finish Clear active task
|
||||
python3 task.py set-branch <dir> <branch> Set git branch
|
||||
python3 task.py set-base-branch <dir> <branch> Set PR target branch
|
||||
python3 task.py set-scope <dir> <scope> Set scope for PR title
|
||||
python3 task.py archive <task-dir> Archive completed task
|
||||
python3 task.py add-subtask <parent> <child> Link child task to parent
|
||||
python3 task.py remove-subtask <parent> <child> Unlink child from parent
|
||||
python3 task.py list [--mine] [--status <status>] List tasks
|
||||
python3 task.py list-archive [YYYY-MM] List archived tasks
|
||||
|
||||
Monorepo options:
|
||||
--package <pkg> Package name (validated against config.yaml packages)
|
||||
|
||||
List options:
|
||||
--mine, -m Show only tasks assigned to current developer
|
||||
--status, -s <s> Filter by status (planning, in_progress, review, completed)
|
||||
|
||||
Examples:
|
||||
python3 task.py create "Add login feature" --slug add-login
|
||||
python3 task.py create "Add login feature" --slug add-login --package cli
|
||||
python3 task.py create "Child task" --slug child --parent .trellis/tasks/01-21-parent
|
||||
python3 task.py add-context <dir> implement .trellis/spec/cli/backend/auth.md "Auth guidelines"
|
||||
python3 task.py set-branch <dir> task/add-login
|
||||
python3 task.py start .trellis/tasks/01-21-add-login
|
||||
python3 task.py current --source
|
||||
python3 task.py finish
|
||||
python3 task.py archive add-login
|
||||
python3 task.py add-subtask parent-task child-task # Link existing tasks
|
||||
python3 task.py remove-subtask parent-task child-task
|
||||
python3 task.py list # List all active tasks
|
||||
python3 task.py list --mine # List my tasks only
|
||||
python3 task.py list --mine --status in_progress # List my in-progress tasks
|
||||
""")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Entry
|
||||
# =============================================================================
|
||||
|
||||
def main() -> int:
|
||||
"""CLI entry point."""
|
||||
# Deprecation guard: `init-context` was removed in v0.5.0-beta.12.
|
||||
# Detect early so argparse doesn't mask the real reason with a generic
|
||||
# "invalid choice" error.
|
||||
if len(sys.argv) >= 2 and sys.argv[1] == "init-context":
|
||||
print(
|
||||
colored(
|
||||
"Error: `task.py init-context` was removed in v0.5.0-beta.12.",
|
||||
Colors.RED,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"implement.jsonl / check.jsonl are now seeded on `task.py create` for",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"sub-agent-capable platforms and curated by the AI during planning when needed.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("See .trellis/workflow.md planning artifact guidance or run:", file=sys.stderr)
|
||||
print(
|
||||
" python3 ./.trellis/scripts/get_context.py --mode phase --step 1",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"Use `task.py add-context <dir> implement|check <path> <reason>` to append entries.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Task Management Script",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", help="Commands")
|
||||
|
||||
# create
|
||||
p_create = subparsers.add_parser("create", help="Create new task")
|
||||
p_create.add_argument("title", help="Task title")
|
||||
p_create.add_argument("--slug", "-s", help="Task slug")
|
||||
p_create.add_argument("--assignee", "-a", help="Assignee developer")
|
||||
p_create.add_argument("--priority", "-p", default="P2", help="Priority (P0-P3)")
|
||||
p_create.add_argument("--description", "-d", help="Task description")
|
||||
p_create.add_argument("--parent", help="Parent task directory (establishes subtask link)")
|
||||
p_create.add_argument("--package", help="Package name for monorepo projects")
|
||||
|
||||
# add-context
|
||||
p_add = subparsers.add_parser("add-context", help="Add context entry")
|
||||
p_add.add_argument("dir", help="Task directory")
|
||||
p_add.add_argument("file", help="JSONL file (implement|check)")
|
||||
p_add.add_argument("path", help="File path to add")
|
||||
p_add.add_argument("reason", nargs="?", help="Reason for adding")
|
||||
|
||||
# validate
|
||||
p_validate = subparsers.add_parser("validate", help="Validate context files")
|
||||
p_validate.add_argument("dir", help="Task directory")
|
||||
|
||||
# list-context
|
||||
p_listctx = subparsers.add_parser("list-context", help="List context entries")
|
||||
p_listctx.add_argument("dir", help="Task directory")
|
||||
|
||||
# start
|
||||
p_start = subparsers.add_parser("start", help="Set active task")
|
||||
p_start.add_argument("dir", help="Task directory")
|
||||
|
||||
# current
|
||||
p_current = subparsers.add_parser("current", help="Show active task")
|
||||
p_current.add_argument("--source", action="store_true",
|
||||
help="Show active task source")
|
||||
|
||||
# finish
|
||||
subparsers.add_parser("finish", help="Clear active task")
|
||||
|
||||
# set-branch
|
||||
p_branch = subparsers.add_parser("set-branch", help="Set git branch")
|
||||
p_branch.add_argument("dir", help="Task directory")
|
||||
p_branch.add_argument("branch", help="Branch name")
|
||||
|
||||
# set-base-branch
|
||||
p_base = subparsers.add_parser("set-base-branch", help="Set PR target branch")
|
||||
p_base.add_argument("dir", help="Task directory")
|
||||
p_base.add_argument("base_branch", help="Base branch name (PR target)")
|
||||
|
||||
# set-scope
|
||||
p_scope = subparsers.add_parser("set-scope", help="Set scope")
|
||||
p_scope.add_argument("dir", help="Task directory")
|
||||
p_scope.add_argument("scope", help="Scope name")
|
||||
|
||||
# archive
|
||||
p_archive = subparsers.add_parser("archive", help="Archive task")
|
||||
p_archive.add_argument("name", help="Task directory or name")
|
||||
p_archive.add_argument("--no-commit", action="store_true", help="Skip auto git commit after archive")
|
||||
|
||||
# list
|
||||
p_list = subparsers.add_parser("list", help="List tasks")
|
||||
p_list.add_argument("--mine", "-m", action="store_true", help="My tasks only")
|
||||
p_list.add_argument("--status", "-s", help="Filter by status")
|
||||
|
||||
# add-subtask
|
||||
p_addsub = subparsers.add_parser("add-subtask", help="Link child task to parent")
|
||||
p_addsub.add_argument("parent_dir", help="Parent task directory")
|
||||
p_addsub.add_argument("child_dir", help="Child task directory")
|
||||
|
||||
# remove-subtask
|
||||
p_rmsub = subparsers.add_parser("remove-subtask", help="Unlink child task from parent")
|
||||
p_rmsub.add_argument("parent_dir", help="Parent task directory")
|
||||
p_rmsub.add_argument("child_dir", help="Child task directory")
|
||||
|
||||
# list-archive
|
||||
p_listarch = subparsers.add_parser("list-archive", help="List archived tasks")
|
||||
p_listarch.add_argument("month", nargs="?", help="Month (YYYY-MM)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
show_usage()
|
||||
return 1
|
||||
|
||||
commands = {
|
||||
"create": cmd_create,
|
||||
"add-context": cmd_add_context,
|
||||
"validate": cmd_validate,
|
||||
"list-context": cmd_list_context,
|
||||
"start": cmd_start,
|
||||
"current": cmd_current,
|
||||
"finish": cmd_finish,
|
||||
"set-branch": cmd_set_branch,
|
||||
"set-base-branch": cmd_set_base_branch,
|
||||
"set-scope": cmd_set_scope,
|
||||
"archive": cmd_archive,
|
||||
"add-subtask": cmd_add_subtask,
|
||||
"remove-subtask": cmd_remove_subtask,
|
||||
"list": cmd_list,
|
||||
"list-archive": cmd_list_archive,
|
||||
}
|
||||
|
||||
if args.command in commands:
|
||||
return commands[args.command](args)
|
||||
else:
|
||||
show_usage()
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
77
.trellis/spec/README.md
Normal file
77
.trellis/spec/README.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Next.js Full-Stack Development Guidelines
|
||||
|
||||
Universal development guidelines for production Next.js applications with oRPC API layer and PostgreSQL.
|
||||
|
||||
## Structure
|
||||
|
||||
### [Frontend](./frontend/index.md)
|
||||
|
||||
React 19 + Next.js 15 App Router frontend development patterns:
|
||||
|
||||
- [Directory Structure](./frontend/directory-structure.md)
|
||||
- [Components](./frontend/components.md)
|
||||
- [State Management](./frontend/state-management.md)
|
||||
- [Hooks](./frontend/hooks.md)
|
||||
- [API Integration](./frontend/api-integration.md)
|
||||
- [oRPC Usage](./frontend/orpc-usage.md)
|
||||
- [Authentication](./frontend/authentication.md)
|
||||
- [AI SDK Integration](./frontend/ai-sdk-integration.md)
|
||||
- [CSS & Layout](./frontend/css-layout.md)
|
||||
- [Type Safety](./frontend/type-safety.md)
|
||||
- [Quality Checklist](./frontend/quality.md)
|
||||
|
||||
### [Backend](./backend/index.md)
|
||||
|
||||
oRPC + Drizzle ORM backend development patterns:
|
||||
|
||||
- [Directory Structure](./backend/directory-structure.md)
|
||||
- [oRPC Usage](./backend/orpc-usage.md)
|
||||
- [Authentication](./backend/authentication.md)
|
||||
- [Database](./backend/database.md)
|
||||
- [AI SDK Integration](./backend/ai-sdk-integration.md)
|
||||
- [Logging](./backend/logging.md)
|
||||
- [Performance](./backend/performance.md)
|
||||
- [Type Safety](./backend/type-safety.md)
|
||||
- [Quality Checklist](./backend/quality.md)
|
||||
|
||||
### [Shared](./shared/index.md)
|
||||
|
||||
Cross-cutting concerns:
|
||||
|
||||
- [Dependencies](./shared/dependencies.md)
|
||||
- [Code Quality](./shared/code-quality.md)
|
||||
- [TypeScript Conventions](./shared/typescript.md)
|
||||
|
||||
### [Guides](./guides/index.md)
|
||||
|
||||
Development thinking guides:
|
||||
|
||||
- [Pre-Implementation Checklist](./guides/pre-implementation-checklist.md)
|
||||
- [Cross-Layer Thinking Guide](./guides/cross-layer-thinking-guide.md)
|
||||
|
||||
### [Common Issues / Pitfalls](./big-question/index.md)
|
||||
|
||||
Common issues and solutions:
|
||||
|
||||
- [PostgreSQL JSON vs JSONB](./big-question/postgres-json-jsonb.md)
|
||||
- [WebKit Tap Highlight](./big-question/webkit-tap-highlight.md)
|
||||
- [Sentry & next-intl Conflict](./big-question/sentry-nextintl-conflict.md)
|
||||
- [Turbopack vs Webpack Flexbox](./big-question/turbopack-webpack-flexbox.md)
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend**: Next.js 15, React 19, TailwindCSS 4, Radix UI, React Query
|
||||
- **Backend**: oRPC, Drizzle ORM, PostgreSQL, better-auth
|
||||
- **AI**: Vercel AI SDK (multi-provider)
|
||||
- **Real-time**: Ably / WebSocket / SSE
|
||||
- **Build**: Turborepo + pnpm (monorepo)
|
||||
- **Monitoring**: Sentry + OpenTelemetry
|
||||
|
||||
## Usage
|
||||
|
||||
These guidelines can be used as:
|
||||
|
||||
1. **New Project Template** - Copy the entire structure for new Next.js projects
|
||||
2. **Reference Documentation** - Consult specific guides when implementing features
|
||||
3. **Code Review Checklist** - Verify implementations against established patterns
|
||||
4. **Onboarding Material** - Help new developers understand project conventions
|
||||
351
.trellis/spec/backend/ai-sdk-integration.md
Normal file
351
.trellis/spec/backend/ai-sdk-integration.md
Normal file
@@ -0,0 +1,351 @@
|
||||
# AI SDK Backend Integration Guidelines
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This document covers backend integration patterns using the Vercel AI SDK (`ai` package) for AI-powered features.
|
||||
|
||||
### Supported Providers
|
||||
- **OpenAI**: GPT-4o, GPT-4o-mini, GPT-4-turbo
|
||||
- **Google Gemini**: gemini-1.5-pro, gemini-1.5-flash
|
||||
- **Anthropic**: Claude 3.5 Sonnet, Claude 3 Opus
|
||||
|
||||
### Package Dependencies
|
||||
```bash
|
||||
pnpm add ai @ai-sdk/openai @ai-sdk/google @ai-sdk/anthropic
|
||||
```
|
||||
|
||||
## 2. Basic Usage
|
||||
|
||||
### generateText
|
||||
|
||||
Use `generateText` for simple text generation tasks where you need a complete response.
|
||||
|
||||
```typescript
|
||||
import { generateText } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
|
||||
const { text } = await generateText({
|
||||
model: openai("gpt-4o-mini"),
|
||||
prompt: "Summarize this document...",
|
||||
});
|
||||
```
|
||||
|
||||
### generateObject (Structured Output with Zod)
|
||||
|
||||
Use `generateObject` when you need type-safe structured output. The AI SDK validates the response against your Zod schema automatically.
|
||||
|
||||
```typescript
|
||||
import { generateObject } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { z } from "zod";
|
||||
|
||||
const classificationSchema = z.object({
|
||||
category: z.enum(["urgent", "normal", "low"]),
|
||||
confidence: z.number().min(0).max(1),
|
||||
reasoning: z.string(),
|
||||
});
|
||||
|
||||
const { object } = await generateObject({
|
||||
model: openai("gpt-4o-mini"),
|
||||
schema: classificationSchema,
|
||||
prompt: "Classify the priority of this task...",
|
||||
});
|
||||
// object is typed as { category: "urgent" | "normal" | "low", confidence: number, reasoning: string }
|
||||
```
|
||||
|
||||
### streamText (For SSE/Streaming)
|
||||
|
||||
Use `streamText` for real-time streaming responses, ideal for chat interfaces and long-form content generation.
|
||||
|
||||
```typescript
|
||||
import { streamText } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
|
||||
const result = streamText({
|
||||
model: openai("gpt-4o"),
|
||||
messages: conversationHistory,
|
||||
system: "You are a helpful assistant.",
|
||||
});
|
||||
|
||||
// Return as SSE stream
|
||||
return result.toDataStreamResponse();
|
||||
```
|
||||
|
||||
## 3. Telemetry Configuration
|
||||
|
||||
**IMPORTANT**: Always enable telemetry for token tracking and performance monitoring.
|
||||
|
||||
```typescript
|
||||
import { generateObject } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
|
||||
const { object } = await generateObject({
|
||||
model: openai("gpt-4o-mini"),
|
||||
schema: mySchema,
|
||||
prompt,
|
||||
experimental_telemetry: {
|
||||
isEnabled: true,
|
||||
functionId: "orders.classify", // Module.function naming
|
||||
metadata: {
|
||||
orderId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Telemetry Naming Convention
|
||||
|
||||
Use dot-separated format for `functionId`: `module.function`
|
||||
|
||||
| Module | Example functionId |
|
||||
|--------|-------------------|
|
||||
| Orders | `orders.classify`, `orders.summarize` |
|
||||
| Support | `support.generateReply`, `support.categorize` |
|
||||
| Content | `content.summarize`, `content.translate` |
|
||||
| Users | `users.analyzePreferences` |
|
||||
|
||||
### Auto-recorded Metrics
|
||||
|
||||
When telemetry is enabled, these metrics are automatically tracked:
|
||||
|
||||
| Metric | Description |
|
||||
|--------|-------------|
|
||||
| `ai.model.id` | Model identifier (e.g., gpt-4o-mini) |
|
||||
| `ai.model.provider` | Provider name (e.g., openai) |
|
||||
| `ai.usage.prompt_tokens` | Input tokens consumed |
|
||||
| `ai.usage.completion_tokens` | Output tokens generated |
|
||||
| `ai.usage.total_tokens` | Total tokens used |
|
||||
| `ai.response.finish_reason` | Completion reason (stop, length, etc.) |
|
||||
|
||||
## 4. Tool Calling
|
||||
|
||||
Define tools that the AI model can invoke to perform actions in your system.
|
||||
|
||||
```typescript
|
||||
import { generateText, tool } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { z } from "zod";
|
||||
|
||||
const result = await generateText({
|
||||
model: openai("gpt-4o"),
|
||||
prompt: "Create a task for the user...",
|
||||
tools: {
|
||||
createTask: tool({
|
||||
description: "Create a new task in the system",
|
||||
parameters: z.object({
|
||||
title: z.string(),
|
||||
dueDate: z.string().optional(),
|
||||
priority: z.enum(["high", "medium", "low"]),
|
||||
}),
|
||||
execute: async ({ title, dueDate, priority }) => {
|
||||
const task = await db.insert(tasks).values({
|
||||
title,
|
||||
dueDate: dueDate ? new Date(dueDate) : null,
|
||||
priority,
|
||||
}).returning();
|
||||
return { success: true, taskId: task[0].id };
|
||||
},
|
||||
}),
|
||||
searchOrders: tool({
|
||||
description: "Search for orders by criteria",
|
||||
parameters: z.object({
|
||||
query: z.string(),
|
||||
status: z.enum(["pending", "completed", "cancelled"]).optional(),
|
||||
limit: z.number().default(10),
|
||||
}),
|
||||
execute: async ({ query, status, limit }) => {
|
||||
const orders = await db.query.orders.findMany({
|
||||
where: and(
|
||||
like(orders.title, `%${query}%`),
|
||||
status ? eq(orders.status, status) : undefined
|
||||
),
|
||||
limit,
|
||||
});
|
||||
return { orders };
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// Access tool results
|
||||
if (result.toolCalls) {
|
||||
for (const toolCall of result.toolCalls) {
|
||||
console.log(`Tool: ${toolCall.toolName}`, toolCall.result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Error Handling
|
||||
|
||||
Always implement graceful error handling for AI operations.
|
||||
|
||||
```typescript
|
||||
import { generateObject } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { logger } from "@your-app/logs";
|
||||
|
||||
async function classifyOrder(orderData: OrderData) {
|
||||
try {
|
||||
const { object } = await generateObject({
|
||||
model: openai("gpt-4o-mini"),
|
||||
schema: classificationSchema,
|
||||
prompt: buildClassificationPrompt(orderData),
|
||||
experimental_telemetry: {
|
||||
isEnabled: true,
|
||||
functionId: "orders.classify",
|
||||
},
|
||||
});
|
||||
return { success: true, data: object };
|
||||
} catch (error) {
|
||||
logger.error("AI generation failed", {
|
||||
error,
|
||||
orderId: orderData.id,
|
||||
prompt: buildClassificationPrompt(orderData).slice(0, 100)
|
||||
});
|
||||
|
||||
// Return graceful fallback
|
||||
return {
|
||||
success: false,
|
||||
reason: "AI processing failed",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Common Error Types
|
||||
|
||||
| Error | Cause | Resolution |
|
||||
|-------|-------|------------|
|
||||
| Rate limit exceeded | Too many requests | Implement exponential backoff |
|
||||
| Context length exceeded | Prompt too long | Truncate or summarize input |
|
||||
| Invalid API key | Missing/wrong credentials | Check environment variables |
|
||||
| Schema validation failed | AI output doesn't match schema | Adjust schema or prompt |
|
||||
|
||||
## 6. Prompt Engineering Best Practices
|
||||
|
||||
### Use XML Structure for Complex Prompts
|
||||
|
||||
XML tags help the AI model better understand the structure of your request.
|
||||
|
||||
```typescript
|
||||
const prompt = `
|
||||
<context>
|
||||
${contextData}
|
||||
</context>
|
||||
|
||||
<task>
|
||||
Analyze the above context and extract key information.
|
||||
</task>
|
||||
|
||||
<output_format>
|
||||
Return a JSON object with the following fields:
|
||||
- summary: A brief summary
|
||||
- keyPoints: Array of key points
|
||||
- sentiment: positive, negative, or neutral
|
||||
</output_format>
|
||||
`;
|
||||
```
|
||||
|
||||
### System Prompts
|
||||
|
||||
Define consistent behavior with system prompts.
|
||||
|
||||
```typescript
|
||||
import { generateText } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
|
||||
const result = await generateText({
|
||||
model: openai("gpt-4o"),
|
||||
system: `You are a professional assistant.
|
||||
Always respond in a structured format.
|
||||
Be concise and accurate.
|
||||
Never make up information - if unsure, say so.`,
|
||||
messages: userMessages,
|
||||
});
|
||||
```
|
||||
|
||||
### Multi-step Prompts
|
||||
|
||||
For complex tasks, break down into multiple AI calls.
|
||||
|
||||
```typescript
|
||||
// Step 1: Extract entities
|
||||
const { object: entities } = await generateObject({
|
||||
model: openai("gpt-4o-mini"),
|
||||
schema: entitiesSchema,
|
||||
prompt: `Extract entities from: ${document}`,
|
||||
});
|
||||
|
||||
// Step 2: Classify based on entities
|
||||
const { object: classification } = await generateObject({
|
||||
model: openai("gpt-4o-mini"),
|
||||
schema: classificationSchema,
|
||||
prompt: `
|
||||
<entities>
|
||||
${JSON.stringify(entities, null, 2)}
|
||||
</entities>
|
||||
|
||||
<task>
|
||||
Based on these entities, classify the document category.
|
||||
</task>
|
||||
`,
|
||||
});
|
||||
```
|
||||
|
||||
## 7. Provider-Specific Configuration
|
||||
|
||||
### OpenAI
|
||||
|
||||
```typescript
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
|
||||
const model = openai("gpt-4o-mini", {
|
||||
// Optional: custom configuration
|
||||
});
|
||||
```
|
||||
|
||||
### Google Gemini
|
||||
|
||||
```typescript
|
||||
import { google } from "@ai-sdk/google";
|
||||
|
||||
const model = google("gemini-1.5-flash");
|
||||
```
|
||||
|
||||
### Anthropic
|
||||
|
||||
```typescript
|
||||
import { anthropic } from "@ai-sdk/anthropic";
|
||||
|
||||
const model = anthropic("claude-3-5-sonnet-20241022");
|
||||
```
|
||||
|
||||
## 8. Best Practices Summary
|
||||
|
||||
| Rule | Description |
|
||||
|------|-------------|
|
||||
| Always enable telemetry | Track token usage and performance for cost monitoring |
|
||||
| Use generateObject for structured output | Leverage Zod schemas for type safety and validation |
|
||||
| Use XML prompts for complex tasks | Better structure improves AI understanding |
|
||||
| Handle errors gracefully | Return fallback responses, never crash |
|
||||
| Log AI failures | Include context (truncated prompt, IDs) for debugging |
|
||||
| Use appropriate model sizes | Use mini models for simple tasks, larger for complex |
|
||||
| Implement rate limiting | Protect against API quota exhaustion |
|
||||
| Cache responses when appropriate | Reduce costs for repeated queries |
|
||||
|
||||
## 9. Environment Variables
|
||||
|
||||
Required environment variables for AI providers:
|
||||
|
||||
```bash
|
||||
# OpenAI
|
||||
OPENAI_API_KEY=sk-...
|
||||
|
||||
# Google Gemini
|
||||
GOOGLE_GENERATIVE_AI_API_KEY=...
|
||||
|
||||
# Anthropic
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
723
.trellis/spec/backend/authentication.md
Normal file
723
.trellis/spec/backend/authentication.md
Normal file
@@ -0,0 +1,723 @@
|
||||
# Authentication Guidelines
|
||||
|
||||
This document covers backend authentication integration using better-auth, including session management, protected procedures, and OAuth configuration.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
### What is better-auth
|
||||
|
||||
better-auth is a modern authentication library for TypeScript applications that provides:
|
||||
|
||||
- Session-based authentication with secure cookie management
|
||||
- Multiple authentication methods (email/password, OAuth, magic links, passkeys)
|
||||
- Built-in support for organizations and multi-tenancy
|
||||
- Database adapter integration (Drizzle ORM)
|
||||
- Two-factor authentication (2FA)
|
||||
- Admin functionality
|
||||
|
||||
### Session-based Authentication
|
||||
|
||||
The authentication system uses secure, HTTP-only cookies to manage user sessions:
|
||||
|
||||
- Sessions are stored in the database and cached in Redis for performance
|
||||
- Session tokens are automatically validated on each request
|
||||
- Cookie names are prefixed with `__Secure-` in production (HTTPS)
|
||||
|
||||
### Supported Providers
|
||||
|
||||
| Provider | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| Email/Password | Credential | Traditional email and password authentication |
|
||||
| Google | OAuth | Social login with Google account |
|
||||
| GitHub | OAuth | Social login with GitHub account |
|
||||
| Magic Link | Passwordless | Email-based one-time login links |
|
||||
| Passkey | Passwordless | WebAuthn/FIDO2 biometric authentication |
|
||||
|
||||
## 2. Auth Configuration
|
||||
|
||||
### Server-side Auth Setup
|
||||
|
||||
The auth configuration is defined in the auth package:
|
||||
|
||||
```typescript
|
||||
// packages/auth/auth.ts
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { db } from "@your-app/database";
|
||||
import {
|
||||
admin,
|
||||
magicLink,
|
||||
organization,
|
||||
passkey,
|
||||
twoFactor,
|
||||
username,
|
||||
} from "better-auth/plugins";
|
||||
|
||||
export const auth = betterAuth({
|
||||
baseURL: process.env.APP_URL,
|
||||
appName: "Your App Name",
|
||||
|
||||
// Database adapter
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
}),
|
||||
|
||||
// Session configuration
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days in seconds
|
||||
freshAge: 0,
|
||||
},
|
||||
|
||||
// Account linking for OAuth providers
|
||||
account: {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
trustedProviders: ["google", "github"],
|
||||
},
|
||||
},
|
||||
|
||||
// Plugins
|
||||
plugins: [
|
||||
username(),
|
||||
admin(),
|
||||
passkey(),
|
||||
magicLink({
|
||||
sendMagicLink: async ({ email, url }, request) => {
|
||||
// Send magic link email
|
||||
await sendEmail({
|
||||
to: email,
|
||||
templateId: "magicLink",
|
||||
context: { url },
|
||||
});
|
||||
},
|
||||
}),
|
||||
organization({
|
||||
sendInvitationEmail: async ({ email, id, organization }, request) => {
|
||||
// Send organization invitation email
|
||||
},
|
||||
}),
|
||||
twoFactor(),
|
||||
],
|
||||
});
|
||||
|
||||
// Export session type
|
||||
export type Session = typeof auth.$Infer.Session;
|
||||
```
|
||||
|
||||
### Database Adapter (Drizzle)
|
||||
|
||||
better-auth uses Drizzle ORM for database operations. The required tables are automatically created:
|
||||
|
||||
- `user` - User accounts
|
||||
- `session` - Authentication sessions
|
||||
- `account` - OAuth provider accounts (Google, GitHub, etc.)
|
||||
- `verification` - Email verification tokens
|
||||
|
||||
### Session Configuration
|
||||
|
||||
```typescript
|
||||
session: {
|
||||
// Session lifetime (default: 7 days)
|
||||
expiresIn: 60 * 60 * 24 * 7,
|
||||
|
||||
// Fresh session age for sensitive operations (0 = always require re-auth)
|
||||
freshAge: 0,
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Protected Procedures
|
||||
|
||||
### Procedure Types
|
||||
|
||||
The API layer provides three procedure types with different authentication levels:
|
||||
|
||||
```typescript
|
||||
// packages/api/orpc/procedures.ts
|
||||
import { ORPCError, os } from "@orpc/server";
|
||||
|
||||
// Public procedure - no authentication required
|
||||
export const publicProcedure = os
|
||||
.$context<{ headers: Headers }>()
|
||||
.use(logIdMiddleware);
|
||||
|
||||
// Protected procedure - requires authenticated user
|
||||
export const protectedProcedure = publicProcedure.use(
|
||||
async ({ context, next }) => {
|
||||
const { session } = await getSessionWithCache(context.headers);
|
||||
|
||||
if (!session) {
|
||||
throw new ORPCError("UNAUTHORIZED");
|
||||
}
|
||||
|
||||
return await next({
|
||||
context: {
|
||||
session: session.session,
|
||||
user: session.user,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Admin procedure - requires admin role
|
||||
export const adminProcedure = protectedProcedure.use(
|
||||
async ({ context, next }) => {
|
||||
if (context.user.role !== "admin") {
|
||||
throw new ORPCError("FORBIDDEN");
|
||||
}
|
||||
|
||||
return await next();
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Using Protected Procedures
|
||||
|
||||
**Basic protected endpoint:**
|
||||
|
||||
```typescript
|
||||
// procedures/get-profile.ts
|
||||
import { protectedProcedure } from "../../../orpc/procedures";
|
||||
|
||||
export const getProfile = protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/users/profile",
|
||||
tags: ["Users"],
|
||||
summary: "Get current user profile",
|
||||
})
|
||||
.handler(async ({ context }) => {
|
||||
// Access authenticated user from context
|
||||
const { user, session } = context;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
reason: "Profile retrieved",
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
},
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
**Admin-only endpoint:**
|
||||
|
||||
```typescript
|
||||
// procedures/list-users.ts
|
||||
import { adminProcedure } from "../../../orpc/procedures";
|
||||
import { z } from "zod";
|
||||
|
||||
export const listUsers = adminProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/admin/users",
|
||||
tags: ["Administration"],
|
||||
summary: "List all users",
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.number().min(1).max(100).default(10),
|
||||
offset: z.number().min(0).default(0),
|
||||
}),
|
||||
)
|
||||
.handler(async ({ input: { limit, offset } }) => {
|
||||
const users = await getUsers({ limit, offset });
|
||||
return { users };
|
||||
});
|
||||
```
|
||||
|
||||
### Accessing User Session in Context
|
||||
|
||||
The protected procedure middleware injects session data into the context:
|
||||
|
||||
```typescript
|
||||
interface ProtectedContext {
|
||||
session: {
|
||||
id: string;
|
||||
userId: string;
|
||||
expiresAt: Date;
|
||||
// ... other session fields
|
||||
};
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: "user" | "admin";
|
||||
// ... other user fields
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Accessing context in handlers:**
|
||||
|
||||
```typescript
|
||||
.handler(async ({ context, input }) => {
|
||||
const { user, session } = context;
|
||||
|
||||
// Use user.id for database queries
|
||||
const userOrders = await getOrdersByUserId(user.id);
|
||||
|
||||
// Check user role
|
||||
if (user.role === "admin") {
|
||||
// Admin-specific logic
|
||||
}
|
||||
|
||||
return { success: true, reason: "Success", orders: userOrders };
|
||||
});
|
||||
```
|
||||
|
||||
### Role-based Access Control
|
||||
|
||||
**Custom role middleware:**
|
||||
|
||||
```typescript
|
||||
// Create a middleware for specific roles
|
||||
const organizationAdminProcedure = protectedProcedure.use(
|
||||
async ({ context, input, next }) => {
|
||||
const { organizationId } = input as { organizationId: string };
|
||||
|
||||
const membership = await getOrganizationMembership(
|
||||
organizationId,
|
||||
context.user.id
|
||||
);
|
||||
|
||||
if (!membership || membership.role !== "owner") {
|
||||
throw new ORPCError("FORBIDDEN", {
|
||||
message: "Organization admin access required",
|
||||
});
|
||||
}
|
||||
|
||||
return await next({
|
||||
context: {
|
||||
...context,
|
||||
organization: membership.organization,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
**Verifying organization membership:**
|
||||
|
||||
```typescript
|
||||
// lib/membership.ts
|
||||
import { getOrganizationMembership } from "@your-app/database";
|
||||
|
||||
export async function verifyOrganizationMembership(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
) {
|
||||
const membership = await getOrganizationMembership(organizationId, userId);
|
||||
|
||||
if (!membership) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
organization: membership.organization,
|
||||
role: membership.role,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Session Management
|
||||
|
||||
### Session Caching
|
||||
|
||||
Sessions are cached in Redis to reduce database load:
|
||||
|
||||
```typescript
|
||||
// lib/session-cache.ts
|
||||
import { auth } from "@your-app/auth";
|
||||
import { redis } from "./redis";
|
||||
|
||||
const SESSION_CACHE_PREFIX = "session";
|
||||
const SESSION_TTL = 60 * 60 * 24 * 7; // 7 days
|
||||
|
||||
export async function getSessionWithCache(
|
||||
headers: Headers,
|
||||
): Promise<{ session: Session | null; fromCache: boolean }> {
|
||||
const sessionToken = getSessionTokenFromHeaders(headers);
|
||||
|
||||
if (!sessionToken) {
|
||||
const fresh = await fetchSession(headers);
|
||||
return { session: fresh, fromCache: false };
|
||||
}
|
||||
|
||||
// Try cache first
|
||||
const cached = await redis.get(`${SESSION_CACHE_PREFIX}:${sessionToken}`);
|
||||
if (cached) {
|
||||
return { session: JSON.parse(cached), fromCache: true };
|
||||
}
|
||||
|
||||
// Fetch from database
|
||||
const fresh = await auth.api.getSession({ headers });
|
||||
|
||||
if (fresh) {
|
||||
// Cache the session
|
||||
await redis.set(
|
||||
`${SESSION_CACHE_PREFIX}:${sessionToken}`,
|
||||
JSON.stringify(fresh),
|
||||
{ ex: SESSION_TTL }
|
||||
);
|
||||
}
|
||||
|
||||
return { session: fresh, fromCache: false };
|
||||
}
|
||||
```
|
||||
|
||||
### Getting Session Token from Headers
|
||||
|
||||
```typescript
|
||||
export function getSessionTokenFromHeaders(headers: Headers): string | null {
|
||||
// Check Authorization header first
|
||||
const authHeader = headers.get("Authorization");
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
return authHeader.slice("Bearer ".length);
|
||||
}
|
||||
|
||||
// Fall back to cookie
|
||||
const cookieHeader = headers.get("cookie");
|
||||
if (!cookieHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cookies = parseCookie(cookieHeader);
|
||||
const cookieName = process.env.NODE_ENV === "production"
|
||||
? "__Secure-better-auth.session_token"
|
||||
: "better-auth.session_token";
|
||||
|
||||
return cookies[cookieName] ?? null;
|
||||
}
|
||||
```
|
||||
|
||||
### Session Invalidation
|
||||
|
||||
```typescript
|
||||
// Delete session cache on logout or session change
|
||||
export async function deleteSessionCache(sessionToken: string): Promise<void> {
|
||||
await redis.del(`${SESSION_CACHE_PREFIX}:${sessionToken}`);
|
||||
}
|
||||
```
|
||||
|
||||
## 5. OAuth Integration
|
||||
|
||||
### Google OAuth Setup
|
||||
|
||||
**Configuration:**
|
||||
|
||||
```typescript
|
||||
// auth.ts
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID as string,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
|
||||
scope: [
|
||||
"email",
|
||||
"profile",
|
||||
"openid",
|
||||
// Add additional scopes as needed
|
||||
// "https://www.googleapis.com/auth/calendar",
|
||||
],
|
||||
// Get refresh token for offline access
|
||||
accessType: "offline",
|
||||
prompt: "consent",
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
**Environment variables:**
|
||||
|
||||
```bash
|
||||
# .env
|
||||
GOOGLE_CLIENT_ID=your-google-client-id
|
||||
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||
```
|
||||
|
||||
### GitHub OAuth Setup
|
||||
|
||||
```typescript
|
||||
socialProviders: {
|
||||
github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID as string,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
|
||||
scope: ["user:email"],
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
### Accessing OAuth Tokens
|
||||
|
||||
To access stored OAuth tokens for API calls:
|
||||
|
||||
```typescript
|
||||
import { db } from "@your-app/database";
|
||||
import { account } from "@your-app/database/drizzle/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
export async function getOAuthToken(userId: string, provider: string) {
|
||||
const accountRecord = await db.query.account.findFirst({
|
||||
where: and(
|
||||
eq(account.userId, userId),
|
||||
eq(account.providerId, provider)
|
||||
),
|
||||
});
|
||||
|
||||
if (!accountRecord) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: accountRecord.accessToken,
|
||||
refreshToken: accountRecord.refreshToken,
|
||||
expiresAt: accountRecord.accessTokenExpiresAt,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Token Refresh
|
||||
|
||||
better-auth handles token refresh automatically. For manual refresh:
|
||||
|
||||
```typescript
|
||||
import { auth } from "@your-app/auth";
|
||||
|
||||
export async function refreshOAuthToken(userId: string, provider: string) {
|
||||
// Use auth API to refresh token
|
||||
const result = await auth.api.refreshAccessToken({
|
||||
userId,
|
||||
providerId: provider,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Error Handling
|
||||
|
||||
### Standard Auth Errors
|
||||
|
||||
Use oRPC error codes for authentication failures:
|
||||
|
||||
```typescript
|
||||
import { ORPCError } from "@orpc/server";
|
||||
|
||||
// User not authenticated
|
||||
throw new ORPCError("UNAUTHORIZED");
|
||||
|
||||
// User authenticated but lacks permission
|
||||
throw new ORPCError("FORBIDDEN", {
|
||||
message: "Admin access required",
|
||||
});
|
||||
|
||||
// Session expired
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "Session expired, please login again",
|
||||
});
|
||||
```
|
||||
|
||||
### Error Response Pattern
|
||||
|
||||
```typescript
|
||||
// Consistent error response structure
|
||||
export const authErrorSchema = z.object({
|
||||
success: z.literal(false),
|
||||
reason: z.string(),
|
||||
code: z.enum(["UNAUTHORIZED", "FORBIDDEN", "SESSION_EXPIRED"]).optional(),
|
||||
});
|
||||
|
||||
// In handler
|
||||
if (!hasPermission) {
|
||||
return {
|
||||
success: false,
|
||||
reason: "You do not have permission to perform this action",
|
||||
code: "FORBIDDEN",
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Handling Session Expiration
|
||||
|
||||
```typescript
|
||||
// Graceful session expiration handling
|
||||
export async function handleSessionExpiration(sessionToken: string) {
|
||||
// Clear cache
|
||||
await deleteSessionCache(sessionToken);
|
||||
|
||||
// Log the event
|
||||
logger.info("Session expired", { sessionToken: sessionToken.slice(0, 10) });
|
||||
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "Your session has expired. Please login again.",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Best Practices
|
||||
|
||||
### Always Validate Session in Protected Routes
|
||||
|
||||
```typescript
|
||||
// GOOD - Use protectedProcedure for authenticated endpoints
|
||||
export const updateProfile = protectedProcedure
|
||||
.route({ method: "PATCH", path: "/users/profile" })
|
||||
.handler(async ({ context }) => {
|
||||
// context.user is guaranteed to exist
|
||||
});
|
||||
|
||||
// BAD - Manual session check in public procedure
|
||||
export const updateProfile = publicProcedure
|
||||
.handler(async ({ context }) => {
|
||||
const session = await getSession(context.headers);
|
||||
if (!session) throw new ORPCError("UNAUTHORIZED");
|
||||
// Error-prone and inconsistent
|
||||
});
|
||||
```
|
||||
|
||||
### Use Middleware for Reusable Auth Checks
|
||||
|
||||
```typescript
|
||||
// Create reusable middleware for common patterns
|
||||
const withOrganization = async ({ context, input, next }) => {
|
||||
const { organizationId } = input;
|
||||
|
||||
const membership = await verifyOrganizationMembership(
|
||||
organizationId,
|
||||
context.user.id
|
||||
);
|
||||
|
||||
if (!membership) {
|
||||
throw new ORPCError("FORBIDDEN", {
|
||||
message: "Not a member of this organization",
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
context: { ...context, organization: membership.organization },
|
||||
});
|
||||
};
|
||||
|
||||
// Use in procedures
|
||||
export const getOrganizationData = protectedProcedure
|
||||
.use(withOrganization)
|
||||
.handler(async ({ context }) => {
|
||||
// context.organization is now available
|
||||
});
|
||||
```
|
||||
|
||||
### Proper Error Responses
|
||||
|
||||
```typescript
|
||||
// Always return meaningful error messages
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
const result = await performAction(input);
|
||||
return { success: true, reason: "Action completed", data: result };
|
||||
} catch (error) {
|
||||
if (error instanceof ORPCError) {
|
||||
throw error; // Re-throw oRPC errors
|
||||
}
|
||||
|
||||
logger.error("Action failed", { error, userId: context.user.id });
|
||||
|
||||
return {
|
||||
success: false,
|
||||
reason: "An unexpected error occurred",
|
||||
};
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Secure Session Token Handling
|
||||
|
||||
```typescript
|
||||
// Never log full session tokens
|
||||
logger.info("Session validated", {
|
||||
sessionToken: `${token.substring(0, 10)}...`,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
// Clear sensitive data from responses
|
||||
const sanitizedUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
// Don't include: passwordHash, sessionTokens, etc.
|
||||
};
|
||||
```
|
||||
|
||||
### Cache Invalidation on Auth Events
|
||||
|
||||
```typescript
|
||||
// In auth hooks
|
||||
hooks: {
|
||||
after: createAuthMiddleware(async (ctx) => {
|
||||
if (ctx.path.startsWith("/sign-out")) {
|
||||
const sessionToken = getSessionTokenFromHeaders(ctx.headers);
|
||||
if (sessionToken) {
|
||||
await deleteSessionCache(sessionToken);
|
||||
}
|
||||
}
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
## Client-side Auth Usage
|
||||
|
||||
For client-side authentication, use the auth client:
|
||||
|
||||
```typescript
|
||||
// packages/auth/client.ts
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import {
|
||||
adminClient,
|
||||
magicLinkClient,
|
||||
organizationClient,
|
||||
passkeyClient,
|
||||
twoFactorClient,
|
||||
} from "better-auth/client/plugins";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [
|
||||
magicLinkClient(),
|
||||
organizationClient(),
|
||||
adminClient(),
|
||||
passkeyClient(),
|
||||
twoFactorClient(),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
**Usage in React components:**
|
||||
|
||||
```typescript
|
||||
import { authClient } from "@your-app/auth/client";
|
||||
|
||||
// Sign in
|
||||
await authClient.signIn.email({
|
||||
email: "user@example.com",
|
||||
password: "password",
|
||||
});
|
||||
|
||||
// Sign out
|
||||
await authClient.signOut();
|
||||
|
||||
// Get current session
|
||||
const session = await authClient.getSession();
|
||||
|
||||
// Use hooks
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | Solution |
|
||||
|------|----------|
|
||||
| Require authentication | Use `protectedProcedure` |
|
||||
| Require admin role | Use `adminProcedure` |
|
||||
| Get current user | Access `context.user` in handler |
|
||||
| Get session data | Access `context.session` in handler |
|
||||
| Check organization membership | Use `verifyOrganizationMembership` helper |
|
||||
| Throw auth error | `throw new ORPCError("UNAUTHORIZED")` |
|
||||
| Throw permission error | `throw new ORPCError("FORBIDDEN")` |
|
||||
419
.trellis/spec/backend/database.md
Normal file
419
.trellis/spec/backend/database.md
Normal file
@@ -0,0 +1,419 @@
|
||||
# Database Operations
|
||||
|
||||
This document covers database best practices using Drizzle ORM with PostgreSQL.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
### 1. NO `await` in Loops (N+1 Problem)
|
||||
|
||||
Never use `await` inside a loop. This creates the N+1 query problem, causing severe performance degradation.
|
||||
|
||||
```typescript
|
||||
// BAD - N+1 queries (1 query per iteration)
|
||||
const orders = await db.select().from(orderTable).where(eq(orderTable.userId, userId));
|
||||
for (const order of orders) {
|
||||
const items = await db.select().from(orderItemTable).where(eq(orderItemTable.orderId, order.id));
|
||||
order.items = items;
|
||||
}
|
||||
|
||||
// GOOD - 2 queries total using inArray
|
||||
const orders = await db.select().from(orderTable).where(eq(orderTable.userId, userId));
|
||||
const orderIds = orders.map(o => o.id);
|
||||
|
||||
// Single query for all items
|
||||
const allItems = await db
|
||||
.select()
|
||||
.from(orderItemTable)
|
||||
.where(inArray(orderItemTable.orderId, orderIds));
|
||||
|
||||
// Group items by orderId in memory
|
||||
const itemsByOrder = new Map<string, typeof allItems>();
|
||||
for (const item of allItems) {
|
||||
const existing = itemsByOrder.get(item.orderId) || [];
|
||||
existing.push(item);
|
||||
itemsByOrder.set(item.orderId, existing);
|
||||
}
|
||||
|
||||
// Attach to orders
|
||||
const ordersWithItems = orders.map(order => ({
|
||||
...order,
|
||||
items: itemsByOrder.get(order.id) || [],
|
||||
}));
|
||||
```
|
||||
|
||||
### 2. Batch Insert Pattern
|
||||
|
||||
Use batch inserts for multiple records instead of individual inserts.
|
||||
|
||||
```typescript
|
||||
// BAD - Multiple insert statements
|
||||
for (const item of items) {
|
||||
await db.insert(orderItemTable).values(item);
|
||||
}
|
||||
|
||||
// GOOD - Single batch insert
|
||||
await db.insert(orderItemTable).values(items);
|
||||
|
||||
// With returning clause
|
||||
const insertedItems = await db
|
||||
.insert(orderItemTable)
|
||||
.values(items)
|
||||
.returning();
|
||||
```
|
||||
|
||||
### 3. Conflict Handling with `onConflictDoUpdate`
|
||||
|
||||
Handle upserts efficiently with conflict resolution.
|
||||
|
||||
```typescript
|
||||
// Upsert single record
|
||||
await db
|
||||
.insert(userSettingsTable)
|
||||
.values({
|
||||
userId,
|
||||
theme: "dark",
|
||||
notifications: true,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: userSettingsTable.userId,
|
||||
set: {
|
||||
theme: sql`excluded.theme`,
|
||||
notifications: sql`excluded.notifications`,
|
||||
updatedAt: sql`NOW()`,
|
||||
},
|
||||
});
|
||||
|
||||
// Batch upsert with composite key
|
||||
const upsertedRecords = await db
|
||||
.insert(inventoryTable)
|
||||
.values(inventoryData)
|
||||
.onConflictDoUpdate({
|
||||
target: [inventoryTable.warehouseId, inventoryTable.productId],
|
||||
set: {
|
||||
quantity: sql`excluded.quantity`,
|
||||
updatedAt: sql`NOW()`,
|
||||
},
|
||||
})
|
||||
.returning({
|
||||
id: inventoryTable.id,
|
||||
productId: inventoryTable.productId,
|
||||
});
|
||||
```
|
||||
|
||||
## Query Organization
|
||||
|
||||
Database queries should be organized in `packages/database/drizzle/queries/`.
|
||||
|
||||
```
|
||||
packages/database/drizzle/queries/
|
||||
├── index.ts # Re-exports all query modules
|
||||
├── types.ts # Shared query types
|
||||
├── users.ts # User-related queries
|
||||
├── orders.ts # Order-related queries
|
||||
└── products.ts # Product-related queries
|
||||
```
|
||||
|
||||
**Example: `queries/orders.ts`**
|
||||
|
||||
```typescript
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { db } from "../client";
|
||||
import { order as orderTable, orderItem as orderItemTable } from "../schema/postgres";
|
||||
|
||||
/**
|
||||
* Bulk upsert orders with conflict handling
|
||||
*/
|
||||
export async function bulkUpsertOrders(
|
||||
ordersData: Array<{
|
||||
externalId: string;
|
||||
customerId: string;
|
||||
status: string;
|
||||
total: number;
|
||||
}>,
|
||||
) {
|
||||
if (ordersData.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const upserted = await db
|
||||
.insert(orderTable)
|
||||
.values(ordersData)
|
||||
.onConflictDoUpdate({
|
||||
target: [orderTable.externalId],
|
||||
set: {
|
||||
status: sql`excluded.status`,
|
||||
total: sql`excluded.total`,
|
||||
updatedAt: sql`NOW()`,
|
||||
},
|
||||
})
|
||||
.returning({
|
||||
id: orderTable.id,
|
||||
externalId: orderTable.externalId,
|
||||
});
|
||||
|
||||
return upserted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get orders with items for a user
|
||||
*/
|
||||
export async function getOrdersWithItems(params: {
|
||||
userId: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
const { userId, limit = 20 } = params;
|
||||
|
||||
const orders = await db
|
||||
.select()
|
||||
.from(orderTable)
|
||||
.where(eq(orderTable.userId, userId))
|
||||
.orderBy(desc(orderTable.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
if (orders.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const orderIds = orders.map(o => o.id);
|
||||
const items = await db
|
||||
.select()
|
||||
.from(orderItemTable)
|
||||
.where(inArray(orderItemTable.orderId, orderIds));
|
||||
|
||||
const itemsByOrder = groupBy(items, "orderId");
|
||||
|
||||
return orders.map(order => ({
|
||||
...order,
|
||||
items: itemsByOrder.get(order.id) || [],
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced SQL Patterns
|
||||
|
||||
### JSON Column Operations
|
||||
|
||||
When using PostgreSQL JSON/JSONB columns, proper casting is required for JSON functions.
|
||||
|
||||
```typescript
|
||||
// BAD - Missing cast for jsonb functions
|
||||
const result = await db
|
||||
.select()
|
||||
.from(productTable)
|
||||
.where(sql`${productTable.metadata}->>'category' = 'electronics'`);
|
||||
|
||||
// GOOD - Explicit cast for jsonb operations
|
||||
const result = await db
|
||||
.select()
|
||||
.from(productTable)
|
||||
.where(sql`${productTable.metadata}::jsonb->>'category' = 'electronics'`);
|
||||
|
||||
// JSON array contains check
|
||||
const withTag = await db
|
||||
.select()
|
||||
.from(productTable)
|
||||
.where(sql`${productTable.tags}::jsonb ? 'featured'`);
|
||||
|
||||
// JSON array length
|
||||
const withMultipleTags = await db
|
||||
.select()
|
||||
.from(productTable)
|
||||
.where(sql`jsonb_array_length(${productTable.tags}::jsonb) > 3`);
|
||||
```
|
||||
|
||||
### Raw SQL Column Names (camelCase)
|
||||
|
||||
When using raw SQL with Drizzle, column names must use double quotes for camelCase names.
|
||||
|
||||
```typescript
|
||||
// BAD - PostgreSQL will lowercase unquoted identifiers
|
||||
await db.execute(sql`
|
||||
UPDATE order
|
||||
SET lastUpdatedAt = NOW()
|
||||
WHERE userId = ${userId}
|
||||
`);
|
||||
|
||||
// GOOD - Double quotes preserve camelCase
|
||||
await db.execute(sql`
|
||||
UPDATE "order"
|
||||
SET "lastUpdatedAt" = NOW()
|
||||
WHERE "userId" = ${userId}
|
||||
`);
|
||||
|
||||
// Complex raw SQL example
|
||||
await db.execute(sql`
|
||||
UPDATE "order" AS o
|
||||
SET
|
||||
"totalAmount" = sub."calculatedTotal",
|
||||
"updatedAt" = NOW()
|
||||
FROM (
|
||||
SELECT
|
||||
"orderId",
|
||||
SUM("price" * "quantity") AS "calculatedTotal"
|
||||
FROM "orderItem"
|
||||
WHERE "orderId" = ANY(${sql.raw(arrayLiteral)})
|
||||
GROUP BY "orderId"
|
||||
) AS sub
|
||||
WHERE o.id = sub."orderId"
|
||||
`);
|
||||
```
|
||||
|
||||
### Enum Comparison
|
||||
|
||||
When comparing enum columns in raw SQL, cast the column to text.
|
||||
|
||||
```typescript
|
||||
// BAD - Direct enum comparison may fail
|
||||
await db.execute(sql`
|
||||
SELECT * FROM "order"
|
||||
WHERE status != 'DRAFT'
|
||||
`);
|
||||
|
||||
// GOOD - Cast enum column to text
|
||||
await db.execute(sql`
|
||||
SELECT * FROM "order"
|
||||
WHERE status::text != 'DRAFT'
|
||||
`);
|
||||
|
||||
// In Drizzle query builder (works correctly)
|
||||
const orders = await db
|
||||
.select()
|
||||
.from(orderTable)
|
||||
.where(ne(orderTable.status, "DRAFT"));
|
||||
```
|
||||
|
||||
### Aggregation with Filtering
|
||||
|
||||
Use FILTER clause for conditional aggregation.
|
||||
|
||||
```typescript
|
||||
await db.execute(sql`
|
||||
UPDATE "category" AS c
|
||||
SET
|
||||
"productCount" = sub."count",
|
||||
"activeProductCount" = sub."activeCount",
|
||||
"updatedAt" = NOW()
|
||||
FROM (
|
||||
SELECT
|
||||
"categoryId",
|
||||
COUNT(*)::int AS "count",
|
||||
COUNT(*) FILTER (WHERE "status" = 'ACTIVE')::int AS "activeCount"
|
||||
FROM "product"
|
||||
WHERE "categoryId" = ANY(${sql.raw(categoryIds)})
|
||||
GROUP BY "categoryId"
|
||||
) AS sub
|
||||
WHERE c.id = sub."categoryId"
|
||||
`);
|
||||
```
|
||||
|
||||
## Transaction Patterns
|
||||
|
||||
### Basic Transaction
|
||||
|
||||
```typescript
|
||||
import { db } from "@your-app/database";
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// All operations use tx instead of db
|
||||
const [order] = await tx
|
||||
.insert(orderTable)
|
||||
.values({ userId, total: 0 })
|
||||
.returning();
|
||||
|
||||
await tx.insert(orderItemTable).values(
|
||||
items.map(item => ({
|
||||
orderId: order.id,
|
||||
...item,
|
||||
}))
|
||||
);
|
||||
|
||||
// Update order total
|
||||
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
|
||||
await tx
|
||||
.update(orderTable)
|
||||
.set({ total })
|
||||
.where(eq(orderTable.id, order.id));
|
||||
|
||||
return order;
|
||||
});
|
||||
```
|
||||
|
||||
### Transaction with Rollback
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(orderTable).values(orderData);
|
||||
|
||||
// This will cause rollback if payment fails
|
||||
const paymentResult = await processPayment(orderData.total);
|
||||
if (!paymentResult.success) {
|
||||
throw new Error("Payment failed");
|
||||
}
|
||||
|
||||
await tx.update(orderTable)
|
||||
.set({ paymentId: paymentResult.id })
|
||||
.where(eq(orderTable.id, orderData.id));
|
||||
});
|
||||
} catch (error) {
|
||||
// Transaction automatically rolled back
|
||||
logger.error("Order creation failed", { error });
|
||||
}
|
||||
```
|
||||
|
||||
## Query Performance Tips
|
||||
|
||||
### Use Indexes
|
||||
|
||||
Ensure your queries use appropriate indexes:
|
||||
|
||||
```typescript
|
||||
// Good for index on (userId, createdAt DESC)
|
||||
const recentOrders = await db
|
||||
.select()
|
||||
.from(orderTable)
|
||||
.where(eq(orderTable.userId, userId))
|
||||
.orderBy(desc(orderTable.createdAt))
|
||||
.limit(10);
|
||||
```
|
||||
|
||||
### Select Only Needed Columns
|
||||
|
||||
```typescript
|
||||
// BAD - Selects all columns including large text fields
|
||||
const orders = await db.select().from(orderTable);
|
||||
|
||||
// GOOD - Select only needed columns
|
||||
const orders = await db
|
||||
.select({
|
||||
id: orderTable.id,
|
||||
status: orderTable.status,
|
||||
total: orderTable.total,
|
||||
})
|
||||
.from(orderTable);
|
||||
```
|
||||
|
||||
### Use Relations for Complex Queries
|
||||
|
||||
```typescript
|
||||
// Using Drizzle relations for nested data
|
||||
const ordersWithDetails = await db.query.order.findMany({
|
||||
where: eq(orderTable.userId, userId),
|
||||
with: {
|
||||
items: {
|
||||
with: {
|
||||
product: true,
|
||||
},
|
||||
},
|
||||
customer: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: (orders, { desc }) => desc(orders.createdAt),
|
||||
limit: 20,
|
||||
});
|
||||
```
|
||||
252
.trellis/spec/backend/directory-structure.md
Normal file
252
.trellis/spec/backend/directory-structure.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# Directory Structure
|
||||
|
||||
This document describes the module organization pattern for backend API development.
|
||||
|
||||
## Module Structure
|
||||
|
||||
Each API module follows a consistent directory structure:
|
||||
|
||||
```
|
||||
packages/api/modules/[module]/
|
||||
├── types.ts # Zod schemas and TypeScript types
|
||||
├── router.ts # Hono router with route definitions
|
||||
├── lib/ # Core business logic (shared across procedures)
|
||||
│ ├── client.ts # External service clients
|
||||
│ └── helpers.ts # Helper functions
|
||||
├── procedures/ # HTTP endpoint handlers
|
||||
│ ├── create.ts
|
||||
│ ├── update.ts
|
||||
│ ├── delete.ts
|
||||
│ └── list.ts
|
||||
└── api/ # API documentation (optional)
|
||||
├── create.md
|
||||
└── list.md
|
||||
```
|
||||
|
||||
## File Responsibilities
|
||||
|
||||
### `types.ts` - Schemas and Types
|
||||
|
||||
Define all Zod schemas and TypeScript types for the module.
|
||||
|
||||
```typescript
|
||||
// types.ts
|
||||
import { z } from "zod";
|
||||
|
||||
// Input Schemas
|
||||
export const createOrderInputSchema = z.object({
|
||||
customerId: z.string(),
|
||||
items: z.array(z.object({
|
||||
productId: z.string(),
|
||||
quantity: z.number().min(1),
|
||||
})).min(1),
|
||||
});
|
||||
|
||||
// Output Schemas
|
||||
export const orderResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
reason: z.string(),
|
||||
order: z.object({
|
||||
id: z.string(),
|
||||
status: z.string(),
|
||||
total: z.number(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
// Type exports
|
||||
export type CreateOrderInput = z.infer<typeof createOrderInputSchema>;
|
||||
export type OrderResponse = z.infer<typeof orderResponseSchema>;
|
||||
```
|
||||
|
||||
### `router.ts` - Route Definitions
|
||||
|
||||
The router aggregates all procedures and defines the API routes.
|
||||
|
||||
```typescript
|
||||
// router.ts
|
||||
import { Hono } from "hono";
|
||||
import { createOrder } from "./procedures/create";
|
||||
import { listOrders } from "./procedures/list";
|
||||
import { updateOrderStatus } from "./procedures/update";
|
||||
|
||||
export const ordersRouter = new Hono()
|
||||
.basePath("/orders")
|
||||
.post("/", createOrder)
|
||||
.get("/", listOrders)
|
||||
.patch("/:id/status", updateOrderStatus);
|
||||
```
|
||||
|
||||
### `lib/` - Business Logic
|
||||
|
||||
Contains reusable business logic shared across procedures.
|
||||
|
||||
```
|
||||
lib/
|
||||
├── client.ts # External API clients (payment gateway, etc.)
|
||||
├── helpers.ts # Pure helper functions
|
||||
├── validators.ts # Business rule validators
|
||||
└── transformers.ts # Data transformation utilities
|
||||
```
|
||||
|
||||
**Example: `lib/helpers.ts`**
|
||||
|
||||
```typescript
|
||||
// lib/helpers.ts
|
||||
import type { Order } from "../types";
|
||||
|
||||
/**
|
||||
* Calculate order total with tax
|
||||
*/
|
||||
export function calculateOrderTotal(
|
||||
items: Array<{ price: number; quantity: number }>,
|
||||
taxRate: number = 0.1,
|
||||
): number {
|
||||
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
|
||||
return subtotal * (1 + taxRate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate order reference number
|
||||
*/
|
||||
export function generateOrderReference(timestamp: Date): string {
|
||||
const year = timestamp.getFullYear();
|
||||
const month = String(timestamp.getMonth() + 1).padStart(2, "0");
|
||||
const random = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
return `ORD-${year}${month}-${random}`;
|
||||
}
|
||||
```
|
||||
|
||||
### `procedures/` - Endpoint Handlers
|
||||
|
||||
Each procedure handles a single API endpoint with clear responsibilities.
|
||||
|
||||
```typescript
|
||||
// procedures/create.ts
|
||||
import { db } from "@your-app/database";
|
||||
import { order as orderTable } from "@your-app/database/drizzle/schema/postgres";
|
||||
import { logger } from "@your-app/logs";
|
||||
import { protectedProcedure } from "../../../orpc/procedures";
|
||||
import { calculateOrderTotal, generateOrderReference } from "../lib/helpers";
|
||||
import { createOrderInputSchema, orderResponseSchema } from "../types";
|
||||
|
||||
export const createOrder = protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/orders",
|
||||
tags: ["Orders"],
|
||||
summary: "Create a new order",
|
||||
})
|
||||
.input(createOrderInputSchema)
|
||||
.output(orderResponseSchema)
|
||||
.handler(async ({ input, context: { user } }) => {
|
||||
const { customerId, items } = input;
|
||||
|
||||
// Business logic
|
||||
const total = calculateOrderTotal(items);
|
||||
const reference = generateOrderReference(new Date());
|
||||
|
||||
// Database operation
|
||||
const [newOrder] = await db
|
||||
.insert(orderTable)
|
||||
.values({
|
||||
userId: user.id,
|
||||
customerId,
|
||||
reference,
|
||||
total,
|
||||
status: "PENDING",
|
||||
})
|
||||
.returning();
|
||||
|
||||
logger.info("Order created", {
|
||||
orderId: newOrder.id,
|
||||
userId: user.id,
|
||||
total,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
reason: "Order created successfully",
|
||||
order: {
|
||||
id: newOrder.id,
|
||||
status: newOrder.status,
|
||||
total: newOrder.total,
|
||||
},
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
### `api/` - Documentation (Optional)
|
||||
|
||||
Markdown documentation for each endpoint, useful for complex APIs.
|
||||
|
||||
```markdown
|
||||
<!-- api/create.md -->
|
||||
# Create Order
|
||||
|
||||
## Schema
|
||||
|
||||
### Input
|
||||
- `customerId`: string - Customer identifier
|
||||
- `items`: array - Order items
|
||||
- `productId`: string - Product identifier
|
||||
- `quantity`: number - Quantity (min: 1)
|
||||
|
||||
### Output
|
||||
- `success`: boolean
|
||||
- `reason`: string
|
||||
- `order`: object (optional)
|
||||
|
||||
## Logic
|
||||
|
||||
1. Validate user has permission to create orders for the customer
|
||||
2. Verify all products exist and are in stock
|
||||
3. Calculate total with applicable discounts
|
||||
4. Create order record
|
||||
5. Reserve inventory
|
||||
6. Return order details
|
||||
|
||||
## Usage Example
|
||||
|
||||
```typescript
|
||||
const result = await api.orders.create({
|
||||
customerId: "cust_123",
|
||||
items: [
|
||||
{ productId: "prod_456", quantity: 2 },
|
||||
],
|
||||
});
|
||||
```
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Files
|
||||
|
||||
| Type | Convention | Example |
|
||||
|------|------------|---------|
|
||||
| Procedures | Verb-based | `create.ts`, `list.ts`, `update-status.ts` |
|
||||
| Lib files | Noun-based | `helpers.ts`, `validators.ts`, `client.ts` |
|
||||
| Types | Always `types.ts` | `types.ts` |
|
||||
| Router | Always `router.ts` | `router.ts` |
|
||||
|
||||
### Exports
|
||||
|
||||
| Type | Convention | Example |
|
||||
|------|------------|---------|
|
||||
| Schemas | `{name}Schema` suffix | `createOrderInputSchema` |
|
||||
| Types | PascalCase | `CreateOrderInput` |
|
||||
| Procedures | camelCase verb | `createOrder`, `listOrders` |
|
||||
| Helpers | camelCase verb | `calculateTotal`, `generateReference` |
|
||||
|
||||
## When to Create New Modules
|
||||
|
||||
Create a new module when:
|
||||
|
||||
1. The feature represents a distinct domain entity (users, orders, products)
|
||||
2. The feature has multiple related operations (CRUD + custom actions)
|
||||
3. The feature will be reused across multiple routes
|
||||
|
||||
Avoid creating modules for:
|
||||
|
||||
1. Single-use utility functions (place in existing `lib/`)
|
||||
2. Simple helpers (place in `@your-app/utils`)
|
||||
3. Database queries only (place in `packages/database/drizzle/queries/`)
|
||||
154
.trellis/spec/backend/index.md
Normal file
154
.trellis/spec/backend/index.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Backend Development Guidelines Index
|
||||
|
||||
> **Tech Stack**: Next.js 15 API Routes + oRPC + Drizzle ORM + PostgreSQL
|
||||
|
||||
## Related Guidelines
|
||||
|
||||
| Guideline | Location | When to Read |
|
||||
| ------------------------- | ------------ | ---------------------------- |
|
||||
| **Shared Code Standards** | `../shared/` | Always - applies to all code |
|
||||
|
||||
---
|
||||
|
||||
## Documentation Files
|
||||
|
||||
| File | Description | When to Read |
|
||||
| ---------------------------------------------------- | -------------------------------------------------- | ---------------------------------- |
|
||||
| [directory-structure.md](./directory-structure.md) | Module organization and directory layout | Starting a new feature |
|
||||
| [orpc-usage.md](./orpc-usage.md) | oRPC router, procedures, middleware patterns | Creating/modifying API endpoints |
|
||||
| [type-safety.md](./type-safety.md) | Zod schemas, type narrowing, response patterns | Type-related decisions |
|
||||
| [database.md](./database.md) | Drizzle ORM, queries, transactions, SQL patterns | Database operations |
|
||||
| [authentication.md](./authentication.md) | better-auth, sessions, OAuth, protected procedures | Auth-related features |
|
||||
| [logging.md](./logging.md) | Structured logging, Sentry tracing, telemetry | Debugging, observability |
|
||||
| [local-json-mvp.md](./local-json-mvp.md) | Local JSON persistence/session/API contracts before database adoption | Single-repo MVP features without DB/oRPC deps |
|
||||
| [performance.md](./performance.md) | Concurrency, caching, batch processing, streaming | Performance optimization |
|
||||
| [ai-sdk-integration.md](./ai-sdk-integration.md) | Vercel AI SDK, tool calling, prompt patterns | AI-powered features |
|
||||
| [quality.md](./quality.md) | Pre-commit checklist for backend code | Before committing |
|
||||
|
||||
---
|
||||
|
||||
## Quick Navigation
|
||||
|
||||
### Service Module Structure
|
||||
|
||||
| Task | File |
|
||||
| ----------------------------- | -------------------------------------------------- |
|
||||
| Project structure | [directory-structure.md](./directory-structure.md) |
|
||||
| Domain module pattern | [directory-structure.md](./directory-structure.md) |
|
||||
| Write types.ts | [directory-structure.md](./directory-structure.md) |
|
||||
| Write procedure | [directory-structure.md](./directory-structure.md) |
|
||||
| Write lib/ helpers | [directory-structure.md](./directory-structure.md) |
|
||||
| Router setup | [orpc-usage.md](./orpc-usage.md) |
|
||||
| Middleware composition | [orpc-usage.md](./orpc-usage.md) |
|
||||
| Naming conventions | [directory-structure.md](./directory-structure.md) |
|
||||
|
||||
### Type Safety
|
||||
|
||||
| Task | File |
|
||||
| -------------------- | ---------------------------------- |
|
||||
| Type safety patterns | [type-safety.md](./type-safety.md) |
|
||||
| Discriminated unions | [type-safety.md](./type-safety.md) |
|
||||
| Zod-first types | [type-safety.md](./type-safety.md) |
|
||||
| Zod error handling | [type-safety.md](./type-safety.md) |
|
||||
| Standard response | [type-safety.md](./type-safety.md) |
|
||||
|
||||
### Database (Drizzle + PostgreSQL)
|
||||
|
||||
| Task | File |
|
||||
| ----------------------- | ---------------------------- |
|
||||
| Query organization | [database.md](./database.md) |
|
||||
| Batch queries (inArray) | [database.md](./database.md) |
|
||||
| Conflict handling | [database.md](./database.md) |
|
||||
| Transactions | [database.md](./database.md) |
|
||||
| JSON column operations | [database.md](./database.md) |
|
||||
| Raw SQL camelCase | [database.md](./database.md) |
|
||||
| Enum comparison | [database.md](./database.md) |
|
||||
|
||||
### Error Handling / Logging
|
||||
|
||||
| Task | File |
|
||||
| --------------------------- | ------------------------------ |
|
||||
| Structured logging | [logging.md](./logging.md) |
|
||||
| Sentry span tracing | [logging.md](./logging.md) |
|
||||
| Error capture | [logging.md](./logging.md) |
|
||||
| oRPC error codes | [orpc-usage.md](./orpc-usage.md) |
|
||||
| Batch operation logging | [logging.md](./logging.md) |
|
||||
|
||||
### Performance
|
||||
|
||||
| Task | File |
|
||||
| ----------------------------- | ---------------------------------- |
|
||||
| Parallel execution | [performance.md](./performance.md) |
|
||||
| Concurrency control (p-limit) | [performance.md](./performance.md) |
|
||||
| Exponential backoff retry | [performance.md](./performance.md) |
|
||||
| Redis caching | [performance.md](./performance.md) |
|
||||
| Distributed locks | [performance.md](./performance.md) |
|
||||
| Chunked batch processing | [performance.md](./performance.md) |
|
||||
| Streaming large datasets | [performance.md](./performance.md) |
|
||||
|
||||
### Authentication
|
||||
|
||||
| Task | File |
|
||||
| --------------------------- | -------------------------------------- |
|
||||
| Protected procedures | [authentication.md](./authentication.md) |
|
||||
| Admin procedures | [authentication.md](./authentication.md) |
|
||||
| Session caching (Redis) | [authentication.md](./authentication.md) |
|
||||
| OAuth integration | [authentication.md](./authentication.md) |
|
||||
| Role-based access control | [authentication.md](./authentication.md) |
|
||||
| Client-side auth | [authentication.md](./authentication.md) |
|
||||
|
||||
### AI Integration
|
||||
|
||||
| Task | File |
|
||||
| --------------------------- | ---------------------------------------------- |
|
||||
| generateText / generateObject | [ai-sdk-integration.md](./ai-sdk-integration.md) |
|
||||
| Streaming (streamText) | [ai-sdk-integration.md](./ai-sdk-integration.md) |
|
||||
| Tool calling | [ai-sdk-integration.md](./ai-sdk-integration.md) |
|
||||
| Telemetry configuration | [ai-sdk-integration.md](./ai-sdk-integration.md) |
|
||||
| Prompt engineering | [ai-sdk-integration.md](./ai-sdk-integration.md) |
|
||||
| AI error handling | [ai-sdk-integration.md](./ai-sdk-integration.md) |
|
||||
|
||||
---
|
||||
|
||||
## Core Rules Summary
|
||||
|
||||
| Rule | Reference |
|
||||
| ---------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| **No `await` in loops** - use `inArray` for batch queries | [database.md](./database.md) |
|
||||
| **No `console.log`** - use structured logger | [logging.md](./logging.md) |
|
||||
| **No non-null assertions `!`** - use type narrowing | [type-safety.md](./type-safety.md) |
|
||||
| **All API inputs/outputs have Zod schemas** | [type-safety.md](./type-safety.md) |
|
||||
| **Import enums from utils** - not from database package | [type-safety.md](./type-safety.md) |
|
||||
| **Standard response format** - always include `success`/`reason` | [type-safety.md](./type-safety.md) |
|
||||
| **Use `protectedProcedure`** for authenticated endpoints | [authentication.md](./authentication.md) |
|
||||
| **One procedure per file** - keep procedures focused | [orpc-usage.md](./orpc-usage.md) |
|
||||
| **Service modules follow domain layout** | [directory-structure.md](./directory-structure.md) |
|
||||
| **Use `Promise.all`** for independent parallel operations | [performance.md](./performance.md) |
|
||||
| **Use `p-limit`** for external API concurrency control | [performance.md](./performance.md) |
|
||||
| **Always enable AI telemetry** for token tracking | [ai-sdk-integration.md](./ai-sdk-integration.md) |
|
||||
| **Cast `::jsonb`** for PostgreSQL JSON operations | [database.md](./database.md) |
|
||||
| **Double-quote camelCase** column names in raw SQL | [database.md](./database.md) |
|
||||
| **Use structured context** in logs - no string interpolation | [logging.md](./logging.md) |
|
||||
| **Run pre-commit checklist** before committing | [quality.md](./quality.md) |
|
||||
|
||||
---
|
||||
|
||||
## Reference Files
|
||||
|
||||
| Feature | Typical Location |
|
||||
| -------------------- | --------------------------------------- |
|
||||
| Drizzle Client | `packages/database/drizzle/client.ts` |
|
||||
| Schema Definition | `packages/database/drizzle/schema/` |
|
||||
| Database Queries | `packages/database/drizzle/queries/` |
|
||||
| oRPC Router | `packages/api/orpc/router.ts` |
|
||||
| Base Procedures | `packages/api/orpc/procedures.ts` |
|
||||
| Middleware | `packages/api/orpc/middleware/` |
|
||||
| Service Module | `packages/api/modules/{domain}/` |
|
||||
| Module Types (Zod) | `packages/api/modules/{domain}/types.ts`|
|
||||
| Auth Configuration | `packages/auth/auth.ts` |
|
||||
| Auth Client | `packages/auth/client.ts` |
|
||||
| Shared Utils/Enums | `packages/utils/` |
|
||||
|
||||
---
|
||||
|
||||
**Language**: All documentation must be written in **English**.
|
||||
86
.trellis/spec/backend/local-json-mvp.md
Normal file
86
.trellis/spec/backend/local-json-mvp.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Local JSON MVP Persistence and API Contracts
|
||||
|
||||
## Scenario: Single-Repo MVP Before Database Adoption
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: implementing full-stack CRUD, auth, RBAC, or audit logging before Drizzle/Postgres/oRPC/better-auth are installed.
|
||||
- Applies to this single-package Next.js app when the feature must be real and durable in local/runtime execution, but production database infrastructure is not yet available.
|
||||
- Store boundary: `modules/core/server/store.ts`.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- `readData(): Promise<AppData>`
|
||||
- `writeData(data: AppData): Promise<void>`
|
||||
- `updateData<T>(mutator: (data: AppData) => T): Promise<T>`
|
||||
- `getCurrentAuthContext(): Promise<AuthContext | null>`
|
||||
- `requirePermission(permission: Permission, auditContext: DeniedAuditContext): Promise<PermissionCheckSuccess | PermissionCheckFailure>`
|
||||
- Route Handlers use standard `GET`, `POST`, `PATCH`, and `DELETE` exports and return `Response`.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- Default data file: `.data/teatea.json`.
|
||||
- Override key: `TEATEA_DATA_DIR` points to the directory containing `teatea.json`.
|
||||
- `.data/` must stay ignored; it may contain account hashes and session IDs.
|
||||
- API response shape:
|
||||
|
||||
```ts
|
||||
type ApiResult<T extends Record<string, unknown>> =
|
||||
| ({ success: true; reason: string } & T)
|
||||
| { success: false; reason: string };
|
||||
```
|
||||
|
||||
- Session cookie:
|
||||
- Name: `teatea_session`
|
||||
- Flags: `httpOnly`, `sameSite: "lax"`, `path: "/"`
|
||||
- Lifetime: 7 days
|
||||
- Passwords:
|
||||
- Never persist plaintext.
|
||||
- Use Node crypto salt + scrypt hash until a dedicated auth library is introduced.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- Missing/expired session -> `401` with `{ success: false, reason: "未登录或会话已过期" }`.
|
||||
- Missing permission -> `403` with `{ success: false, reason: "权限不足" }` and a denied audit log.
|
||||
- Invalid JSON body -> `400` with a Chinese user-facing `reason`.
|
||||
- Missing record by ID -> `404` with `{ success: false, reason: "<entity>不存在" }`.
|
||||
- Duplicate account email -> `409` with `{ success: false, reason: "账号已存在" }`.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: UI submits to Route Handler, Route Handler validates input, calls `requirePermission`, mutates data through `updateData`, writes audit log, returns `ApiResult`.
|
||||
- Base: Server Component reads data directly via `readData` after session/permission checks.
|
||||
- Bad: UI or route handler reads `.data/teatea.json` directly, bypasses `requirePermission`, or stores auth state in localStorage.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- `pnpm lint`
|
||||
- `pnpm type-check`
|
||||
- `pnpm build`
|
||||
- Manual or automated integration assertions:
|
||||
- first setup creates admin and cookie session
|
||||
- protected app route redirects without cookie
|
||||
- CRUD mutation persists across reload/API list
|
||||
- denied permission returns 403 and writes an audit log
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```ts
|
||||
window.localStorage.setItem("teatea.session", JSON.stringify(session));
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```ts
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set({
|
||||
name: "teatea_session",
|
||||
value: sessionId,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
});
|
||||
```
|
||||
|
||||
340
.trellis/spec/backend/logging.md
Normal file
340
.trellis/spec/backend/logging.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# Logging and Monitoring
|
||||
|
||||
This document covers structured logging, error tracking with Sentry, and observability patterns.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
### NO `console.log` - Use Structured Logger
|
||||
|
||||
Never use `console.log` in production code. Always use the structured logger from `@your-app/logs`.
|
||||
|
||||
```typescript
|
||||
// BAD - Unstructured console logging
|
||||
console.log("Order created:", orderId);
|
||||
console.error("Failed to process:", error);
|
||||
|
||||
// GOOD - Structured logging
|
||||
import { logger } from "@your-app/logs";
|
||||
|
||||
logger.info("Order created", {
|
||||
orderId,
|
||||
userId,
|
||||
total: order.total,
|
||||
});
|
||||
|
||||
logger.error("Failed to process order", {
|
||||
orderId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
```
|
||||
|
||||
## Logger API
|
||||
|
||||
```typescript
|
||||
import { logger } from "@your-app/logs";
|
||||
|
||||
// Log levels
|
||||
logger.debug("Debug message", { context: "value" });
|
||||
logger.info("Info message", { orderId, status });
|
||||
logger.warn("Warning message", { userId, reason: "quota exceeded" });
|
||||
logger.error("Error message", { error: err.message, stack: err.stack });
|
||||
```
|
||||
|
||||
## Sentry Integration
|
||||
|
||||
### Span Tracing
|
||||
|
||||
Use the tracing system to monitor performance and track operations.
|
||||
|
||||
```typescript
|
||||
import { SpanPrefix, span } from "../../../lib/tracer";
|
||||
|
||||
// Database operations
|
||||
const orders = await span(
|
||||
`${SpanPrefix.DB}GetUserOrders`,
|
||||
() => db.select().from(orderTable).where(eq(orderTable.userId, userId)),
|
||||
{ userId, limit: 20 }
|
||||
);
|
||||
|
||||
// External API calls
|
||||
const response = await span(
|
||||
`${SpanPrefix.Http}FetchInventory`,
|
||||
() => inventoryClient.getStock(productIds),
|
||||
{ productCount: productIds.length }
|
||||
);
|
||||
|
||||
// Redis cache operations
|
||||
const cached = await span(
|
||||
`${SpanPrefix.Redis}GetSession`,
|
||||
() => redis.get(sessionKey),
|
||||
{ sessionKey }
|
||||
);
|
||||
```
|
||||
|
||||
### SpanPrefix Constants
|
||||
|
||||
Use standardized prefixes for consistent Sentry categorization:
|
||||
|
||||
```typescript
|
||||
import { SpanPrefix } from "../../../lib/tracer";
|
||||
|
||||
const SpanPrefix = {
|
||||
/** Database operations - maps to Sentry op: db.query */
|
||||
DB: "DB.",
|
||||
|
||||
/** External HTTP API calls - maps to Sentry op: http.client */
|
||||
Http: "Http.",
|
||||
|
||||
/** Redis cache operations - maps to Sentry op: db.redis */
|
||||
Redis: "Redis.",
|
||||
|
||||
/** AI model invocations - maps to Sentry op: ai.run */
|
||||
AI: "AI.",
|
||||
|
||||
/** Generic cache operations - maps to Sentry op: cache */
|
||||
Cache: "Cache.",
|
||||
|
||||
/** Queue/message operations - maps to Sentry op: queue */
|
||||
Queue: "Queue.",
|
||||
} as const;
|
||||
```
|
||||
|
||||
### Naming Convention
|
||||
|
||||
```typescript
|
||||
// Pattern: ${SpanPrefix.Type}${Action}${Resource}
|
||||
|
||||
// Database
|
||||
`${SpanPrefix.DB}GetUserOrders`
|
||||
`${SpanPrefix.DB}BatchUpdateProducts`
|
||||
`${SpanPrefix.DB}CreateOrder`
|
||||
|
||||
// External APIs
|
||||
`${SpanPrefix.Http}FetchPaymentStatus`
|
||||
`${SpanPrefix.Http}SendNotification`
|
||||
|
||||
// Redis
|
||||
`${SpanPrefix.Redis}GetSession`
|
||||
`${SpanPrefix.Redis}SetCache`
|
||||
|
||||
// AI
|
||||
`${SpanPrefix.AI}ClassifyContent`
|
||||
`${SpanPrefix.AI}GenerateResponse`
|
||||
```
|
||||
|
||||
### Error Capture
|
||||
|
||||
```typescript
|
||||
import { captureError } from "../../../lib/tracer";
|
||||
|
||||
try {
|
||||
await processOrder(orderId);
|
||||
} catch (error) {
|
||||
captureError(error, {
|
||||
tags: {
|
||||
operation: "processOrder",
|
||||
orderId,
|
||||
},
|
||||
extra: {
|
||||
userId: context.user.id,
|
||||
orderStatus: order.status,
|
||||
},
|
||||
});
|
||||
|
||||
throw error; // Re-throw if needed
|
||||
}
|
||||
```
|
||||
|
||||
### Trace Context
|
||||
|
||||
For complex operations, use trace context to correlate logs:
|
||||
|
||||
```typescript
|
||||
import { runWithTrace, getLogId } from "../../../lib/tracer";
|
||||
|
||||
export async function processOrderBatch(orderIds: string[]) {
|
||||
return runWithTrace(`batch-${Date.now()}`, async () => {
|
||||
const logId = getLogId();
|
||||
|
||||
logger.info("Starting batch processing", {
|
||||
logId,
|
||||
orderCount: orderIds.length
|
||||
});
|
||||
|
||||
for (const orderId of orderIds) {
|
||||
await span(
|
||||
`${SpanPrefix.DB}ProcessOrder`,
|
||||
() => processSingleOrder(orderId),
|
||||
{ orderId }
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("Batch processing complete", { logId });
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## AI SDK Telemetry
|
||||
|
||||
When using the Vercel AI SDK, enable telemetry for token tracking:
|
||||
|
||||
```typescript
|
||||
import { generateText } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
|
||||
const result = await generateText({
|
||||
model: openai("gpt-4o"),
|
||||
prompt: userPrompt,
|
||||
experimental_telemetry: {
|
||||
isEnabled: true,
|
||||
functionId: "classify-content",
|
||||
metadata: {
|
||||
userId,
|
||||
contentLength: content.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Telemetry Metadata
|
||||
|
||||
Include relevant context in telemetry:
|
||||
|
||||
```typescript
|
||||
experimental_telemetry: {
|
||||
isEnabled: true,
|
||||
functionId: "generate-response", // Unique identifier for this AI function
|
||||
metadata: {
|
||||
// User context
|
||||
userId: context.user.id,
|
||||
|
||||
// Input metrics
|
||||
promptTokens: estimatedTokens,
|
||||
|
||||
// Business context
|
||||
feature: "auto-reply",
|
||||
priority: "high",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling Patterns
|
||||
|
||||
### Structured Error Logging
|
||||
|
||||
```typescript
|
||||
async function processPayment(orderId: string) {
|
||||
try {
|
||||
const result = await paymentGateway.charge(orderId);
|
||||
|
||||
logger.info("Payment processed", {
|
||||
orderId,
|
||||
transactionId: result.transactionId,
|
||||
amount: result.amount,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error("Payment processing failed", {
|
||||
orderId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
errorCode: (error as any).code,
|
||||
});
|
||||
|
||||
// Capture to Sentry with context
|
||||
captureError(error, {
|
||||
tags: { service: "payment", operation: "charge" },
|
||||
extra: { orderId },
|
||||
});
|
||||
|
||||
throw new ORPCError("INTERNAL_SERVER_ERROR", {
|
||||
message: "Payment processing failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Batch Operation Logging
|
||||
|
||||
```typescript
|
||||
async function batchUpdateInventory(updates: InventoryUpdate[]) {
|
||||
const results: ProcessResult[] = [];
|
||||
|
||||
logger.info("Starting batch inventory update", {
|
||||
updateCount: updates.length,
|
||||
});
|
||||
|
||||
const processed = await Promise.allSettled(
|
||||
updates.map(update => processUpdate(update))
|
||||
);
|
||||
|
||||
const successful = processed.filter(r => r.status === "fulfilled").length;
|
||||
const failed = processed.filter(r => r.status === "rejected").length;
|
||||
|
||||
logger.info("Batch inventory update complete", {
|
||||
total: updates.length,
|
||||
successful,
|
||||
failed,
|
||||
});
|
||||
|
||||
if (failed > 0) {
|
||||
logger.warn("Some inventory updates failed", {
|
||||
failedCount: failed,
|
||||
errors: processed
|
||||
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
|
||||
.map(r => r.reason?.message || "Unknown error"),
|
||||
});
|
||||
}
|
||||
|
||||
return { successful, failed };
|
||||
}
|
||||
```
|
||||
|
||||
## Logging Best Practices
|
||||
|
||||
### What to Log
|
||||
|
||||
**Always log:**
|
||||
- Request/response for external API calls
|
||||
- Database write operations (create, update, delete)
|
||||
- Authentication events
|
||||
- Business-critical operations
|
||||
- Errors and exceptions
|
||||
|
||||
**Log with care (avoid sensitive data):**
|
||||
- User inputs (sanitize PII)
|
||||
- Request payloads (redact secrets)
|
||||
|
||||
**Never log:**
|
||||
- Passwords or tokens
|
||||
- Credit card numbers
|
||||
- Personal identification numbers
|
||||
- API keys or secrets
|
||||
|
||||
### Log Levels Guide
|
||||
|
||||
| Level | Use Case | Example |
|
||||
|-------|----------|---------|
|
||||
| `debug` | Development diagnostics | Variable values, flow tracing |
|
||||
| `info` | Normal operations | Order created, user logged in |
|
||||
| `warn` | Recoverable issues | Rate limit approaching, retry attempted |
|
||||
| `error` | Failures requiring attention | API call failed, database error |
|
||||
|
||||
### Structured Context
|
||||
|
||||
Always include relevant context as structured data:
|
||||
|
||||
```typescript
|
||||
// BAD - String interpolation
|
||||
logger.info(`User ${userId} created order ${orderId} for $${total}`);
|
||||
|
||||
// GOOD - Structured context
|
||||
logger.info("Order created", {
|
||||
userId,
|
||||
orderId,
|
||||
total,
|
||||
currency: "USD",
|
||||
itemCount: items.length,
|
||||
});
|
||||
```
|
||||
805
.trellis/spec/backend/orpc-usage.md
Normal file
805
.trellis/spec/backend/orpc-usage.md
Normal file
@@ -0,0 +1,805 @@
|
||||
# oRPC Backend Usage Guidelines
|
||||
|
||||
## 1. Overview
|
||||
|
||||
### What is oRPC
|
||||
|
||||
oRPC (OpenAPI RPC) is a type-safe RPC framework for TypeScript that provides end-to-end type safety from your backend to frontend. It combines the best aspects of REST APIs and RPC frameworks while generating OpenAPI specifications automatically.
|
||||
|
||||
### Why oRPC over tRPC or plain REST
|
||||
|
||||
| Feature | oRPC | tRPC | REST |
|
||||
|---------|------|------|------|
|
||||
| Type Safety | End-to-end | End-to-end | Manual |
|
||||
| OpenAPI Generation | Built-in | Plugin required | Manual |
|
||||
| HTTP Method Control | Full control | Limited | Full control |
|
||||
| Learning Curve | Low | Low | Medium |
|
||||
| Middleware Support | Native | Native | Framework-dependent |
|
||||
| Schema Validation | Zod native | Zod native | Manual |
|
||||
|
||||
Key advantages of oRPC:
|
||||
- **OpenAPI-first**: Automatic OpenAPI spec generation for documentation and client generation
|
||||
- **HTTP semantics**: Full control over HTTP methods, paths, and tags
|
||||
- **Type inference**: Automatic TypeScript types from Zod schemas
|
||||
- **Middleware composition**: Chainable middleware for auth, logging, etc.
|
||||
|
||||
### Project Structure with oRPC
|
||||
|
||||
```
|
||||
packages/api/
|
||||
├── orpc/
|
||||
│ ├── router.ts # Main router composition
|
||||
│ ├── procedures.ts # Base procedure definitions
|
||||
│ └── middleware/ # Reusable middleware
|
||||
│ ├── log-id-middleware.ts
|
||||
│ └── locale-middleware.ts
|
||||
├── modules/
|
||||
│ └── [module]/
|
||||
│ ├── router.ts # Module router exports
|
||||
│ ├── types.ts # Zod schemas and TypeScript types
|
||||
│ └── procedures/ # Individual procedure implementations
|
||||
│ ├── create-item.ts
|
||||
│ ├── list-items.ts
|
||||
│ └── update-item.ts
|
||||
└── lib/ # Shared utilities
|
||||
```
|
||||
|
||||
## 2. Router Setup
|
||||
|
||||
### Main Router Structure
|
||||
|
||||
The main router composes all module routers under a common prefix:
|
||||
|
||||
```typescript
|
||||
// orpc/router.ts
|
||||
import type { RouterClient } from "@orpc/server";
|
||||
import { usersRouter } from "../modules/users/router";
|
||||
import { itemsRouter } from "../modules/items/router";
|
||||
import { publicProcedure } from "./procedures";
|
||||
|
||||
export const router = publicProcedure
|
||||
// Prefix for OpenAPI paths
|
||||
.prefix("/api")
|
||||
.router({
|
||||
users: usersRouter,
|
||||
items: itemsRouter,
|
||||
// Add more module routers here
|
||||
});
|
||||
|
||||
// Export type for frontend client
|
||||
export type ApiRouterClient = RouterClient<typeof router>;
|
||||
```
|
||||
|
||||
### Module Router Composition
|
||||
|
||||
Each module exports a router object that groups related procedures:
|
||||
|
||||
```typescript
|
||||
// modules/items/router.ts
|
||||
import { createItem } from "./procedures/create-item";
|
||||
import { deleteItem } from "./procedures/delete-item";
|
||||
import { findItem } from "./procedures/find-item";
|
||||
import { listItems } from "./procedures/list-items";
|
||||
import { updateItem } from "./procedures/update-item";
|
||||
|
||||
export const itemsRouter = {
|
||||
list: listItems,
|
||||
find: findItem,
|
||||
create: createItem,
|
||||
update: updateItem,
|
||||
delete: deleteItem,
|
||||
// Nested routes are supported
|
||||
drafts: {
|
||||
list: listDrafts,
|
||||
save: saveDraft,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Base Procedures with Middleware
|
||||
|
||||
Define base procedures with common middleware:
|
||||
|
||||
```typescript
|
||||
// orpc/procedures.ts
|
||||
import { ORPCError, os } from "@orpc/server";
|
||||
import { logIdMiddleware } from "./middleware/log-id-middleware";
|
||||
|
||||
// Public procedure - no authentication required
|
||||
export const publicProcedure = os
|
||||
.$context<{
|
||||
headers: Headers;
|
||||
}>()
|
||||
.use(logIdMiddleware);
|
||||
|
||||
// Protected procedure - requires authentication
|
||||
export const protectedProcedure = publicProcedure.use(
|
||||
async ({ context, next }) => {
|
||||
const session = await getSession(context.headers);
|
||||
|
||||
if (!session) {
|
||||
throw new ORPCError("UNAUTHORIZED");
|
||||
}
|
||||
|
||||
return await next({
|
||||
context: {
|
||||
session: session.session,
|
||||
user: session.user,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Admin procedure - requires admin role
|
||||
export const adminProcedure = protectedProcedure.use(
|
||||
async ({ context, next }) => {
|
||||
if (context.user.role !== "admin") {
|
||||
throw new ORPCError("FORBIDDEN");
|
||||
}
|
||||
|
||||
return await next();
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## 3. Procedure Definition
|
||||
|
||||
### Query Procedures (GET-like)
|
||||
|
||||
Use GET method for read operations that don't modify data:
|
||||
|
||||
```typescript
|
||||
// modules/items/procedures/list-items.ts
|
||||
import { z } from "zod";
|
||||
import { protectedProcedure } from "../../../orpc/procedures";
|
||||
|
||||
// Define input schema
|
||||
const listItemsInputSchema = z.object({
|
||||
limit: z.number().min(1).max(100).default(50),
|
||||
cursor: z.object({
|
||||
createdAt: z.string(),
|
||||
id: z.string(),
|
||||
}).optional(),
|
||||
filters: z.object({
|
||||
status: z.enum(["active", "archived"]).optional(),
|
||||
category: z.string().optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
// Define output schema
|
||||
const listItemsOutputSchema = z.object({
|
||||
items: z.array(z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
status: z.string(),
|
||||
createdAt: z.date(),
|
||||
})),
|
||||
nextCursor: z.object({
|
||||
createdAt: z.string(),
|
||||
id: z.string(),
|
||||
}).nullable(),
|
||||
hasMore: z.boolean(),
|
||||
});
|
||||
|
||||
export const listItems = protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/items",
|
||||
tags: ["Items"],
|
||||
summary: "List items with cursor pagination",
|
||||
description: "Retrieve a paginated list of items for the current user",
|
||||
})
|
||||
.input(listItemsInputSchema)
|
||||
.output(listItemsOutputSchema)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { limit, cursor, filters } = input;
|
||||
const { user } = context;
|
||||
|
||||
// Query implementation
|
||||
const items = await db.query.items.findMany({
|
||||
where: { userId: user.id, ...filters },
|
||||
limit: limit + 1, // Fetch one extra to check hasMore
|
||||
orderBy: [desc(items.createdAt), desc(items.id)],
|
||||
});
|
||||
|
||||
const hasMore = items.length > limit;
|
||||
const resultItems = hasMore ? items.slice(0, limit) : items;
|
||||
|
||||
return {
|
||||
items: resultItems,
|
||||
nextCursor: hasMore && resultItems.length > 0
|
||||
? {
|
||||
createdAt: resultItems[resultItems.length - 1].createdAt.toISOString(),
|
||||
id: resultItems[resultItems.length - 1].id,
|
||||
}
|
||||
: null,
|
||||
hasMore,
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
### Mutation Procedures (POST/PUT/DELETE-like)
|
||||
|
||||
Use POST for create operations, PUT/PATCH for updates, DELETE for removals:
|
||||
|
||||
```typescript
|
||||
// modules/items/procedures/create-item.ts
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { z } from "zod";
|
||||
import { protectedProcedure } from "../../../orpc/procedures";
|
||||
|
||||
const createItemInputSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
description: z.string().optional(),
|
||||
categoryId: z.string().optional(),
|
||||
});
|
||||
|
||||
const createItemOutputSchema = z.object({
|
||||
item: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
createdAt: z.date(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const createItem = protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/items",
|
||||
tags: ["Items"],
|
||||
summary: "Create a new item",
|
||||
})
|
||||
.input(createItemInputSchema)
|
||||
.output(createItemOutputSchema)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { name, description, categoryId } = input;
|
||||
const { user } = context;
|
||||
|
||||
// Validate category if provided
|
||||
if (categoryId) {
|
||||
const category = await db.query.categories.findFirst({
|
||||
where: { id: categoryId, userId: user.id },
|
||||
});
|
||||
if (!category) {
|
||||
throw new ORPCError("NOT_FOUND", {
|
||||
message: "Category not found",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const item = await db.insert(items).values({
|
||||
name,
|
||||
description,
|
||||
categoryId,
|
||||
userId: user.id,
|
||||
}).returning();
|
||||
|
||||
return { item: item[0] };
|
||||
});
|
||||
```
|
||||
|
||||
### Update Procedure Example
|
||||
|
||||
```typescript
|
||||
// modules/items/procedures/update-item.ts
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { z } from "zod";
|
||||
import { protectedProcedure } from "../../../orpc/procedures";
|
||||
|
||||
const updateItemInputSchema = z.object({
|
||||
itemId: z.string(),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
description: z.string().optional(),
|
||||
status: z.enum(["active", "archived"]).optional(),
|
||||
});
|
||||
|
||||
export const updateItem = protectedProcedure
|
||||
.route({
|
||||
method: "PUT",
|
||||
path: "/items/{itemId}",
|
||||
tags: ["Items"],
|
||||
summary: "Update an item",
|
||||
})
|
||||
.input(updateItemInputSchema)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { itemId, ...updates } = input;
|
||||
const { user } = context;
|
||||
|
||||
// Verify ownership
|
||||
const existingItem = await db.query.items.findFirst({
|
||||
where: { id: itemId },
|
||||
});
|
||||
|
||||
if (!existingItem) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Item not found" });
|
||||
}
|
||||
|
||||
if (existingItem.userId !== user.id) {
|
||||
throw new ORPCError("FORBIDDEN", {
|
||||
message: "You don't have permission to modify this item",
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await db.update(items)
|
||||
.set({ ...updates, updatedAt: new Date() })
|
||||
.where(eq(items.id, itemId))
|
||||
.returning();
|
||||
|
||||
return { item: updated[0] };
|
||||
});
|
||||
```
|
||||
|
||||
### Input Validation with Zod
|
||||
|
||||
oRPC uses Zod for input validation. Define schemas in a separate `types.ts` file for reusability:
|
||||
|
||||
```typescript
|
||||
// modules/items/types.ts
|
||||
import { z } from "zod";
|
||||
|
||||
// Input Schemas
|
||||
export const createItemInputSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
description: z.string().max(1000).optional(),
|
||||
tags: z.array(z.string()).max(10).optional(),
|
||||
});
|
||||
|
||||
export const updateItemInputSchema = z.object({
|
||||
itemId: z.string(),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
description: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
export const listItemsInputSchema = z.object({
|
||||
limit: z.number().int().min(1).max(100).default(50),
|
||||
cursor: z.object({
|
||||
createdAt: z.string(),
|
||||
id: z.string(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
// Output Schemas
|
||||
export const itemSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
status: z.enum(["active", "archived"]),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
|
||||
export const operationResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
});
|
||||
|
||||
export const batchOperationResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
successCount: z.number(),
|
||||
failedCount: z.number(),
|
||||
failedIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
// Type exports (inferred from schemas)
|
||||
export type CreateItemInput = z.infer<typeof createItemInputSchema>;
|
||||
export type UpdateItemInput = z.infer<typeof updateItemInputSchema>;
|
||||
export type ListItemsInput = z.infer<typeof listItemsInputSchema>;
|
||||
export type Item = z.infer<typeof itemSchema>;
|
||||
export type OperationResult = z.infer<typeof operationResultSchema>;
|
||||
```
|
||||
|
||||
## 4. Middleware
|
||||
|
||||
### Authentication Middleware
|
||||
|
||||
Built into `protectedProcedure`:
|
||||
|
||||
```typescript
|
||||
// orpc/procedures.ts
|
||||
export const protectedProcedure = publicProcedure.use(
|
||||
async ({ context, next }) => {
|
||||
const session = await getSession(context.headers);
|
||||
|
||||
if (!session) {
|
||||
throw new ORPCError("UNAUTHORIZED");
|
||||
}
|
||||
|
||||
// Add user info to context for downstream handlers
|
||||
return await next({
|
||||
context: {
|
||||
session: session.session,
|
||||
user: session.user,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Logging Middleware
|
||||
|
||||
Generate and propagate request IDs for tracing:
|
||||
|
||||
```typescript
|
||||
// orpc/middleware/log-id-middleware.ts
|
||||
import { os } from "@orpc/server";
|
||||
|
||||
function generateLogId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
|
||||
}
|
||||
|
||||
function getOrGenerateLogId(headers: Headers): string {
|
||||
// Prefer client-provided x-log-id for distributed tracing
|
||||
const existingLogId = headers.get("x-log-id");
|
||||
if (existingLogId) {
|
||||
return existingLogId;
|
||||
}
|
||||
return generateLogId();
|
||||
}
|
||||
|
||||
export const logIdMiddleware = os
|
||||
.$context<{
|
||||
headers: Headers;
|
||||
}>()
|
||||
.middleware(async ({ context, next }) => {
|
||||
const logId = getOrGenerateLogId(context.headers);
|
||||
|
||||
// Run with tracing context
|
||||
return await runWithTrace(logId, async () => {
|
||||
return await next({
|
||||
context: {
|
||||
logId,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Locale Middleware
|
||||
|
||||
Extract locale from cookies for i18n:
|
||||
|
||||
```typescript
|
||||
// orpc/middleware/locale-middleware.ts
|
||||
import { os } from "@orpc/server";
|
||||
import { getCookie } from "@orpc/server/helpers";
|
||||
import { config } from "@your-app/config";
|
||||
import type { Locale } from "@your-app/i18n";
|
||||
|
||||
export const localeMiddleware = os
|
||||
.$context<{
|
||||
headers: Headers;
|
||||
}>()
|
||||
.middleware(async ({ context, next }) => {
|
||||
const locale = (getCookie(
|
||||
context.headers,
|
||||
config.i18n.localeCookieName,
|
||||
) as Locale) ?? config.i18n.defaultLocale;
|
||||
|
||||
return await next({
|
||||
context: {
|
||||
locale,
|
||||
},
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Using Middleware in Procedures
|
||||
|
||||
Apply middleware to specific procedures:
|
||||
|
||||
```typescript
|
||||
// modules/contact/procedures/submit-contact-form.ts
|
||||
import { localeMiddleware } from "../../../orpc/middleware/locale-middleware";
|
||||
import { publicProcedure } from "../../../orpc/procedures";
|
||||
|
||||
export const submitContactForm = publicProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/contact",
|
||||
tags: ["Contact"],
|
||||
summary: "Submit contact form",
|
||||
})
|
||||
.input(contactFormSchema)
|
||||
.use(localeMiddleware) // Apply locale middleware
|
||||
.handler(async ({ input, context: { locale } }) => {
|
||||
// locale is now available in context
|
||||
await sendEmail({
|
||||
to: config.contactForm.to,
|
||||
locale,
|
||||
subject: config.contactForm.subject,
|
||||
text: `Name: ${input.name}\n\nEmail: ${input.email}\n\nMessage: ${input.message}`,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Error Handling Middleware
|
||||
|
||||
Create custom error handling:
|
||||
|
||||
```typescript
|
||||
// orpc/middleware/error-middleware.ts
|
||||
import { ORPCError, os } from "@orpc/server";
|
||||
import { logger } from "@your-app/logs";
|
||||
|
||||
export const errorMiddleware = os.middleware(async ({ next, path }) => {
|
||||
try {
|
||||
return await next();
|
||||
} catch (error) {
|
||||
// Log error with context
|
||||
logger.error("Procedure error", {
|
||||
path,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
|
||||
// Re-throw oRPC errors as-is
|
||||
if (error instanceof ORPCError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Wrap unknown errors
|
||||
throw new ORPCError("INTERNAL_SERVER_ERROR", {
|
||||
message: "An unexpected error occurred",
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 5. Context
|
||||
|
||||
### How to Access User Session
|
||||
|
||||
The session is available in context after `protectedProcedure`:
|
||||
|
||||
```typescript
|
||||
export const getProfile = protectedProcedure
|
||||
.route({ method: "GET", path: "/users/profile", tags: ["Users"] })
|
||||
.handler(async ({ context }) => {
|
||||
// context.user contains the authenticated user
|
||||
const { user, session } = context;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
sessionId: session.id,
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
### How to Access Logger
|
||||
|
||||
Use the logger from the shared logs package:
|
||||
|
||||
```typescript
|
||||
import { logger } from "@your-app/logs";
|
||||
|
||||
export const createItem = protectedProcedure
|
||||
.route({ method: "POST", path: "/items", tags: ["Items"] })
|
||||
.input(createItemInputSchema)
|
||||
.handler(async ({ input, context }) => {
|
||||
logger.info("Creating item", {
|
||||
userId: context.user.id,
|
||||
itemName: input.name,
|
||||
});
|
||||
|
||||
try {
|
||||
const item = await db.insert(items).values({
|
||||
...input,
|
||||
userId: context.user.id,
|
||||
}).returning();
|
||||
|
||||
logger.info("Item created successfully", { itemId: item[0].id });
|
||||
return { item: item[0] };
|
||||
} catch (error) {
|
||||
logger.error("Failed to create item", {
|
||||
userId: context.user.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw new ORPCError("INTERNAL_SERVER_ERROR", {
|
||||
message: "Failed to create item",
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### How to Access Database
|
||||
|
||||
Import the database client and use it directly:
|
||||
|
||||
```typescript
|
||||
import { db } from "@your-app/database";
|
||||
import { items, categories } from "@your-app/database/drizzle/schema";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
|
||||
export const listItems = protectedProcedure
|
||||
.route({ method: "GET", path: "/items", tags: ["Items"] })
|
||||
.handler(async ({ context }) => {
|
||||
// Using Drizzle query builder
|
||||
const userItems = await db.query.items.findMany({
|
||||
where: eq(items.userId, context.user.id),
|
||||
orderBy: desc(items.createdAt),
|
||||
with: {
|
||||
category: true, // Include relations
|
||||
},
|
||||
});
|
||||
|
||||
// Or using raw select
|
||||
const itemsWithCategory = await db
|
||||
.select({
|
||||
id: items.id,
|
||||
name: items.name,
|
||||
categoryName: categories.name,
|
||||
})
|
||||
.from(items)
|
||||
.leftJoin(categories, eq(items.categoryId, categories.id))
|
||||
.where(eq(items.userId, context.user.id));
|
||||
|
||||
return { items: userItems };
|
||||
});
|
||||
```
|
||||
|
||||
## 6. Best Practices
|
||||
|
||||
### Input/Output Schema Naming Conventions
|
||||
|
||||
Follow consistent naming patterns:
|
||||
|
||||
```typescript
|
||||
// Input schemas: [action][Entity]InputSchema
|
||||
export const createItemInputSchema = z.object({ ... });
|
||||
export const updateItemInputSchema = z.object({ ... });
|
||||
export const listItemsInputSchema = z.object({ ... });
|
||||
export const deleteItemInputSchema = z.object({ ... });
|
||||
|
||||
// Output schemas: [action][Entity]OutputSchema or [entity]Schema
|
||||
export const itemSchema = z.object({ ... });
|
||||
export const listItemsOutputSchema = z.object({ ... });
|
||||
export const operationResultSchema = z.object({ ... });
|
||||
|
||||
// Shared/reusable schemas: [entity]Schema or [concept]Schema
|
||||
export const paginationSchema = z.object({
|
||||
limit: z.number().int().min(1).max(100).default(50),
|
||||
cursor: z.object({
|
||||
createdAt: z.string(),
|
||||
id: z.string(),
|
||||
}).optional(),
|
||||
});
|
||||
```
|
||||
|
||||
### Error Handling Patterns
|
||||
|
||||
Use appropriate error codes and messages:
|
||||
|
||||
```typescript
|
||||
import { ORPCError } from "@orpc/client";
|
||||
|
||||
// Resource not found
|
||||
throw new ORPCError("NOT_FOUND", {
|
||||
message: "Item not found",
|
||||
});
|
||||
|
||||
// Permission denied
|
||||
throw new ORPCError("FORBIDDEN", {
|
||||
message: "You don't have permission to access this resource",
|
||||
});
|
||||
|
||||
// Authentication required
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "Please sign in to continue",
|
||||
});
|
||||
|
||||
// Validation error (usually handled by Zod, but for custom validation)
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Invalid email format",
|
||||
});
|
||||
|
||||
// Conflict (e.g., duplicate entry)
|
||||
throw new ORPCError("CONFLICT", {
|
||||
message: "An item with this name already exists",
|
||||
});
|
||||
|
||||
// Server error (wrap internal errors)
|
||||
try {
|
||||
await externalService.call();
|
||||
} catch (error) {
|
||||
logger.error("External service failed", { error });
|
||||
throw new ORPCError("INTERNAL_SERVER_ERROR", {
|
||||
message: "Service temporarily unavailable",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Procedure Organization
|
||||
|
||||
1. **One procedure per file**: Keep procedures focused and testable
|
||||
2. **Group related procedures**: Use module routers to organize by domain
|
||||
3. **Reuse schemas**: Define common schemas in `types.ts`
|
||||
4. **Consistent file naming**: Use kebab-case matching the procedure name
|
||||
|
||||
```
|
||||
modules/items/
|
||||
├── router.ts # Exports all procedures
|
||||
├── types.ts # Shared schemas and types
|
||||
└── procedures/
|
||||
├── create-item.ts # createItem procedure
|
||||
├── delete-item.ts # deleteItem procedure
|
||||
├── find-item.ts # findItem procedure
|
||||
├── list-items.ts # listItems procedure
|
||||
└── update-item.ts # updateItem procedure
|
||||
```
|
||||
|
||||
### Performance Tips
|
||||
|
||||
1. **Use cursor pagination** instead of offset for large datasets
|
||||
2. **Batch database queries** to avoid N+1 problems
|
||||
3. **Add appropriate indexes** for filtered/sorted columns
|
||||
4. **Use select** to fetch only needed columns
|
||||
|
||||
```typescript
|
||||
// Avoid N+1 queries - fetch related data in batch
|
||||
const items = await db.query.items.findMany({
|
||||
where: eq(items.userId, user.id),
|
||||
limit,
|
||||
});
|
||||
|
||||
// Batch fetch labels for all items
|
||||
const itemIds = items.map(i => i.id);
|
||||
const labels = await db.query.itemLabels.findMany({
|
||||
where: inArray(itemLabels.itemId, itemIds),
|
||||
});
|
||||
|
||||
// Group labels by itemId
|
||||
const labelsByItemId = new Map();
|
||||
for (const label of labels) {
|
||||
const existing = labelsByItemId.get(label.itemId) || [];
|
||||
existing.push(label);
|
||||
labelsByItemId.set(label.itemId, existing);
|
||||
}
|
||||
|
||||
// Combine results
|
||||
const itemsWithLabels = items.map(item => ({
|
||||
...item,
|
||||
labels: labelsByItemId.get(item.id) || [],
|
||||
}));
|
||||
```
|
||||
|
||||
### Testing Procedures
|
||||
|
||||
Structure tests to cover various scenarios:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCaller } from "../test-utils";
|
||||
|
||||
describe("createItem", () => {
|
||||
it("creates an item successfully", async () => {
|
||||
const caller = createCaller({ user: testUser });
|
||||
|
||||
const result = await caller.items.create({
|
||||
name: "Test Item",
|
||||
description: "A test item",
|
||||
});
|
||||
|
||||
expect(result.item.name).toBe("Test Item");
|
||||
expect(result.item.id).toBeDefined();
|
||||
});
|
||||
|
||||
it("throws UNAUTHORIZED for unauthenticated users", async () => {
|
||||
const caller = createCaller({ user: null });
|
||||
|
||||
await expect(
|
||||
caller.items.create({ name: "Test" })
|
||||
).rejects.toThrow("UNAUTHORIZED");
|
||||
});
|
||||
|
||||
it("validates input schema", async () => {
|
||||
const caller = createCaller({ user: testUser });
|
||||
|
||||
await expect(
|
||||
caller.items.create({ name: "" }) // Empty name
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
```
|
||||
498
.trellis/spec/backend/performance.md
Normal file
498
.trellis/spec/backend/performance.md
Normal file
@@ -0,0 +1,498 @@
|
||||
# Performance Patterns
|
||||
|
||||
This document covers performance optimization patterns for backend development.
|
||||
|
||||
## Parallel Execution with Promise.all
|
||||
|
||||
When operations are independent, execute them in parallel.
|
||||
|
||||
```typescript
|
||||
// BAD - Sequential execution (slow)
|
||||
const user = await getUser(userId);
|
||||
const orders = await getOrders(userId);
|
||||
const preferences = await getPreferences(userId);
|
||||
|
||||
// GOOD - Parallel execution
|
||||
const [user, orders, preferences] = await Promise.all([
|
||||
getUser(userId),
|
||||
getOrders(userId),
|
||||
getPreferences(userId),
|
||||
]);
|
||||
```
|
||||
|
||||
### Promise.allSettled for Partial Failures
|
||||
|
||||
When some operations can fail without blocking others:
|
||||
|
||||
```typescript
|
||||
const results = await Promise.allSettled([
|
||||
processOrderA(),
|
||||
processOrderB(),
|
||||
processOrderC(),
|
||||
]);
|
||||
|
||||
const successful = results
|
||||
.filter((r): r is PromiseFulfilledResult<Order> => r.status === "fulfilled")
|
||||
.map(r => r.value);
|
||||
|
||||
const failed = results
|
||||
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
|
||||
.map(r => r.reason);
|
||||
|
||||
logger.info("Batch processing complete", {
|
||||
successful: successful.length,
|
||||
failed: failed.length,
|
||||
});
|
||||
```
|
||||
|
||||
## Concurrency Control with p-limit
|
||||
|
||||
When calling external APIs, limit concurrent requests to avoid rate limiting.
|
||||
|
||||
```typescript
|
||||
import pLimit from "p-limit";
|
||||
|
||||
// Create limiter with max 20 concurrent requests
|
||||
const limit = pLimit(20);
|
||||
|
||||
const orderIds = ["order1", "order2", /* ... hundreds more */];
|
||||
|
||||
// Process all with controlled concurrency
|
||||
const results = await Promise.all(
|
||||
orderIds.map(orderId =>
|
||||
limit(() => fetchOrderDetails(orderId))
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### Shared Limiter Pattern
|
||||
|
||||
For module-wide concurrency control:
|
||||
|
||||
```typescript
|
||||
// lib/api-client.ts
|
||||
import pLimit from "p-limit";
|
||||
|
||||
// External API concurrency limit
|
||||
const API_CONCURRENCY = 20;
|
||||
|
||||
export function createApiLimiter(): ReturnType<typeof pLimit> {
|
||||
return pLimit(API_CONCURRENCY);
|
||||
}
|
||||
|
||||
// Usage in procedure
|
||||
const limiter = createApiLimiter();
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
items.map(item =>
|
||||
limiter(async () => {
|
||||
try {
|
||||
const result = await externalApi.process(item);
|
||||
return { itemId: item.id, success: true, result };
|
||||
} catch (error) {
|
||||
return {
|
||||
itemId: item.id,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error"
|
||||
};
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
## Rate Limit Retry with Exponential Backoff
|
||||
|
||||
Handle rate limits gracefully with automatic retry.
|
||||
|
||||
```typescript
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
async function fetchWithRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
context: { operation: string; itemId: string }
|
||||
): Promise<T> {
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error: any) {
|
||||
const isRateLimited = error?.code === 429 || error?.status === 429;
|
||||
|
||||
if (isRateLimited && attempt < MAX_RETRIES) {
|
||||
// Exponential backoff: 2^attempt seconds + random jitter
|
||||
const delay = 2 ** attempt * 1000 + Math.random() * 1000;
|
||||
|
||||
logger.warn("Rate limited, retrying", {
|
||||
operation: context.operation,
|
||||
itemId: context.itemId,
|
||||
attempt,
|
||||
delay: Math.round(delay),
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed after ${MAX_RETRIES} attempts`);
|
||||
}
|
||||
|
||||
// Usage
|
||||
const result = await fetchWithRetry(
|
||||
() => externalApi.getResource(resourceId),
|
||||
{ operation: "getResource", itemId: resourceId }
|
||||
);
|
||||
```
|
||||
|
||||
### Backoff Configuration
|
||||
|
||||
```typescript
|
||||
interface RetryConfig {
|
||||
maxRetries: number;
|
||||
baseDelay: number; // Base delay in ms
|
||||
maxDelay: number; // Maximum delay cap
|
||||
jitterFactor: number; // Random jitter (0-1)
|
||||
}
|
||||
|
||||
const defaultConfig: RetryConfig = {
|
||||
maxRetries: 3,
|
||||
baseDelay: 1000,
|
||||
maxDelay: 30000,
|
||||
jitterFactor: 0.5,
|
||||
};
|
||||
|
||||
function calculateDelay(attempt: number, config: RetryConfig): number {
|
||||
const exponentialDelay = config.baseDelay * 2 ** (attempt - 1);
|
||||
const cappedDelay = Math.min(exponentialDelay, config.maxDelay);
|
||||
const jitter = cappedDelay * config.jitterFactor * Math.random();
|
||||
return cappedDelay + jitter;
|
||||
}
|
||||
```
|
||||
|
||||
## Redis Caching (Cache-Aside Pattern)
|
||||
|
||||
Implement caching for expensive operations.
|
||||
|
||||
```typescript
|
||||
import { redis } from "../../../lib/redis";
|
||||
import { SpanPrefix, span } from "../../../lib/tracer";
|
||||
|
||||
const CACHE_TTL = 3600; // 1 hour in seconds
|
||||
|
||||
interface CachedUserProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
preferences: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function getUserProfile(userId: string): Promise<CachedUserProfile> {
|
||||
const cacheKey = `user:profile:${userId}`;
|
||||
|
||||
// 1. Try cache first
|
||||
const cached = await span(
|
||||
`${SpanPrefix.Redis}GetUserProfile`,
|
||||
async () => {
|
||||
const data = await redis.get<string>(cacheKey);
|
||||
return data ? JSON.parse(data) as CachedUserProfile : null;
|
||||
},
|
||||
{ userId }
|
||||
);
|
||||
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// 2. Cache miss - fetch from database
|
||||
const profile = await span(
|
||||
`${SpanPrefix.DB}FetchUserProfile`,
|
||||
() => db.query.user.findFirst({
|
||||
where: eq(userTable.id, userId),
|
||||
with: { preferences: true },
|
||||
}),
|
||||
{ userId }
|
||||
);
|
||||
|
||||
if (!profile) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
||||
}
|
||||
|
||||
const cacheValue: CachedUserProfile = {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
preferences: profile.preferences,
|
||||
};
|
||||
|
||||
// 3. Store in cache
|
||||
await span(
|
||||
`${SpanPrefix.Redis}SetUserProfile`,
|
||||
() => redis.set(cacheKey, JSON.stringify(cacheValue), { ex: CACHE_TTL }),
|
||||
{ userId }
|
||||
);
|
||||
|
||||
return cacheValue;
|
||||
}
|
||||
```
|
||||
|
||||
### Cache Invalidation
|
||||
|
||||
```typescript
|
||||
async function updateUserProfile(
|
||||
userId: string,
|
||||
updates: Partial<UserProfile>
|
||||
): Promise<void> {
|
||||
// 1. Update database
|
||||
await db.update(userTable)
|
||||
.set(updates)
|
||||
.where(eq(userTable.id, userId));
|
||||
|
||||
// 2. Invalidate cache
|
||||
const cacheKey = `user:profile:${userId}`;
|
||||
await redis.del(cacheKey);
|
||||
|
||||
logger.info("User profile updated and cache invalidated", { userId });
|
||||
}
|
||||
```
|
||||
|
||||
### Cache Key Patterns
|
||||
|
||||
```typescript
|
||||
// User-specific data
|
||||
`user:profile:${userId}`
|
||||
`user:settings:${userId}`
|
||||
`user:orders:${userId}:page:${page}`
|
||||
|
||||
// Resource-specific data
|
||||
`product:${productId}`
|
||||
`inventory:${warehouseId}:${productId}`
|
||||
|
||||
// Aggregated data
|
||||
`stats:daily:${date}`
|
||||
`leaderboard:${category}`
|
||||
```
|
||||
|
||||
## Background Tasks with Distributed Locks
|
||||
|
||||
Prevent duplicate processing in distributed environments.
|
||||
|
||||
```typescript
|
||||
const LOCK_KEY = "task:process-orders";
|
||||
const LOCK_TTL = 300; // 5 minutes
|
||||
|
||||
async function processScheduledOrders(): Promise<void> {
|
||||
// 1. Try to acquire lock
|
||||
const lockResult = await redis.set(LOCK_KEY, Date.now(), {
|
||||
ex: LOCK_TTL,
|
||||
nx: true, // Only set if not exists
|
||||
});
|
||||
|
||||
if (!lockResult) {
|
||||
logger.info("Another instance is processing orders, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. Process with lock held
|
||||
logger.info("Acquired lock, processing scheduled orders");
|
||||
|
||||
const pendingOrders = await db
|
||||
.select()
|
||||
.from(orderTable)
|
||||
.where(and(
|
||||
eq(orderTable.status, "SCHEDULED"),
|
||||
lte(orderTable.scheduledAt, new Date())
|
||||
))
|
||||
.limit(100);
|
||||
|
||||
for (const order of pendingOrders) {
|
||||
await processOrder(order);
|
||||
}
|
||||
|
||||
logger.info("Scheduled orders processed", {
|
||||
count: pendingOrders.length
|
||||
});
|
||||
} finally {
|
||||
// 3. Release lock
|
||||
await redis.del(LOCK_KEY);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Lock with Heartbeat
|
||||
|
||||
For long-running tasks, extend the lock periodically:
|
||||
|
||||
```typescript
|
||||
async function processLongRunningTask(): Promise<void> {
|
||||
const LOCK_KEY = "task:long-running";
|
||||
const LOCK_TTL = 30;
|
||||
const HEARTBEAT_INTERVAL = 10000; // 10 seconds
|
||||
|
||||
const lockResult = await redis.set(LOCK_KEY, Date.now(), {
|
||||
ex: LOCK_TTL,
|
||||
nx: true,
|
||||
});
|
||||
|
||||
if (!lockResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Heartbeat to extend lock
|
||||
const heartbeat = setInterval(async () => {
|
||||
await redis.expire(LOCK_KEY, LOCK_TTL);
|
||||
}, HEARTBEAT_INTERVAL);
|
||||
|
||||
try {
|
||||
await doExpensiveWork();
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
await redis.del(LOCK_KEY);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Batch Processing Patterns
|
||||
|
||||
### Chunked Processing
|
||||
|
||||
For large datasets, process in chunks:
|
||||
|
||||
```typescript
|
||||
const CHUNK_SIZE = 100;
|
||||
|
||||
async function processAllOrders(orderIds: string[]): Promise<void> {
|
||||
// Split into chunks
|
||||
const chunks: string[][] = [];
|
||||
for (let i = 0; i < orderIds.length; i += CHUNK_SIZE) {
|
||||
chunks.push(orderIds.slice(i, i + CHUNK_SIZE));
|
||||
}
|
||||
|
||||
logger.info("Processing orders in chunks", {
|
||||
totalOrders: orderIds.length,
|
||||
chunkCount: chunks.length,
|
||||
chunkSize: CHUNK_SIZE,
|
||||
});
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunk = chunks[i];
|
||||
if (!chunk) continue;
|
||||
|
||||
await processOrderChunk(chunk);
|
||||
|
||||
logger.info("Chunk processed", {
|
||||
chunkIndex: i + 1,
|
||||
totalChunks: chunks.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function processOrderChunk(orderIds: string[]): Promise<void> {
|
||||
// Batch database query
|
||||
const orders = await db
|
||||
.select()
|
||||
.from(orderTable)
|
||||
.where(inArray(orderTable.id, orderIds));
|
||||
|
||||
// Parallel processing with concurrency limit
|
||||
const limiter = pLimit(10);
|
||||
|
||||
await Promise.all(
|
||||
orders.map(order => limiter(() => processOrder(order)))
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Progress Reporting
|
||||
|
||||
Track and report progress for long operations:
|
||||
|
||||
```typescript
|
||||
interface ProgressTracker {
|
||||
total: number;
|
||||
processed: number;
|
||||
failed: number;
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
async function batchProcessWithProgress(
|
||||
items: string[],
|
||||
progressCallback?: (progress: ProgressTracker) => void
|
||||
): Promise<void> {
|
||||
const progress: ProgressTracker = {
|
||||
total: items.length,
|
||||
processed: 0,
|
||||
failed: 0,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
|
||||
const UPDATE_INTERVAL = 20; // Report every 20 items
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
await processItem(item);
|
||||
progress.processed++;
|
||||
} catch {
|
||||
progress.failed++;
|
||||
}
|
||||
|
||||
// Report progress periodically
|
||||
if ((progress.processed + progress.failed) % UPDATE_INTERVAL === 0) {
|
||||
progressCallback?.(progress);
|
||||
|
||||
logger.info("Batch progress", {
|
||||
processed: progress.processed,
|
||||
failed: progress.failed,
|
||||
total: progress.total,
|
||||
elapsedMs: Date.now() - progress.startTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Memory Optimization
|
||||
|
||||
### Streaming Large Datasets
|
||||
|
||||
For very large datasets, use streaming:
|
||||
|
||||
```typescript
|
||||
async function* streamOrders(userId: string): AsyncGenerator<Order> {
|
||||
let cursor: string | undefined;
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
while (true) {
|
||||
const orders = await db
|
||||
.select()
|
||||
.from(orderTable)
|
||||
.where(and(
|
||||
eq(orderTable.userId, userId),
|
||||
cursor ? gt(orderTable.id, cursor) : undefined
|
||||
))
|
||||
.orderBy(asc(orderTable.id))
|
||||
.limit(PAGE_SIZE);
|
||||
|
||||
if (orders.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const order of orders) {
|
||||
yield order;
|
||||
}
|
||||
|
||||
const lastOrder = orders[orders.length - 1];
|
||||
cursor = lastOrder?.id;
|
||||
|
||||
if (orders.length < PAGE_SIZE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
for await (const order of streamOrders(userId)) {
|
||||
await processOrder(order);
|
||||
}
|
||||
```
|
||||
81
.trellis/spec/backend/quality.md
Normal file
81
.trellis/spec/backend/quality.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Pre-commit Checklist
|
||||
|
||||
Run through this checklist before committing backend code.
|
||||
|
||||
## Type Safety
|
||||
|
||||
- [ ] **No non-null assertions (`!`)** - Use local variables and conditionals for type narrowing
|
||||
- [ ] **All API inputs have Zod schemas** - Defined in `types.ts`
|
||||
- [ ] **All API outputs have Zod schemas** - Including `success` and `reason` fields
|
||||
- [ ] **Enums imported from `@your-app/utils`** - Not from database package
|
||||
|
||||
## Database Operations
|
||||
|
||||
- [ ] **No `await` in loops** - Use `inArray` for batch queries
|
||||
- [ ] **Batch inserts used** - Not individual inserts in loops
|
||||
- [ ] **Conflict handling considered** - Use `onConflictDoUpdate` when appropriate
|
||||
- [ ] **JSON columns cast properly** - `::jsonb` for jsonb functions
|
||||
- [ ] **Raw SQL column names quoted** - Double quotes for camelCase columns
|
||||
|
||||
## Logging
|
||||
|
||||
- [ ] **No `console.log`** - Use `logger` from `@your-app/logs`
|
||||
- [ ] **Structured logging used** - Pass objects, not string interpolation
|
||||
- [ ] **Errors logged with context** - Include relevant IDs and stack traces
|
||||
- [ ] **Sensitive data excluded** - No passwords, tokens, or PII in logs
|
||||
|
||||
## Performance
|
||||
|
||||
- [ ] **Parallel execution where possible** - Use `Promise.all` for independent operations
|
||||
- [ ] **Concurrency control for external APIs** - Use `p-limit` for rate-limited APIs
|
||||
- [ ] **Retry logic for rate limits** - Exponential backoff implemented
|
||||
- [ ] **Caching considered** - For expensive or frequently accessed data
|
||||
|
||||
## Error Handling
|
||||
|
||||
- [ ] **Errors properly caught and logged** - With Sentry context when applicable
|
||||
- [ ] **Appropriate error codes returned** - `NOT_FOUND`, `FORBIDDEN`, `BAD_REQUEST`, etc.
|
||||
- [ ] **Batch operations handle partial failures** - Return detailed error information
|
||||
|
||||
## Code Organization
|
||||
|
||||
- [ ] **Code in correct location** - Procedures, lib, types in right directories
|
||||
- [ ] **Reusable logic extracted** - Shared code in `lib/` directory
|
||||
- [ ] **Naming conventions followed** - Schemas, types, functions named correctly
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Response Format
|
||||
```typescript
|
||||
return {
|
||||
success: true,
|
||||
reason: "Operation completed successfully",
|
||||
// additional fields
|
||||
};
|
||||
```
|
||||
|
||||
### Batch Query Pattern
|
||||
```typescript
|
||||
const items = await db
|
||||
.select()
|
||||
.from(itemTable)
|
||||
.where(inArray(itemTable.parentId, parentIds));
|
||||
|
||||
const itemsByParent = groupBy(items, "parentId");
|
||||
```
|
||||
|
||||
### Logging Pattern
|
||||
```typescript
|
||||
logger.info("Operation completed", {
|
||||
operationId,
|
||||
userId,
|
||||
itemCount: items.length,
|
||||
});
|
||||
```
|
||||
|
||||
### Error Pattern
|
||||
```typescript
|
||||
if (!resource) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Resource not found" });
|
||||
}
|
||||
```
|
||||
316
.trellis/spec/backend/type-safety.md
Normal file
316
.trellis/spec/backend/type-safety.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# Type Safety Guidelines
|
||||
|
||||
This document covers TypeScript best practices and type safety patterns for backend development.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
### 1. NO Non-null Assertions (`!`)
|
||||
|
||||
Never use the non-null assertion operator (`!`). It bypasses TypeScript's null checking and can lead to runtime errors.
|
||||
|
||||
```typescript
|
||||
// BAD - Non-null assertion
|
||||
const user = users.find(u => u.id === id);
|
||||
await processUser(user!); // Dangerous!
|
||||
|
||||
// GOOD - Use local variable for type narrowing
|
||||
const user = users.find(u => u.id === id);
|
||||
if (!user) {
|
||||
return { success: false, reason: "User not found" };
|
||||
}
|
||||
// TypeScript now knows user is defined
|
||||
await processUser(user);
|
||||
```
|
||||
|
||||
**Why this matters:**
|
||||
|
||||
- Non-null assertions (`!`) tell TypeScript to trust you, but runtime doesn't care
|
||||
- If the value is actually `null` or `undefined`, you get a runtime crash
|
||||
- Local variable narrowing is verifiable at both compile-time and runtime
|
||||
|
||||
### 2. All Inputs/Outputs Must Have Zod Schemas
|
||||
|
||||
Every API endpoint must define explicit input and output schemas using Zod.
|
||||
|
||||
```typescript
|
||||
// types.ts
|
||||
import { z } from "zod";
|
||||
|
||||
// Input Schema
|
||||
export const updateUserInputSchema = z.object({
|
||||
userId: z.string(),
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
email: z.string().email().optional(),
|
||||
settings: z.object({
|
||||
notifications: z.boolean(),
|
||||
theme: z.enum(["light", "dark"]),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
// Output Schema
|
||||
export const updateUserOutputSchema = z.object({
|
||||
success: z.boolean(),
|
||||
reason: z.string(),
|
||||
user: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
// Type exports (inferred from schemas)
|
||||
export type UpdateUserInput = z.infer<typeof updateUserInputSchema>;
|
||||
export type UpdateUserOutput = z.infer<typeof updateUserOutputSchema>;
|
||||
```
|
||||
|
||||
**Using schemas in procedures:**
|
||||
|
||||
```typescript
|
||||
// procedures/update.ts
|
||||
export const updateUser = protectedProcedure
|
||||
.route({
|
||||
method: "PATCH",
|
||||
path: "/users/:userId",
|
||||
tags: ["Users"],
|
||||
summary: "Update user profile",
|
||||
})
|
||||
.input(updateUserInputSchema)
|
||||
.output(updateUserOutputSchema)
|
||||
.handler(async ({ input, context }) => {
|
||||
// input is fully typed as UpdateUserInput
|
||||
const { userId, name, email, settings } = input;
|
||||
|
||||
// ... implementation
|
||||
|
||||
return {
|
||||
success: true,
|
||||
reason: "User updated successfully",
|
||||
user: { id: userId, name: updatedName, email: updatedEmail },
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Import Enums from Shared Utils Package
|
||||
|
||||
**Never import enums directly from the database package.** The database package includes the PostgreSQL client, which can cause issues in certain environments (edge runtime, client-side code).
|
||||
|
||||
```typescript
|
||||
// BAD - Imports database client as side effect
|
||||
import { messageCategoryEnum } from "@your-app/database/drizzle/schema/postgres";
|
||||
|
||||
// GOOD - Import from utils (no database client dependency)
|
||||
import {
|
||||
messageCategoryZodSchema,
|
||||
type MessageCategory,
|
||||
MESSAGE_CATEGORY_VALUES,
|
||||
} from "@your-app/utils";
|
||||
```
|
||||
|
||||
**How enums are organized in the utils package:**
|
||||
|
||||
```typescript
|
||||
// packages/utils/lib/enum-types.ts
|
||||
import { z } from "zod";
|
||||
|
||||
// Import enum values from schema (not the full database package)
|
||||
import {
|
||||
statusEnum
|
||||
} from "@your-app/database/drizzle/schema";
|
||||
|
||||
// Export as Zod schema and TypeScript type
|
||||
export const ORDER_STATUS_VALUES = statusEnum.enumValues;
|
||||
export const orderStatusZodSchema = z.enum(ORDER_STATUS_VALUES);
|
||||
export type OrderStatus = z.infer<typeof orderStatusZodSchema>;
|
||||
```
|
||||
|
||||
### 4. Standard Response Format
|
||||
|
||||
All API responses must include `success` and `reason` fields for consistent error handling.
|
||||
|
||||
```typescript
|
||||
// Output schema pattern
|
||||
export const operationResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
reason: z.string(),
|
||||
// Additional fields as needed
|
||||
data: z.unknown().optional(),
|
||||
});
|
||||
|
||||
// Success response
|
||||
return {
|
||||
success: true,
|
||||
reason: "Operation completed successfully",
|
||||
data: result,
|
||||
};
|
||||
|
||||
// Error response
|
||||
return {
|
||||
success: false,
|
||||
reason: "Insufficient permissions to perform this action",
|
||||
};
|
||||
```
|
||||
|
||||
**Batch operation response pattern:**
|
||||
|
||||
```typescript
|
||||
export const batchOperationResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
total: z.number(),
|
||||
processed: z.number(),
|
||||
failed: z.number(),
|
||||
errors: z.array(z.object({
|
||||
itemId: z.string(),
|
||||
error: z.string(),
|
||||
})).optional(),
|
||||
});
|
||||
```
|
||||
|
||||
## Type Narrowing Patterns
|
||||
|
||||
### Array Operations
|
||||
|
||||
```typescript
|
||||
// BAD - Assumes array has elements
|
||||
const firstOrder = orders[0];
|
||||
await processOrder(firstOrder!);
|
||||
|
||||
// GOOD - Check first
|
||||
const firstOrder = orders[0];
|
||||
if (!firstOrder) {
|
||||
return { success: false, reason: "No orders found" };
|
||||
}
|
||||
await processOrder(firstOrder);
|
||||
```
|
||||
|
||||
### Optional Chaining with Fallback
|
||||
|
||||
```typescript
|
||||
// BAD - Non-null assertion on optional property
|
||||
const userName = user.profile!.name!;
|
||||
|
||||
// GOOD - Safe access with fallback
|
||||
const userName = user.profile?.name ?? "Unknown";
|
||||
|
||||
// GOOD - When value is required, validate first
|
||||
const profile = user.profile;
|
||||
if (!profile?.name) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Profile name is required" });
|
||||
}
|
||||
const userName = profile.name;
|
||||
```
|
||||
|
||||
### Map/Find Operations
|
||||
|
||||
```typescript
|
||||
// BAD - Assuming find always succeeds
|
||||
const account = accounts.find(a => a.id === accountId)!;
|
||||
|
||||
// GOOD - Handle the undefined case
|
||||
const account = accounts.find(a => a.id === accountId);
|
||||
if (!account) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Account not found" });
|
||||
}
|
||||
// account is now guaranteed to be defined
|
||||
```
|
||||
|
||||
## Zod Schema Best Practices
|
||||
|
||||
### Reusable Base Schemas
|
||||
|
||||
```typescript
|
||||
// Define reusable schemas
|
||||
const paginationSchema = z.object({
|
||||
page: z.number().min(1).default(1),
|
||||
limit: z.number().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
const timestampSchema = z.object({
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
// Compose into larger schemas
|
||||
export const listOrdersInputSchema = paginationSchema.extend({
|
||||
status: orderStatusZodSchema.optional(),
|
||||
customerId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const orderSchema = z.object({
|
||||
id: z.string(),
|
||||
status: orderStatusZodSchema,
|
||||
total: z.number(),
|
||||
}).merge(timestampSchema);
|
||||
```
|
||||
|
||||
### Discriminated Unions
|
||||
|
||||
```typescript
|
||||
// For polymorphic responses
|
||||
export const notificationSchema = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("email"),
|
||||
recipient: z.string().email(),
|
||||
subject: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("sms"),
|
||||
phoneNumber: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("push"),
|
||||
deviceToken: z.string(),
|
||||
title: z.string(),
|
||||
body: z.string(),
|
||||
}),
|
||||
]);
|
||||
```
|
||||
|
||||
### Transform and Refine
|
||||
|
||||
```typescript
|
||||
// Transform input data
|
||||
export const createProductInputSchema = z.object({
|
||||
name: z.string().transform(s => s.trim()),
|
||||
price: z.string().transform(s => parseFloat(s)),
|
||||
tags: z.string().transform(s => s.split(",").map(t => t.trim())),
|
||||
});
|
||||
|
||||
// Add custom validation
|
||||
export const dateRangeSchema = z.object({
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
}).refine(
|
||||
data => new Date(data.endDate) > new Date(data.startDate),
|
||||
{ message: "End date must be after start date" }
|
||||
);
|
||||
```
|
||||
|
||||
## Error Handling Types
|
||||
|
||||
Use typed errors with oRPC:
|
||||
|
||||
```typescript
|
||||
import { ORPCError } from "@orpc/server";
|
||||
|
||||
// Standard error codes
|
||||
throw new ORPCError("NOT_FOUND", { message: "Resource not found" });
|
||||
throw new ORPCError("FORBIDDEN", { message: "Access denied" });
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Invalid input" });
|
||||
throw new ORPCError("UNAUTHORIZED", { message: "Authentication required" });
|
||||
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Unexpected error" });
|
||||
```
|
||||
|
||||
## Type Inference Helpers
|
||||
|
||||
```typescript
|
||||
// Infer types from Drizzle tables
|
||||
type User = typeof userTable.$inferSelect;
|
||||
type NewUser = typeof userTable.$inferInsert;
|
||||
|
||||
// Infer from Zod schemas
|
||||
type CreateOrderInput = z.infer<typeof createOrderInputSchema>;
|
||||
|
||||
// Utility types for partial updates
|
||||
type UpdateOrderInput = Partial<Omit<CreateOrderInput, "id">>;
|
||||
```
|
||||
34
.trellis/spec/big-question/index.md
Normal file
34
.trellis/spec/big-question/index.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Common Issues and Solutions
|
||||
|
||||
> Documented pitfalls discovered while building production Next.js fullstack applications.
|
||||
> These issues apply to any project using Next.js with PostgreSQL, Drizzle ORM, Tailwind CSS, and i18n.
|
||||
|
||||
## Severity Levels
|
||||
|
||||
| Level | Description |
|
||||
| -------- | ---------------------------------------------------- |
|
||||
| Critical | Build fails or data corruption |
|
||||
| Warning | Degraded experience, workaround exists |
|
||||
| Info | Minor visual issue, easy to fix once identified |
|
||||
|
||||
---
|
||||
|
||||
## Issue Index
|
||||
|
||||
| Issue | Category | Severity |
|
||||
| ---------------------------------------------------------------- | ---------------- | -------- |
|
||||
| [postgres-json-jsonb.md](./postgres-json-jsonb.md) | Database/ORM | Critical |
|
||||
| [sentry-nextintl-conflict.md](./sentry-nextintl-conflict.md) | Plugin Conflicts | Critical |
|
||||
| [turbopack-webpack-flexbox.md](./turbopack-webpack-flexbox.md) | Build System | Warning |
|
||||
| [webkit-tap-highlight.md](./webkit-tap-highlight.md) | Mobile/CSS | Info |
|
||||
|
||||
---
|
||||
|
||||
## How to Contribute
|
||||
|
||||
Found a new pitfall? Add it to this directory:
|
||||
|
||||
1. Create a new `.md` file with a descriptive kebab-case name
|
||||
2. Follow the existing format: Problem, Root Cause, Solution, Key Takeaways
|
||||
3. Update this index table with the correct category and severity
|
||||
4. Include reproducible code examples whenever possible
|
||||
192
.trellis/spec/big-question/postgres-json-jsonb.md
Normal file
192
.trellis/spec/big-question/postgres-json-jsonb.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# PostgreSQL JSON vs JSONB Type Issues with Drizzle ORM
|
||||
|
||||
## Problem
|
||||
|
||||
Database queries using PostgreSQL's `jsonb_*` functions fail with type errors, even though the column appears to store JSON data correctly.
|
||||
|
||||
**Error message:**
|
||||
```
|
||||
function jsonb_array_elements(json) does not exist
|
||||
HINT: No function matches the given name and argument types.
|
||||
You might need to add explicit type casts.
|
||||
```
|
||||
|
||||
**Additional symptoms:**
|
||||
- Raw SQL queries fail to find columns with camelCase names
|
||||
- JSON aggregation functions return unexpected results
|
||||
|
||||
## Root Cause
|
||||
|
||||
### Issue 1: Drizzle's `json()` Maps to PostgreSQL `json`, Not `jsonb`
|
||||
|
||||
When defining a JSON column in Drizzle ORM:
|
||||
|
||||
```typescript
|
||||
// Drizzle schema definition
|
||||
export const orders = pgTable("orders", {
|
||||
id: text("id").primaryKey(),
|
||||
metadata: json("metadata"), // Creates PostgreSQL 'json' type, NOT 'jsonb'
|
||||
});
|
||||
```
|
||||
|
||||
PostgreSQL has two JSON types with different characteristics:
|
||||
|
||||
| Feature | `json` | `jsonb` |
|
||||
|---------|--------|---------|
|
||||
| Storage | Text (preserves whitespace, key order) | Binary (normalized) |
|
||||
| Functions | `json_*` functions only | `jsonb_*` functions only |
|
||||
| Indexing | Limited | GIN indexes supported |
|
||||
| Performance | Slower for operations | Faster for operations |
|
||||
|
||||
The `jsonb_*` functions (like `jsonb_array_elements`, `jsonb_extract_path`) **only work with `jsonb` type**.
|
||||
|
||||
### Issue 2: Column Name Case Sensitivity
|
||||
|
||||
PostgreSQL treats unquoted identifiers as lowercase. If your column uses camelCase:
|
||||
|
||||
```sql
|
||||
-- This fails (looks for column named 'metadata' in lowercase)
|
||||
SELECT metadata->>'userId' FROM orders;
|
||||
|
||||
-- Column is actually named "metaData" with exact case
|
||||
SELECT "metaData"->>'userId' FROM orders;
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
### Solution 1: Use Type Cast for jsonb Functions
|
||||
|
||||
Add `::jsonb` type cast before using jsonb functions:
|
||||
|
||||
```typescript
|
||||
// Before (fails)
|
||||
const result = await db.execute(sql`
|
||||
SELECT jsonb_array_elements(items) as item
|
||||
FROM orders
|
||||
WHERE id = ${orderId}
|
||||
`);
|
||||
|
||||
// After (works)
|
||||
const result = await db.execute(sql`
|
||||
SELECT jsonb_array_elements(items::jsonb) as item
|
||||
FROM orders
|
||||
WHERE id = ${orderId}
|
||||
`);
|
||||
```
|
||||
|
||||
### Solution 2: Define Column as `jsonb` in Schema
|
||||
|
||||
If you need jsonb functionality frequently, define the column as jsonb:
|
||||
|
||||
```typescript
|
||||
import { pgTable, text, jsonb } from "drizzle-orm/pg-core";
|
||||
|
||||
export const orders = pgTable("orders", {
|
||||
id: text("id").primaryKey(),
|
||||
metadata: jsonb("metadata"), // Now uses PostgreSQL 'jsonb' type
|
||||
});
|
||||
```
|
||||
|
||||
**Note:** This requires a migration if the column already exists.
|
||||
|
||||
### Solution 3: Quote camelCase Column Names in Raw SQL
|
||||
|
||||
Always use double quotes for camelCase column names:
|
||||
|
||||
```typescript
|
||||
// Before (fails - column not found)
|
||||
const result = await db.execute(sql`
|
||||
SELECT "userId", createdAt
|
||||
FROM orders
|
||||
`);
|
||||
|
||||
// After (works)
|
||||
const result = await db.execute(sql`
|
||||
SELECT "userId", "createdAt"
|
||||
FROM orders
|
||||
`);
|
||||
```
|
||||
|
||||
### Complete Example: Querying JSON Array Data
|
||||
|
||||
```typescript
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
// Table with json column storing an array of items
|
||||
// items: [{ "productId": "123", "quantity": 2 }, ...]
|
||||
|
||||
// Query to find orders containing a specific product
|
||||
async function findOrdersWithProduct(productId: string) {
|
||||
const result = await db.execute(sql`
|
||||
SELECT
|
||||
o.id,
|
||||
o."createdAt",
|
||||
item->>'productId' as "productId",
|
||||
(item->>'quantity')::int as quantity
|
||||
FROM orders o,
|
||||
jsonb_array_elements(o.items::jsonb) as item
|
||||
WHERE item->>'productId' = ${productId}
|
||||
`);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// Query to aggregate JSON array data
|
||||
async function getOrderItemStats(orderId: string) {
|
||||
const result = await db.execute(sql`
|
||||
SELECT
|
||||
COUNT(*) as "itemCount",
|
||||
SUM((item->>'quantity')::int) as "totalQuantity"
|
||||
FROM orders o,
|
||||
jsonb_array_elements(o.items::jsonb) as item
|
||||
WHERE o.id = ${orderId}
|
||||
`);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
```
|
||||
|
||||
### Best Practice: Create a Helper for JSON Queries
|
||||
|
||||
```typescript
|
||||
// utils/db-helpers.ts
|
||||
import { sql, SQL } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Wraps a column reference with ::jsonb cast for use with jsonb functions
|
||||
*/
|
||||
export function asJsonb(column: SQL | string): SQL {
|
||||
if (typeof column === "string") {
|
||||
return sql.raw(`"${column}"::jsonb`);
|
||||
}
|
||||
return sql`${column}::jsonb`;
|
||||
}
|
||||
|
||||
// Usage
|
||||
const result = await db.execute(sql`
|
||||
SELECT jsonb_array_elements(${asJsonb("items")}) as item
|
||||
FROM orders
|
||||
`);
|
||||
```
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **Know the difference between `json` and `jsonb`**
|
||||
- `json`: Text storage, use `json_*` functions
|
||||
- `jsonb`: Binary storage, use `jsonb_*` functions, better performance
|
||||
|
||||
2. **Drizzle's `json()` creates PostgreSQL `json` type** - use `jsonb()` if you need jsonb functionality
|
||||
|
||||
3. **Always add `::jsonb` cast** when using jsonb functions with json columns
|
||||
|
||||
4. **Quote camelCase identifiers** in raw SQL queries with double quotes
|
||||
|
||||
5. **Prefer Drizzle's query builder** over raw SQL when possible to avoid these issues
|
||||
|
||||
6. **Test raw SQL queries** directly in a PostgreSQL client before using in code
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [PostgreSQL JSON Types Documentation](https://www.postgresql.org/docs/current/datatype-json.html)
|
||||
- [PostgreSQL JSON Functions](https://www.postgresql.org/docs/current/functions-json.html)
|
||||
- [Drizzle ORM PostgreSQL Column Types](https://orm.drizzle.team/docs/column-types/pg)
|
||||
223
.trellis/spec/big-question/sentry-nextintl-conflict.md
Normal file
223
.trellis/spec/big-question/sentry-nextintl-conflict.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# Sentry and next-intl Build Configuration Conflict
|
||||
|
||||
## Problem
|
||||
|
||||
Production builds fail with the error:
|
||||
|
||||
```
|
||||
Error: Couldn't find next-intl config file
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```
|
||||
Error: Failed to collect page data for /[locale]/page
|
||||
```
|
||||
|
||||
The build works fine in development mode but fails during `next build`.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The `withSentryConfig` wrapper in `next.config.js` interferes with other Next.js plugins, particularly `next-intl`'s plugin (`createNextIntlPlugin`).
|
||||
|
||||
### Technical Details
|
||||
|
||||
When you chain multiple config wrappers:
|
||||
|
||||
```javascript
|
||||
// next.config.js - Problematic configuration
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
const nextConfig = {
|
||||
// your config
|
||||
};
|
||||
|
||||
// This chaining causes conflicts
|
||||
export default withSentryConfig(withNextIntl(nextConfig), {
|
||||
// Sentry options
|
||||
});
|
||||
```
|
||||
|
||||
The issue occurs because:
|
||||
|
||||
1. **Plugin execution order matters** - Sentry's wrapper modifies the webpack configuration in ways that can break other plugins' assumptions
|
||||
2. **Build-time vs runtime** - Some plugins expect to run at specific build phases
|
||||
3. **Config mutation** - Wrappers may mutate the config object in incompatible ways
|
||||
|
||||
Specifically, `withSentryConfig`:
|
||||
- Modifies webpack configuration extensively
|
||||
- Adds custom loaders and plugins
|
||||
- May interfere with `next-intl`'s message loading mechanism
|
||||
|
||||
## Solution
|
||||
|
||||
### Solution 1: Remove withSentryConfig Wrapper (Recommended)
|
||||
|
||||
Sentry's runtime features still work via `instrumentation.ts` without the config wrapper:
|
||||
|
||||
```javascript
|
||||
// next.config.js - Fixed configuration
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
const nextConfig = {
|
||||
// your config
|
||||
};
|
||||
|
||||
// Only use next-intl wrapper, no Sentry wrapper
|
||||
export default withNextIntl(nextConfig);
|
||||
```
|
||||
|
||||
**Why Sentry still works:**
|
||||
|
||||
The `withSentryConfig` wrapper is primarily for:
|
||||
- Source map uploading
|
||||
- Build-time instrumentation
|
||||
- Release management
|
||||
|
||||
However, Sentry's core error tracking works through `instrumentation.ts`:
|
||||
|
||||
```typescript
|
||||
// instrumentation.ts
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
tracesSampleRate: 1.0,
|
||||
// ... other options
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === "edge") {
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
tracesSampleRate: 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Solution 2: Use Sentry Without Source Maps
|
||||
|
||||
If you need some Sentry build features but want to avoid conflicts:
|
||||
|
||||
```javascript
|
||||
// next.config.js
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
const nextConfig = {
|
||||
// your config
|
||||
};
|
||||
|
||||
// Apply next-intl first
|
||||
const configWithIntl = withNextIntl(nextConfig);
|
||||
|
||||
// Conditionally apply Sentry only if not causing issues
|
||||
const finalConfig = process.env.SKIP_SENTRY_BUILD
|
||||
? configWithIntl
|
||||
: withSentryConfig(configWithIntl, {
|
||||
silent: true,
|
||||
disableSourceMapUpload: true, // Disable problematic feature
|
||||
});
|
||||
|
||||
export default finalConfig;
|
||||
```
|
||||
|
||||
### Solution 3: Alternative Plugin Order
|
||||
|
||||
Sometimes reversing the wrapper order helps:
|
||||
|
||||
```javascript
|
||||
// Try applying Sentry first, then next-intl
|
||||
const configWithSentry = withSentryConfig(nextConfig, sentryOptions);
|
||||
export default withNextIntl(configWithSentry);
|
||||
```
|
||||
|
||||
**Note:** This may or may not work depending on your specific versions.
|
||||
|
||||
### Solution 4: Separate Sentry Configuration
|
||||
|
||||
Use Sentry CLI for source map upload instead of the webpack plugin:
|
||||
|
||||
```bash
|
||||
# In your CI/CD pipeline after build
|
||||
npx @sentry/cli sourcemaps upload ./next/static --org your-org --project your-project
|
||||
```
|
||||
|
||||
```javascript
|
||||
// next.config.js - Clean configuration
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
const nextConfig = {
|
||||
productionBrowserSourceMaps: true, // Enable source maps for Sentry CLI
|
||||
};
|
||||
|
||||
export default withNextIntl(nextConfig);
|
||||
```
|
||||
|
||||
## Verification Steps
|
||||
|
||||
After applying the fix:
|
||||
|
||||
1. **Clean build artifacts:**
|
||||
```bash
|
||||
rm -rf .next node_modules/.cache
|
||||
```
|
||||
|
||||
2. **Test production build:**
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
3. **Verify Sentry works in production:**
|
||||
```bash
|
||||
pnpm start
|
||||
# Trigger a test error and check Sentry dashboard
|
||||
```
|
||||
|
||||
4. **Test i18n routing:**
|
||||
```bash
|
||||
# Visit different locale routes
|
||||
curl http://localhost:3000/en/page
|
||||
curl http://localhost:3000/de/page
|
||||
```
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **Plugin wrappers can conflict** - Be cautious when combining multiple Next.js config wrappers
|
||||
|
||||
2. **Sentry works without withSentryConfig** - Core error tracking functions via `instrumentation.ts`
|
||||
|
||||
3. **Order matters** - Try different wrapper orders if you must use multiple plugins
|
||||
|
||||
4. **Source maps are optional** - You can still get stack traces without source map upload
|
||||
|
||||
5. **Test builds locally** - Always run `pnpm build` before deploying
|
||||
|
||||
6. **Keep dependencies updated** - Plugin compatibility issues are often fixed in newer versions
|
||||
|
||||
## Version Information
|
||||
|
||||
This issue was observed with:
|
||||
- Next.js 14.x / 15.x
|
||||
- @sentry/nextjs 7.x / 8.x
|
||||
- next-intl 3.x
|
||||
|
||||
Check the respective changelogs for compatibility updates.
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [Sentry Next.js SDK Documentation](https://docs.sentry.io/platforms/javascript/guides/nextjs/)
|
||||
- [next-intl Plugin Documentation](https://next-intl-docs.vercel.app/docs/getting-started/app-router)
|
||||
- [Next.js Configuration](https://nextjs.org/docs/app/api-reference/next-config-js)
|
||||
141
.trellis/spec/big-question/turbopack-webpack-flexbox.md
Normal file
141
.trellis/spec/big-question/turbopack-webpack-flexbox.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Turbopack vs Webpack Flexbox Layout Differences
|
||||
|
||||
## Problem
|
||||
|
||||
Layout works correctly in development mode (Turbopack) but breaks in production (Webpack). Specifically, flex containers and their children behave differently between the two bundlers.
|
||||
|
||||
**Symptoms:**
|
||||
- Components have correct height in dev mode but collapse or overflow in production
|
||||
- Scrollable areas work in dev but fail in prod
|
||||
- Nested flex layouts display differently between environments
|
||||
|
||||
## Root Cause
|
||||
|
||||
Turbopack (Next.js dev mode default) and Webpack (production build) have subtle differences in how they process CSS, particularly regarding flexbox behavior:
|
||||
|
||||
1. **Turbopack is stricter** about explicit flexbox properties
|
||||
2. **Webpack may auto-infer** certain flex child behaviors that Turbopack does not
|
||||
3. The difference lies in how CSS is compiled and applied, not in the CSS specification itself
|
||||
|
||||
### Technical Details
|
||||
|
||||
When a flex container has `flex-direction: column` and children that need to fill available space, the behavior depends on:
|
||||
|
||||
- The `align-items` property (defaults to `stretch` but may not be consistently applied)
|
||||
- Whether children have explicit `height` or `flex` properties
|
||||
- The interaction between nested flex containers
|
||||
|
||||
**Example of problematic layout:**
|
||||
|
||||
```tsx
|
||||
// Parent component
|
||||
<div className="flex flex-col h-screen">
|
||||
<Header /> {/* Fixed height */}
|
||||
<main className="flex-1 flex"> {/* Should fill remaining space */}
|
||||
<Sidebar />
|
||||
<Content /> {/* Should scroll internally */}
|
||||
</main>
|
||||
</div>
|
||||
```
|
||||
|
||||
In Turbopack, the `main` element might not properly pass its height to children without explicit `items-stretch`.
|
||||
|
||||
## Solution
|
||||
|
||||
### 1. Explicitly Set `items-stretch` on Flex Containers
|
||||
|
||||
Add `items-stretch` to main flex containers that need children to fill available space:
|
||||
|
||||
```tsx
|
||||
// Before (inconsistent between Turbopack/Webpack)
|
||||
<div className="flex flex-col h-screen">
|
||||
<main className="flex-1 flex">
|
||||
{/* children */}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
// After (consistent behavior)
|
||||
<div className="flex flex-col h-screen items-stretch">
|
||||
<main className="flex-1 flex items-stretch">
|
||||
{/* children */}
|
||||
</main>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 2. Apply Parent/Child Responsibility Separation
|
||||
|
||||
Follow a clear pattern for layout responsibilities:
|
||||
|
||||
**Parent's Responsibility:**
|
||||
- Define the flex container (`flex`, `flex-col`, `flex-row`)
|
||||
- Set alignment (`items-stretch`, `justify-between`)
|
||||
- Control overall dimensions (`h-screen`, `w-full`)
|
||||
|
||||
**Child's Responsibility:**
|
||||
- Define its own flex behavior (`flex-1`, `flex-shrink-0`)
|
||||
- Handle internal overflow (`overflow-auto`, `overflow-hidden`)
|
||||
- Set min/max constraints (`min-h-0`, `max-w-full`)
|
||||
|
||||
### 3. Use `min-h-0` for Scrollable Flex Children
|
||||
|
||||
When a flex child needs internal scrolling:
|
||||
|
||||
```tsx
|
||||
<div className="flex flex-col h-full items-stretch">
|
||||
<div className="flex-shrink-0">Fixed Header</div>
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{/* Scrollable content */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
The `min-h-0` is crucial because flex items default to `min-height: auto`, which can prevent overflow from working correctly.
|
||||
|
||||
### Complete Example
|
||||
|
||||
```tsx
|
||||
// App layout with consistent dev/prod behavior
|
||||
function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col h-screen items-stretch">
|
||||
{/* Fixed navigation */}
|
||||
<nav className="flex-shrink-0 h-16 border-b">
|
||||
<Navigation />
|
||||
</nav>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 flex items-stretch min-h-0">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 flex-shrink-0 border-r overflow-auto">
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
|
||||
{/* Main content with internal scroll */}
|
||||
<main className="flex-1 min-w-0 overflow-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **Always test production builds locally** before deployment using `pnpm build && pnpm start`
|
||||
|
||||
2. **Be explicit with flexbox properties** - Don't rely on browser defaults or bundler behavior
|
||||
|
||||
3. **Use `items-stretch` explicitly** on containers where children need to fill space
|
||||
|
||||
4. **Remember `min-h-0` and `min-w-0`** for scrollable flex children
|
||||
|
||||
5. **Separate layout responsibilities** between parent (container behavior) and child (self behavior)
|
||||
|
||||
6. **Document layout patterns** in your project to ensure consistency across the team
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [CSS Flexbox Guide](https://css-tricks.com/snippets/css/a-guide-to-flexbox/)
|
||||
- [Next.js Turbopack Documentation](https://nextjs.org/docs/architecture/turbopack)
|
||||
- [Tailwind CSS Flexbox Utilities](https://tailwindcss.com/docs/flex)
|
||||
218
.trellis/spec/big-question/webkit-tap-highlight.md
Normal file
218
.trellis/spec/big-question/webkit-tap-highlight.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# WebKit Tap Highlight and Border-Radius Issues on Mobile
|
||||
|
||||
## Problem
|
||||
|
||||
Buttons and interactive elements lose their `border-radius` styling when tapped on mobile devices (iOS Safari, Chrome on iOS). The element briefly shows a rectangular highlight instead of respecting the rounded corners.
|
||||
|
||||
**Symptoms:**
|
||||
- Button appears with sharp corners during tap/touch
|
||||
- A blue or gray rectangular overlay flashes on touch
|
||||
- The visual glitch only occurs on WebKit-based mobile browsers
|
||||
- Desktop browsers and Android Chrome don't show the issue
|
||||
|
||||
## Root Cause
|
||||
|
||||
WebKit browsers apply a default tap highlight effect to interactive elements. This highlight:
|
||||
|
||||
1. **Ignores `border-radius`** - The highlight is applied as a simple rectangular overlay
|
||||
2. **Uses system default color** - Typically a semi-transparent blue or gray
|
||||
3. **Overrides visual styling** - The highlight appears on top of your custom styles
|
||||
|
||||
### Technical Details
|
||||
|
||||
When you tap an element on iOS Safari:
|
||||
|
||||
```css
|
||||
/* WebKit's default behavior (pseudo-representation) */
|
||||
element:active {
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
|
||||
/* This creates a RECTANGULAR overlay, ignoring border-radius */
|
||||
}
|
||||
```
|
||||
|
||||
The tap highlight is rendered as a separate layer that doesn't respect the element's `border-radius`, `clip-path`, or other shape-defining properties.
|
||||
|
||||
## Solution
|
||||
|
||||
### Solution 1: Disable Tap Highlight + Wrapper with Overflow Hidden
|
||||
|
||||
The most reliable solution combines two techniques:
|
||||
|
||||
```tsx
|
||||
// Button component with proper mobile touch handling
|
||||
function Button({ children, className, ...props }: ButtonProps) {
|
||||
return (
|
||||
<div className="rounded-lg overflow-hidden inline-block">
|
||||
<button
|
||||
className={cn("rounded-lg px-4 py-2 bg-blue-500 text-white", className)}
|
||||
style={{ WebkitTapHighlightColor: "transparent" }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Why this works:**
|
||||
1. `WebkitTapHighlightColor: "transparent"` removes the default highlight
|
||||
2. The wrapper `div` with `overflow-hidden` clips any remaining visual artifacts
|
||||
3. Both elements have matching `border-radius` for consistent appearance
|
||||
|
||||
### Solution 2: CSS-Only Approach
|
||||
|
||||
If you can't modify the component structure:
|
||||
|
||||
```css
|
||||
/* In your global CSS */
|
||||
.tap-safe {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Custom active state to replace the highlight */
|
||||
.tap-safe:active {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
<button className="tap-safe rounded-lg px-4 py-2 bg-blue-500">
|
||||
Click me
|
||||
</button>
|
||||
```
|
||||
|
||||
### Solution 3: Tailwind CSS Utility Class
|
||||
|
||||
Add a reusable utility in your Tailwind config:
|
||||
|
||||
```javascript
|
||||
// tailwind.config.js
|
||||
module.exports = {
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [
|
||||
function({ addUtilities }) {
|
||||
addUtilities({
|
||||
'.tap-highlight-none': {
|
||||
'-webkit-tap-highlight-color': 'transparent',
|
||||
},
|
||||
});
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
Then use it in components:
|
||||
|
||||
```tsx
|
||||
<button className="tap-highlight-none rounded-lg px-4 py-2">
|
||||
Click me
|
||||
</button>
|
||||
```
|
||||
|
||||
### Solution 4: Wrapper Component for Consistent Behavior
|
||||
|
||||
Create a reusable wrapper for all interactive rounded elements:
|
||||
|
||||
```tsx
|
||||
// components/ui/touch-safe-wrapper.tsx
|
||||
interface TouchSafeWrapperProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
borderRadius?: string;
|
||||
}
|
||||
|
||||
export function TouchSafeWrapper({
|
||||
children,
|
||||
className,
|
||||
borderRadius = "rounded-lg"
|
||||
}: TouchSafeWrapperProps) {
|
||||
return (
|
||||
<div className={cn(borderRadius, "overflow-hidden inline-flex", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
<TouchSafeWrapper>
|
||||
<button
|
||||
className="rounded-lg px-4 py-2 bg-blue-500"
|
||||
style={{ WebkitTapHighlightColor: "transparent" }}
|
||||
>
|
||||
Click me
|
||||
</button>
|
||||
</TouchSafeWrapper>
|
||||
```
|
||||
|
||||
### Complete Example: Card with Clickable Areas
|
||||
|
||||
```tsx
|
||||
function ProductCard({ product }: { product: Product }) {
|
||||
return (
|
||||
<div className="rounded-xl border p-4">
|
||||
<h3>{product.name}</h3>
|
||||
<p>{product.description}</p>
|
||||
|
||||
{/* Action buttons with tap-safe handling */}
|
||||
<div className="flex gap-2 mt-4">
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<button
|
||||
className="rounded-lg px-4 py-2 bg-blue-500 text-white"
|
||||
style={{ WebkitTapHighlightColor: "transparent" }}
|
||||
onClick={() => addToCart(product)}
|
||||
>
|
||||
Add to Cart
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<button
|
||||
className="rounded-lg px-4 py-2 border border-gray-300"
|
||||
style={{ WebkitTapHighlightColor: "transparent" }}
|
||||
onClick={() => viewDetails(product)}
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **WebKit tap highlight ignores border-radius** - This is browser behavior, not a CSS bug
|
||||
|
||||
2. **Always set `WebkitTapHighlightColor: "transparent"`** on interactive elements with rounded corners
|
||||
|
||||
3. **Use a wrapper with `overflow-hidden`** for the most reliable visual clipping
|
||||
|
||||
4. **Test on actual iOS devices** - Simulators and browser dev tools may not reproduce the issue
|
||||
|
||||
5. **Consider adding custom active states** to replace the removed tap feedback for better UX
|
||||
|
||||
6. **Create reusable components** that handle mobile touch behavior consistently
|
||||
|
||||
## Browser Support Notes
|
||||
|
||||
| Browser | Needs Fix |
|
||||
|---------|-----------|
|
||||
| iOS Safari | Yes |
|
||||
| Chrome on iOS | Yes (uses WebKit) |
|
||||
| Firefox on iOS | Yes (uses WebKit) |
|
||||
| Android Chrome | Usually no |
|
||||
| Desktop browsers | No |
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [MDN: -webkit-tap-highlight-color](https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-tap-highlight-color)
|
||||
- [WebKit Bug Tracker](https://bugs.webkit.org/)
|
||||
- [CSS Tricks: Handling Touch Events](https://css-tricks.com/snippets/css/remove-gray-highlight-when-tapping-links-in-mobile-safari/)
|
||||
255
.trellis/spec/frontend/ai-sdk-integration.md
Normal file
255
.trellis/spec/frontend/ai-sdk-integration.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# AI SDK Frontend Integration
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This guide covers frontend integration with the Vercel AI SDK using `@ai-sdk/react`. Key topics include:
|
||||
|
||||
- Using `@ai-sdk/react` for React integration
|
||||
- Streaming chat with the `useChat` hook
|
||||
- Tool call handling with proper format detection
|
||||
|
||||
## 2. Basic Chat with useChat
|
||||
|
||||
The `useChat` hook provides a simple interface for chat functionality:
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
|
||||
export function ChatPanel() {
|
||||
const { messages, input, handleInputChange, handleSubmit, status } = useChat({
|
||||
api: "/api/chat",
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{messages.map((message) => (
|
||||
<div key={message.id}>
|
||||
<strong>{message.role}:</strong> {message.content}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Type a message..."
|
||||
disabled={status === "streaming"}
|
||||
/>
|
||||
<button type="submit" disabled={status === "streaming"}>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Custom Transport with oRPC
|
||||
|
||||
When using oRPC instead of standard fetch:
|
||||
|
||||
```typescript
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { eventIteratorToStream } from "@orpc/client";
|
||||
import { orpcClient } from "@/lib/orpc-client";
|
||||
|
||||
export function ChatPanel({ sessionId }: { sessionId: string }) {
|
||||
const { messages, sendMessage, status } = useChat({
|
||||
id: sessionId,
|
||||
transport: {
|
||||
async sendMessages(options) {
|
||||
return eventIteratorToStream(
|
||||
await orpcClient.chat.send(
|
||||
{
|
||||
sessionId,
|
||||
messages: options.messages,
|
||||
},
|
||||
{ signal: options.abortSignal }
|
||||
)
|
||||
);
|
||||
},
|
||||
reconnectToStream() {
|
||||
throw new Error("Reconnect not supported");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// ... rest of component
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Tool Calls Handling
|
||||
|
||||
**CRITICAL**: Tool calls have TWO different formats that must both be handled:
|
||||
|
||||
### Format 1: Real-time Streaming
|
||||
|
||||
During streaming, tool results appear as:
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: "tool-createTask", // tool-{toolName}
|
||||
toolCallId: "call_abc123",
|
||||
state: "output-available",
|
||||
input: { title: "...", priority: "high" },
|
||||
output: { success: true, taskId: "task_xyz" } // Direct object
|
||||
}
|
||||
```
|
||||
|
||||
### Format 2: History Restore
|
||||
|
||||
When loading from history/database:
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: "tool-result",
|
||||
toolName: "createTask",
|
||||
toolCallId: "call_abc123",
|
||||
output: {
|
||||
type: "json",
|
||||
value: { success: true, taskId: "task_xyz" } // Nested in value
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Unified Handling Pattern
|
||||
|
||||
```typescript
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
|
||||
export function AssistantPanel({ sessionId }: { sessionId: string }) {
|
||||
const [createdItems, setCreatedItems] = useState<Map<string, CreatedItem>>(new Map());
|
||||
const toolCallsRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
const { messages, status } = useChat({
|
||||
id: sessionId,
|
||||
transport: { /* ... */ },
|
||||
|
||||
// Handle real-time tool results
|
||||
onData: (dataPart) => {
|
||||
const payload = typeof dataPart === "object" && "json" in dataPart
|
||||
? (dataPart as { json: unknown }).json
|
||||
: dataPart;
|
||||
|
||||
if (typeof payload === "object" && payload !== null && "type" in payload) {
|
||||
const { type, data } = payload as { type: string; data: any };
|
||||
|
||||
if (type === "tool-output-available" || type === "tool-result") {
|
||||
const { toolCallId, output } = data;
|
||||
const toolName = toolCallsRef.current.get(toolCallId);
|
||||
|
||||
if (toolName === "createTask" && output?.success) {
|
||||
setCreatedItems((prev) => {
|
||||
if (prev.has(toolCallId)) return prev;
|
||||
return new Map(prev).set(toolCallId, {
|
||||
id: output.taskId,
|
||||
title: output.title,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Handle history restore
|
||||
useEffect(() => {
|
||||
messages.forEach((message) => {
|
||||
if (message.role !== "assistant") return;
|
||||
|
||||
const parts = (message as any).parts || [];
|
||||
parts.forEach((part: any) => {
|
||||
// Match both formats
|
||||
const isRealTime = part.type === "tool-createTask" && part.state === "output-available";
|
||||
const isRestored = part.type === "tool-result" && part.toolName === "createTask";
|
||||
|
||||
if ((isRealTime || isRestored) && part.output) {
|
||||
const key = part.toolCallId || message.id;
|
||||
|
||||
// Extract output (handle nested structure)
|
||||
const rawOutput = part.output;
|
||||
const output = rawOutput?.type === "json" && rawOutput?.value
|
||||
? rawOutput.value
|
||||
: rawOutput;
|
||||
|
||||
if (output?.success) {
|
||||
setCreatedItems((prev) => {
|
||||
if (prev.has(key)) return prev;
|
||||
return new Map(prev).set(key, {
|
||||
id: output.taskId,
|
||||
title: output.title,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [messages, status]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
|
||||
{/* Display created items */}
|
||||
{Array.from(createdItems.values()).map((item) => (
|
||||
<CreatedItemCard key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Tool Call State Lifecycle
|
||||
|
||||
During streaming, tool parts go through these states:
|
||||
|
||||
| State | Description |
|
||||
|-------|-------------|
|
||||
| `input-streaming` | Tool input is being generated |
|
||||
| `input-available` | Complete input ready |
|
||||
| `output-available` | Tool executed, result available |
|
||||
| `output-error` | Tool execution failed |
|
||||
|
||||
## 6. Displaying Thought Process
|
||||
|
||||
Show users what the AI is "thinking":
|
||||
|
||||
```typescript
|
||||
const [thoughtSteps, setThoughtSteps] = useState<ThoughtStep[]>([]);
|
||||
|
||||
// In onData handler
|
||||
if (type === "tool-input-start" || type === "tool-call") {
|
||||
const { toolCallId, toolName, input } = data;
|
||||
toolCallsRef.current.set(toolCallId, toolName);
|
||||
|
||||
setThoughtSteps((prev) => [
|
||||
...prev,
|
||||
{ id: toolCallId, toolName, status: "pending", input },
|
||||
]);
|
||||
}
|
||||
|
||||
if (type === "tool-output-available") {
|
||||
setThoughtSteps((prev) =>
|
||||
prev.map((step) =>
|
||||
step.id === toolCallId
|
||||
? { ...step, status: "done", result: output }
|
||||
: step
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Best Practices Summary
|
||||
|
||||
| Rule | Description |
|
||||
|------|-------------|
|
||||
| Handle both tool formats | Real-time and history restore |
|
||||
| Use toolCallId as key | Correlate calls across formats |
|
||||
| Use useRef for toolName mapping | Avoid React state timing issues |
|
||||
| onData for real-time UI | useEffect for history restore |
|
||||
| Show thought process | Better UX for tool-heavy flows |
|
||||
464
.trellis/spec/frontend/api-integration.md
Normal file
464
.trellis/spec/frontend/api-integration.md
Normal file
@@ -0,0 +1,464 @@
|
||||
# API Integration
|
||||
|
||||
This document covers API integration patterns including oRPC client usage, real-time communication, and AI streaming.
|
||||
|
||||
## oRPC Client Usage
|
||||
|
||||
### Client Setup
|
||||
|
||||
```typescript
|
||||
// lib/orpc.ts
|
||||
import { createORPCClient } from '@your-app/api/client'; // Replace with your monorepo package path
|
||||
|
||||
export const orpcClient = createORPCClient({
|
||||
baseUrl: process.env.NEXT_PUBLIC_API_URL,
|
||||
});
|
||||
```
|
||||
|
||||
### Basic API Calls
|
||||
|
||||
```typescript
|
||||
// Simple GET
|
||||
const users = await orpcClient.users.list();
|
||||
|
||||
// GET with parameters
|
||||
const user = await orpcClient.users.get({ id: userId });
|
||||
|
||||
// POST (create)
|
||||
const newUser = await orpcClient.users.create({
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
});
|
||||
|
||||
// PUT/PATCH (update)
|
||||
const updatedUser = await orpcClient.users.update({
|
||||
id: userId,
|
||||
name: 'Jane Doe',
|
||||
});
|
||||
|
||||
// DELETE
|
||||
await orpcClient.users.delete({ id: userId });
|
||||
```
|
||||
|
||||
### With React Query
|
||||
|
||||
```typescript
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { orpcClient } from '@/lib/orpc';
|
||||
|
||||
// Query
|
||||
export function useUsers() {
|
||||
return useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => orpcClient.users.list(),
|
||||
});
|
||||
}
|
||||
|
||||
// Mutation
|
||||
export function useCreateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: orpcClient.users.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Query Patterns
|
||||
|
||||
### Pagination
|
||||
|
||||
```typescript
|
||||
interface PaginationParams {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export function usePaginatedOrders({ page, pageSize }: PaginationParams) {
|
||||
return useQuery({
|
||||
queryKey: ['orders', { page, pageSize }],
|
||||
queryFn: () => orpcClient.orders.list({ page, pageSize }),
|
||||
placeholderData: (prev) => prev, // Keep previous data while fetching
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Filtering and Sorting
|
||||
|
||||
```typescript
|
||||
interface OrderFilters {
|
||||
status?: string;
|
||||
customerId?: string;
|
||||
sortBy?: 'createdAt' | 'total';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export function useFilteredOrders(filters: OrderFilters) {
|
||||
return useQuery({
|
||||
queryKey: ['orders', filters],
|
||||
queryFn: () => orpcClient.orders.list(filters),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Prefetching
|
||||
|
||||
```typescript
|
||||
export function useOrdersWithPrefetch() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['orders', { page: 1 }],
|
||||
queryFn: () => orpcClient.orders.list({ page: 1 }),
|
||||
});
|
||||
|
||||
// Prefetch next page
|
||||
useEffect(() => {
|
||||
if (query.data?.hasNextPage) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['orders', { page: 2 }],
|
||||
queryFn: () => orpcClient.orders.list({ page: 2 }),
|
||||
});
|
||||
}
|
||||
}, [query.data, queryClient]);
|
||||
|
||||
return query;
|
||||
}
|
||||
```
|
||||
|
||||
## Real-time Communication
|
||||
|
||||
### WebSocket with Ably
|
||||
|
||||
```typescript
|
||||
// lib/ably.ts
|
||||
import Ably from 'ably';
|
||||
|
||||
export const ablyClient = new Ably.Realtime({
|
||||
authUrl: '/api/ably/auth',
|
||||
});
|
||||
|
||||
// Hook for real-time updates
|
||||
export function useRealtimeOrders() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
const channel = ablyClient.channels.get('orders');
|
||||
|
||||
channel.subscribe('order:created', (message) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
||||
});
|
||||
|
||||
channel.subscribe('order:updated', (message) => {
|
||||
const order = message.data;
|
||||
queryClient.setQueryData(['orders', order.id], order);
|
||||
});
|
||||
|
||||
return () => {
|
||||
channel.unsubscribe();
|
||||
};
|
||||
}, [queryClient]);
|
||||
}
|
||||
```
|
||||
|
||||
### WebSocket Connection Management
|
||||
|
||||
```typescript
|
||||
export function useWebSocket(channelName: string) {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const channelRef = useRef<Ably.RealtimeChannel | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const channel = ablyClient.channels.get(channelName);
|
||||
channelRef.current = channel;
|
||||
|
||||
channel.on('attached', () => setIsConnected(true));
|
||||
channel.on('detached', () => setIsConnected(false));
|
||||
|
||||
return () => {
|
||||
channel.detach();
|
||||
};
|
||||
}, [channelName]);
|
||||
|
||||
const subscribe = useCallback(
|
||||
(event: string, callback: (data: unknown) => void) => {
|
||||
channelRef.current?.subscribe(event, (message) => {
|
||||
callback(message.data);
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return { isConnected, subscribe };
|
||||
}
|
||||
```
|
||||
|
||||
## SSE/Streaming for AI Chat
|
||||
|
||||
### Basic SSE Pattern
|
||||
|
||||
```typescript
|
||||
export function useAIChat() {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
|
||||
const sendMessage = useCallback(async (content: string) => {
|
||||
setIsStreaming(true);
|
||||
|
||||
// Add user message
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: 'user', content },
|
||||
]);
|
||||
|
||||
// Create placeholder for assistant response
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: 'assistant', content: '' },
|
||||
]);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ai/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: content }),
|
||||
});
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (reader) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const lastMessage = updated[updated.length - 1];
|
||||
lastMessage.content += chunk;
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsStreaming(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { messages, sendMessage, isStreaming };
|
||||
}
|
||||
```
|
||||
|
||||
### Using Vercel AI SDK
|
||||
|
||||
```typescript
|
||||
import { useChat } from 'ai/react';
|
||||
|
||||
export function useAIChatWithSDK() {
|
||||
const {
|
||||
messages,
|
||||
input,
|
||||
handleInputChange,
|
||||
handleSubmit,
|
||||
isLoading,
|
||||
error,
|
||||
} = useChat({
|
||||
api: '/api/ai/chat',
|
||||
onFinish: (message) => {
|
||||
// Handle completed message
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
messages,
|
||||
input,
|
||||
handleInputChange,
|
||||
handleSubmit,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## AI Tool Calls Handling
|
||||
|
||||
AI responses may include tool calls (function calls). The format differs between real-time streaming and history restore.
|
||||
|
||||
### Real-time Streaming Format
|
||||
|
||||
During streaming, tool calls arrive incrementally:
|
||||
|
||||
```typescript
|
||||
interface StreamingToolCall {
|
||||
type: 'tool-call';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface StreamingToolResult {
|
||||
type: 'tool-result';
|
||||
toolCallId: string;
|
||||
result: unknown;
|
||||
}
|
||||
```
|
||||
|
||||
### History Restore Format
|
||||
|
||||
When loading chat history, tool calls are embedded in messages:
|
||||
|
||||
```typescript
|
||||
interface HistoryMessage {
|
||||
role: 'assistant';
|
||||
content: string;
|
||||
toolInvocations?: Array<{
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
state: 'pending' | 'result' | 'error';
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
### Unified Handler Pattern
|
||||
|
||||
```typescript
|
||||
interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
state: 'pending' | 'result' | 'error';
|
||||
}
|
||||
|
||||
function normalizeToolCall(
|
||||
data: StreamingToolCall | HistoryMessage['toolInvocations'][0]
|
||||
): ToolCall {
|
||||
// Handle streaming format
|
||||
if ('type' in data && data.type === 'tool-call') {
|
||||
return {
|
||||
id: data.toolCallId,
|
||||
name: data.toolName,
|
||||
args: data.args,
|
||||
state: 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
// Handle history format
|
||||
return {
|
||||
id: data.toolCallId,
|
||||
name: data.toolName,
|
||||
args: data.args,
|
||||
result: data.result,
|
||||
state: data.state,
|
||||
};
|
||||
}
|
||||
|
||||
// Usage in component
|
||||
function ToolCallDisplay({ toolCall }: { toolCall: ToolCall }) {
|
||||
switch (toolCall.name) {
|
||||
case 'searchProducts':
|
||||
return <ProductSearchResult args={toolCall.args} result={toolCall.result} />;
|
||||
case 'createOrder':
|
||||
return <OrderCreationResult args={toolCall.args} result={toolCall.result} />;
|
||||
default:
|
||||
return <GenericToolResult toolCall={toolCall} />;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Handling Tool Call States
|
||||
|
||||
```typescript
|
||||
export function useToolCallHandler() {
|
||||
const [pendingToolCalls, setPendingToolCalls] = useState<Map<string, ToolCall>>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
const handleStreamChunk = useCallback((chunk: unknown) => {
|
||||
if (isToolCall(chunk)) {
|
||||
setPendingToolCalls((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(chunk.toolCallId, normalizeToolCall(chunk));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (isToolResult(chunk)) {
|
||||
setPendingToolCalls((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(chunk.toolCallId);
|
||||
if (existing) {
|
||||
next.set(chunk.toolCallId, {
|
||||
...existing,
|
||||
result: chunk.result,
|
||||
state: 'result',
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { pendingToolCalls, handleStreamChunk };
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### API Error Handling
|
||||
|
||||
```typescript
|
||||
import { isORPCError } from '@your-app/api/client'; // Replace with your monorepo package path
|
||||
|
||||
export function useCreateOrder() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: orpcClient.orders.create,
|
||||
onError: (err) => {
|
||||
if (isORPCError(err)) {
|
||||
switch (err.code) {
|
||||
case 'UNAUTHORIZED':
|
||||
setError('Please sign in to continue');
|
||||
break;
|
||||
case 'VALIDATION_ERROR':
|
||||
setError('Please check your input');
|
||||
break;
|
||||
default:
|
||||
setError('Something went wrong');
|
||||
}
|
||||
} else {
|
||||
setError('Network error. Please try again.');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return { ...mutation, error };
|
||||
}
|
||||
```
|
||||
|
||||
### Retry Logic
|
||||
|
||||
```typescript
|
||||
export function useResilientQuery() {
|
||||
return useQuery({
|
||||
queryKey: ['data'],
|
||||
queryFn: () => orpcClient.data.get(),
|
||||
retry: 3,
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Centralize API Client**: Keep oRPC client configuration in one place
|
||||
2. **Use Query Keys Consistently**: Follow a hierarchical naming convention
|
||||
3. **Handle Loading States**: Always show feedback during API calls
|
||||
4. **Implement Error Boundaries**: Catch and display errors gracefully
|
||||
5. **Optimize Real-time**: Unsubscribe from channels when components unmount
|
||||
6. **Type Everything**: Leverage TypeScript for API response types
|
||||
748
.trellis/spec/frontend/authentication.md
Normal file
748
.trellis/spec/frontend/authentication.md
Normal file
@@ -0,0 +1,748 @@
|
||||
# Frontend Authentication with better-auth
|
||||
|
||||
This document provides guidelines for implementing client-side authentication using better-auth in a Next.js React application.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
better-auth provides a comprehensive authentication solution for React applications with:
|
||||
|
||||
- **Session Management**: Cookie-based sessions with automatic refresh
|
||||
- **Multiple Auth Methods**: Password, magic link, OAuth, and passkeys
|
||||
- **Type Safety**: Full TypeScript support with inferred types
|
||||
- **Plugin Architecture**: Extensible through plugins (2FA, organizations, admin, etc.)
|
||||
|
||||
### Key Concepts
|
||||
|
||||
- **Auth Client**: The main interface for all authentication operations
|
||||
- **Session Context**: React context for accessing session state across components
|
||||
- **Middleware**: Server-side route protection before rendering
|
||||
|
||||
## 2. Auth Client Setup
|
||||
|
||||
### Creating the Auth Client
|
||||
|
||||
Create a centralized auth client that can be imported throughout your application:
|
||||
|
||||
```typescript
|
||||
// packages/auth/client.ts
|
||||
import {
|
||||
adminClient,
|
||||
inferAdditionalFields,
|
||||
magicLinkClient,
|
||||
organizationClient,
|
||||
passkeyClient,
|
||||
twoFactorClient,
|
||||
} from "better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import type { auth } from ".";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [
|
||||
inferAdditionalFields<typeof auth>(),
|
||||
magicLinkClient(),
|
||||
organizationClient(),
|
||||
adminClient(),
|
||||
passkeyClient(),
|
||||
twoFactorClient(),
|
||||
],
|
||||
});
|
||||
|
||||
export type AuthClientErrorCodes = typeof authClient.$ERROR_CODES & {
|
||||
INVALID_INVITATION: string;
|
||||
};
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
The auth client supports various plugins based on your needs:
|
||||
|
||||
| Plugin | Purpose |
|
||||
|--------|---------|
|
||||
| `inferAdditionalFields` | Type inference for custom user fields |
|
||||
| `magicLinkClient` | Passwordless email login |
|
||||
| `organizationClient` | Multi-tenant organization support |
|
||||
| `adminClient` | Admin user management |
|
||||
| `passkeyClient` | WebAuthn/Passkey authentication |
|
||||
| `twoFactorClient` | Two-factor authentication |
|
||||
|
||||
## 3. Session Hook/Context
|
||||
|
||||
### Session Context Definition
|
||||
|
||||
Define the session context type and create the context:
|
||||
|
||||
```typescript
|
||||
// lib/session-context.ts
|
||||
import type { Session } from "@your-app/auth"; // Replace with your monorepo package path
|
||||
import React from "react";
|
||||
|
||||
export const SessionContext = React.createContext<
|
||||
| {
|
||||
session: Session["session"] | null;
|
||||
user: Session["user"] | null;
|
||||
loaded: boolean;
|
||||
reloadSession: () => Promise<void>;
|
||||
}
|
||||
| undefined
|
||||
>(undefined);
|
||||
```
|
||||
|
||||
### Session Provider Component
|
||||
|
||||
Wrap your application with a SessionProvider to manage session state:
|
||||
|
||||
```typescript
|
||||
// components/SessionProvider.tsx
|
||||
"use client";
|
||||
import { authClient } from "@your-app/auth/client"; // Replace with your monorepo package path
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { SessionContext } from "../lib/session-context";
|
||||
|
||||
// Query key for session caching
|
||||
export const sessionQueryKey = ["user", "session"] as const;
|
||||
|
||||
// Custom hook for fetching session
|
||||
export const useSessionQuery = () => {
|
||||
return useQuery({
|
||||
queryKey: sessionQueryKey,
|
||||
queryFn: async () => {
|
||||
const { data, error } = await authClient.getSession({
|
||||
query: {
|
||||
disableCookieCache: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || "Failed to fetch session");
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
};
|
||||
|
||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: session } = useSessionQuery();
|
||||
const [loaded, setLoaded] = useState(!!session);
|
||||
|
||||
useEffect(() => {
|
||||
if (session && !loaded) {
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [session, loaded]);
|
||||
|
||||
return (
|
||||
<SessionContext.Provider
|
||||
value={{
|
||||
loaded,
|
||||
session: session?.session ?? null,
|
||||
user: session?.user ?? null,
|
||||
reloadSession: async () => {
|
||||
const { data: newSession, error } = await authClient.getSession({
|
||||
query: {
|
||||
disableCookieCache: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || "Failed to fetch session");
|
||||
}
|
||||
|
||||
queryClient.setQueryData(sessionQueryKey, () => newSession);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SessionContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### useSession Hook
|
||||
|
||||
Create a convenient hook to access session data:
|
||||
|
||||
```typescript
|
||||
// hooks/use-session.ts
|
||||
import { useContext } from "react";
|
||||
import { SessionContext } from "../lib/session-context";
|
||||
|
||||
export const useSession = () => {
|
||||
const sessionContext = useContext(SessionContext);
|
||||
|
||||
if (sessionContext === undefined) {
|
||||
throw new Error("useSession must be used within SessionProvider");
|
||||
}
|
||||
|
||||
return sessionContext;
|
||||
};
|
||||
```
|
||||
|
||||
### Usage Example
|
||||
|
||||
```typescript
|
||||
function UserGreeting() {
|
||||
const { user, loaded } = useSession();
|
||||
|
||||
if (!loaded) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <div>Please log in</div>;
|
||||
}
|
||||
|
||||
return <div>Welcome, {user.name}!</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Protected Routes
|
||||
|
||||
### Middleware for Route Protection
|
||||
|
||||
Use Next.js middleware to protect routes at the server level:
|
||||
|
||||
```typescript
|
||||
// middleware.ts
|
||||
import { getSessionCookie } from "better-auth/cookies";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { withQuery } from "ufo";
|
||||
|
||||
export default async function middleware(req: NextRequest) {
|
||||
const { pathname, origin } = req.nextUrl;
|
||||
const sessionCookie = getSessionCookie(req);
|
||||
|
||||
// Protect /app routes
|
||||
if (pathname.startsWith("/app")) {
|
||||
if (!sessionCookie) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
withQuery("/auth/login", {
|
||||
redirectTo: pathname,
|
||||
}),
|
||||
origin,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Allow auth routes
|
||||
if (pathname.startsWith("/auth")) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!api|_next/static|_next/image|favicon.ico).*)",
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### Client-Side Route Protection
|
||||
|
||||
For additional client-side protection, redirect authenticated users away from auth pages:
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useSession } from "@/hooks/use-session";
|
||||
|
||||
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const { user, loaded } = useSession();
|
||||
const redirectPath = "/app/dashboard";
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded && user) {
|
||||
router.replace(redirectPath);
|
||||
}
|
||||
}, [user, loaded, router]);
|
||||
|
||||
if (!loaded) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return null; // Will redirect
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
### Loading States
|
||||
|
||||
Always handle loading states to prevent flash of unauthorized content:
|
||||
|
||||
```typescript
|
||||
function ProtectedContent() {
|
||||
const { user, loaded } = useSession();
|
||||
|
||||
// Show loading while session is being fetched
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect or show unauthorized message
|
||||
if (!user) {
|
||||
return <Redirect to="/auth/login" />;
|
||||
}
|
||||
|
||||
return <DashboardContent user={user} />;
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Login/Logout Flows
|
||||
|
||||
### Email/Password Sign In
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { authClient } from "@your-app/auth/client"; // Replace with your monorepo package path
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter();
|
||||
|
||||
const onSubmit = async (values: { email: string; password: string }) => {
|
||||
try {
|
||||
const { data, error } = await authClient.signIn.email({
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Handle 2FA redirect if enabled
|
||||
if ((data as any).twoFactorRedirect) {
|
||||
router.replace("/auth/verify");
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to dashboard
|
||||
router.replace("/app/dashboard");
|
||||
} catch (e) {
|
||||
// Handle error
|
||||
console.error("Login failed:", e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{/* Form fields */}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Magic Link Sign In
|
||||
|
||||
```typescript
|
||||
const signInWithMagicLink = async (email: string) => {
|
||||
const { error } = await authClient.signIn.magicLink({
|
||||
email,
|
||||
callbackURL: "/app/dashboard",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Show success message - user will receive email
|
||||
showNotification("Check your email for the login link");
|
||||
};
|
||||
```
|
||||
|
||||
### OAuth Sign In
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { authClient } from "@your-app/auth/client"; // Replace with your monorepo package path
|
||||
|
||||
function SocialSigninButton({ provider }: { provider: string }) {
|
||||
const redirectPath = "/app/dashboard";
|
||||
|
||||
const onSignin = () => {
|
||||
const callbackURL = new URL(redirectPath, window.location.origin);
|
||||
authClient.signIn.social({
|
||||
provider, // "google", "github", etc.
|
||||
callbackURL: callbackURL.toString(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={onSignin}>
|
||||
Sign in with {provider}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Passkey Sign In
|
||||
|
||||
```typescript
|
||||
const signInWithPasskey = async () => {
|
||||
try {
|
||||
await authClient.signIn.passkey();
|
||||
router.replace("/app/dashboard");
|
||||
} catch (e) {
|
||||
console.error("Passkey authentication failed:", e);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Sign Out
|
||||
|
||||
```typescript
|
||||
import { authClient } from "@your-app/auth/client"; // Replace with your monorepo package path
|
||||
|
||||
const onLogout = () => {
|
||||
authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: async () => {
|
||||
// Redirect to home or login page
|
||||
window.location.href = new URL("/", window.location.origin).toString();
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## 6. User Profile
|
||||
|
||||
### Accessing Current User
|
||||
|
||||
Use the `useSession` hook to access user data:
|
||||
|
||||
```typescript
|
||||
function UserProfile() {
|
||||
const { user, loaded } = useSession();
|
||||
|
||||
if (!loaded || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { name, email, image } = user;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<img src={image} alt={name} className="w-10 h-10 rounded-full" />
|
||||
<div>
|
||||
<p className="font-medium">{name}</p>
|
||||
<p className="text-sm text-gray-500">{email}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Updating User Profile
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { authClient } from "@your-app/auth/client"; // Replace with your monorepo package path
|
||||
import { useSession } from "@/hooks/use-session";
|
||||
|
||||
function ChangeNameForm() {
|
||||
const { user, reloadSession } = useSession();
|
||||
|
||||
const onSubmit = async ({ name }: { name: string }) => {
|
||||
const { error } = await authClient.updateUser({
|
||||
name,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
showError("Failed to update name");
|
||||
return;
|
||||
}
|
||||
|
||||
showSuccess("Name updated successfully");
|
||||
|
||||
// Reload session to reflect changes
|
||||
await reloadSession();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={user?.name ?? ""}
|
||||
{...register("name")}
|
||||
/>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Updating Other Profile Fields
|
||||
|
||||
```typescript
|
||||
// Update avatar
|
||||
const updateAvatar = async (imageUrl: string) => {
|
||||
const { error } = await authClient.updateUser({
|
||||
image: imageUrl,
|
||||
});
|
||||
|
||||
if (!error) {
|
||||
await reloadSession();
|
||||
}
|
||||
};
|
||||
|
||||
// Update language preference (if custom field)
|
||||
const updateLanguage = async (language: string) => {
|
||||
const { error } = await authClient.updateUser({
|
||||
language,
|
||||
});
|
||||
|
||||
if (!error) {
|
||||
await reloadSession();
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## 7. Server-Side Session Access
|
||||
|
||||
For server components, access the session directly:
|
||||
|
||||
```typescript
|
||||
// lib/server.ts
|
||||
import "server-only";
|
||||
import { auth } from "@your-app/auth"; // Replace with your monorepo package path
|
||||
import { headers } from "next/headers";
|
||||
import { cache } from "react";
|
||||
|
||||
export const getSession = cache(async () => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
query: {
|
||||
disableCookieCache: true,
|
||||
},
|
||||
});
|
||||
|
||||
return session;
|
||||
});
|
||||
|
||||
export const getActiveOrganization = cache(async (slug: string) => {
|
||||
try {
|
||||
const activeOrganization = await auth.api.getFullOrganization({
|
||||
query: {
|
||||
organizationSlug: slug,
|
||||
},
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
return activeOrganization;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Usage in Server Components
|
||||
|
||||
```typescript
|
||||
// app/(app)/dashboard/page.tsx
|
||||
import { getSession } from "@/lib/server";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Welcome, {session.user.name}</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 8. Best Practices
|
||||
|
||||
### Always Check Session Before Protected Operations
|
||||
|
||||
```typescript
|
||||
function DeleteAccountButton() {
|
||||
const { user, loaded } = useSession();
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!loaded || !user) {
|
||||
showError("Not authenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
// Proceed with deletion
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={handleDelete} disabled={!loaded || !user}>
|
||||
Delete Account
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Handle Loading States Properly
|
||||
|
||||
```typescript
|
||||
function AuthenticatedComponent() {
|
||||
const { user, loaded } = useSession();
|
||||
|
||||
// Always handle loading state first
|
||||
if (!loaded) {
|
||||
return <Skeleton />;
|
||||
}
|
||||
|
||||
// Then handle unauthenticated state
|
||||
if (!user) {
|
||||
return <LoginPrompt />;
|
||||
}
|
||||
|
||||
// Finally render authenticated content
|
||||
return <ProtectedContent user={user} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Proper Redirect After Auth
|
||||
|
||||
```typescript
|
||||
function LoginForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const redirectTo = searchParams.get("redirectTo");
|
||||
|
||||
const onLoginSuccess = () => {
|
||||
// Redirect to original destination or default
|
||||
const destination = redirectTo ?? "/app/dashboard";
|
||||
router.replace(destination);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Invalidate Session Cache After Auth Changes
|
||||
|
||||
```typescript
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
function AuthComponent() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const onAuthChange = () => {
|
||||
// Invalidate session cache to trigger refetch
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: sessionQueryKey,
|
||||
});
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
const handleAuthError = (error: any) => {
|
||||
// Get error code from better-auth error
|
||||
const errorCode = error?.code;
|
||||
|
||||
// Map to user-friendly message
|
||||
const errorMessages: Record<string, string> = {
|
||||
INVALID_CREDENTIALS: "Invalid email or password",
|
||||
USER_NOT_FOUND: "No account found with this email",
|
||||
EMAIL_NOT_VERIFIED: "Please verify your email first",
|
||||
TOO_MANY_REQUESTS: "Too many attempts. Please try again later",
|
||||
};
|
||||
|
||||
const message = errorMessages[errorCode] ?? "An error occurred";
|
||||
showError(message);
|
||||
};
|
||||
```
|
||||
|
||||
### Security Considerations
|
||||
|
||||
1. **Never store sensitive auth data in localStorage** - better-auth uses secure HTTP-only cookies
|
||||
2. **Always validate sessions server-side** - Middleware protection is essential
|
||||
3. **Use HTTPS in production** - Required for secure cookies
|
||||
4. **Implement CSRF protection** - better-auth handles this automatically
|
||||
5. **Set appropriate session expiry** - Configure in server auth options
|
||||
|
||||
## 9. Common Patterns
|
||||
|
||||
### Conditional Rendering Based on Auth
|
||||
|
||||
```typescript
|
||||
function Navigation() {
|
||||
const { user, loaded } = useSession();
|
||||
|
||||
return (
|
||||
<nav>
|
||||
<Link href="/">Home</Link>
|
||||
{loaded && (
|
||||
<>
|
||||
{user ? (
|
||||
<>
|
||||
<Link href="/app/dashboard">Dashboard</Link>
|
||||
<LogoutButton />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/auth/login">Login</Link>
|
||||
<Link href="/auth/signup">Sign Up</Link>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Auth State Persistence Across Tabs
|
||||
|
||||
```typescript
|
||||
// Session is automatically synced via cookies
|
||||
// For real-time sync, listen to storage events
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === "auth-sync") {
|
||||
reloadSession();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
return () => window.removeEventListener("storage", handleStorageChange);
|
||||
}, []);
|
||||
```
|
||||
|
||||
### Automatic Session Refresh
|
||||
|
||||
```typescript
|
||||
// Configure in useSessionQuery
|
||||
export const useSessionQuery = () => {
|
||||
return useQuery({
|
||||
queryKey: sessionQueryKey,
|
||||
queryFn: fetchSession,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchInterval: 10 * 60 * 1000, // Refetch every 10 minutes
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
};
|
||||
```
|
||||
454
.trellis/spec/frontend/components.md
Normal file
454
.trellis/spec/frontend/components.md
Normal file
@@ -0,0 +1,454 @@
|
||||
# Component Development Guidelines
|
||||
|
||||
This document covers component development patterns including Server vs Client components, semantic HTML, and UI best practices.
|
||||
|
||||
## Server vs Client Components
|
||||
|
||||
### Default to Server Components
|
||||
|
||||
Next.js App Router defaults to Server Components. Use them for:
|
||||
|
||||
- Data fetching
|
||||
- Accessing backend resources directly
|
||||
- Keeping sensitive data on the server
|
||||
- Reducing client-side JavaScript
|
||||
|
||||
```typescript
|
||||
// app/(app)/dashboard/page.tsx (Server Component)
|
||||
import { DashboardStats } from '@/modules/dashboard/components';
|
||||
|
||||
export default async function DashboardPage() {
|
||||
// Can fetch data directly
|
||||
const stats = await fetchDashboardStats();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Dashboard</h1>
|
||||
<DashboardStats data={stats} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### When to Use Client Components
|
||||
|
||||
Add `'use client'` directive only when you need:
|
||||
|
||||
- Event handlers (onClick, onChange, etc.)
|
||||
- useState, useEffect, or other React hooks
|
||||
- Browser-only APIs (localStorage, window)
|
||||
- Class components with lifecycle methods
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export function Counter() {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return (
|
||||
<button onClick={() => setCount(count + 1)}>
|
||||
Count: {count}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Composition Pattern
|
||||
|
||||
Keep Server Components at the top, push Client Components down:
|
||||
|
||||
```typescript
|
||||
// Server Component (page.tsx)
|
||||
import { ProductList } from './ProductList';
|
||||
import { FilterSidebar } from './FilterSidebar'; // Client
|
||||
|
||||
export default async function ProductsPage() {
|
||||
const products = await fetchProducts();
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<FilterSidebar /> {/* Client component for interactivity */}
|
||||
<ProductList products={products} /> {/* Can be server or client */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Passing Server Data to Client Components
|
||||
|
||||
```typescript
|
||||
// Server Component
|
||||
export default async function Page() {
|
||||
const initialData = await fetchData();
|
||||
|
||||
return <InteractiveWidget initialData={initialData} />;
|
||||
}
|
||||
|
||||
// Client Component
|
||||
'use client';
|
||||
|
||||
export function InteractiveWidget({ initialData }: { initialData: Data }) {
|
||||
const [data, setData] = useState(initialData);
|
||||
// Interactive logic...
|
||||
}
|
||||
```
|
||||
|
||||
## Semantic HTML
|
||||
|
||||
### Use Proper Elements
|
||||
|
||||
```typescript
|
||||
// Bad: div for everything
|
||||
<div onClick={handleClick}>Click me</div>
|
||||
<div>
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
</div>
|
||||
|
||||
// Good: semantic elements
|
||||
<button onClick={handleClick}>Click me</button>
|
||||
<ul>
|
||||
<li>Item 1</li>
|
||||
<li>Item 2</li>
|
||||
</ul>
|
||||
```
|
||||
|
||||
### Button vs Div
|
||||
|
||||
Always use `<button>` for clickable actions:
|
||||
|
||||
```typescript
|
||||
// Bad: Non-semantic, no keyboard support, no accessibility
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={handleClick}
|
||||
>
|
||||
Save
|
||||
</div>
|
||||
|
||||
// Good: Semantic, keyboard accessible, proper focus
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className="..."
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
```
|
||||
|
||||
### Form Elements
|
||||
|
||||
```typescript
|
||||
// Bad: Missing labels, wrong elements
|
||||
<div>
|
||||
<span>Email</span>
|
||||
<input type="text" />
|
||||
</div>
|
||||
|
||||
// Good: Proper form structure
|
||||
<div>
|
||||
<label htmlFor="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
aria-describedby="email-error"
|
||||
/>
|
||||
{error && <p id="email-error" role="alert">{error}</p>}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Navigation
|
||||
|
||||
```typescript
|
||||
// Bad
|
||||
<div onClick={() => router.push('/about')}>About</div>
|
||||
|
||||
// Good
|
||||
<Link href="/about">About</Link>
|
||||
|
||||
// For programmatic navigation with button appearance
|
||||
<Link href="/about" className="btn btn-primary">
|
||||
About
|
||||
</Link>
|
||||
```
|
||||
|
||||
## Next.js Image Component
|
||||
|
||||
### Always Use next/image
|
||||
|
||||
```typescript
|
||||
// Bad: Raw img tag
|
||||
<img src="/hero.jpg" alt="Hero" />
|
||||
|
||||
// Good: Optimized Image component
|
||||
import Image from 'next/image';
|
||||
|
||||
<Image
|
||||
src="/hero.jpg"
|
||||
alt="Hero image"
|
||||
width={1200}
|
||||
height={600}
|
||||
priority // For above-the-fold images
|
||||
/>
|
||||
```
|
||||
|
||||
### Responsive Images
|
||||
|
||||
```typescript
|
||||
// Fill container
|
||||
<div className="relative h-64 w-full">
|
||||
<Image
|
||||
src="/banner.jpg"
|
||||
alt="Banner"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Remote Images
|
||||
|
||||
Configure domains in `next.config.js`:
|
||||
|
||||
```javascript
|
||||
// next.config.js
|
||||
module.exports = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'images.example.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Command Palette (cmdk)
|
||||
|
||||
### Basic Implementation
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { Command } from 'cmdk';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Toggle with keyboard shortcut
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setOpen((open) => !open);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', down);
|
||||
return () => document.removeEventListener('keydown', down);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Command.Dialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
label="Global Command Menu"
|
||||
>
|
||||
<Command.Input placeholder="Type a command or search..." />
|
||||
<Command.List>
|
||||
<Command.Empty>No results found.</Command.Empty>
|
||||
|
||||
<Command.Group heading="Navigation">
|
||||
<Command.Item onSelect={() => router.push('/dashboard')}>
|
||||
Go to Dashboard
|
||||
</Command.Item>
|
||||
<Command.Item onSelect={() => router.push('/settings')}>
|
||||
Go to Settings
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
|
||||
<Command.Group heading="Actions">
|
||||
<Command.Item onSelect={handleNewOrder}>
|
||||
Create New Order
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
</Command.List>
|
||||
</Command.Dialog>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### With Search Results
|
||||
|
||||
```typescript
|
||||
export function SearchCommandPalette() {
|
||||
const [search, setSearch] = useState('');
|
||||
const { data: results, isLoading } = useSearch(search);
|
||||
|
||||
return (
|
||||
<Command.Dialog open={open} onOpenChange={setOpen}>
|
||||
<Command.Input
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder="Search..."
|
||||
/>
|
||||
<Command.List>
|
||||
{isLoading && <Command.Loading>Searching...</Command.Loading>}
|
||||
|
||||
<Command.Empty>No results found.</Command.Empty>
|
||||
|
||||
{results?.map((item) => (
|
||||
<Command.Item
|
||||
key={item.id}
|
||||
value={item.title}
|
||||
onSelect={() => handleSelect(item)}
|
||||
>
|
||||
{item.title}
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.List>
|
||||
</Command.Dialog>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Styling with Tailwind
|
||||
|
||||
### Component Styling Pattern
|
||||
|
||||
```typescript
|
||||
// Use className for styling
|
||||
export function Card({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border bg-card p-4 shadow-sm',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional Styles
|
||||
|
||||
```typescript
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
className,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md font-medium',
|
||||
// Variants
|
||||
{
|
||||
'bg-primary text-primary-foreground': variant === 'primary',
|
||||
'bg-secondary text-secondary-foreground': variant === 'secondary',
|
||||
'border bg-transparent': variant === 'outline',
|
||||
},
|
||||
// Sizes
|
||||
{
|
||||
'h-8 px-3 text-sm': size === 'sm',
|
||||
'h-10 px-4': size === 'md',
|
||||
'h-12 px-6 text-lg': size === 'lg',
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Responsive Design
|
||||
|
||||
```typescript
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{items.map((item) => (
|
||||
<Card key={item.id}>{item.content}</Card>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
### Focus Management
|
||||
|
||||
```typescript
|
||||
export function Modal({ open, onClose, children }: ModalProps) {
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
closeButtonRef.current?.focus();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
{children}
|
||||
<button ref={closeButtonRef} onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### ARIA Labels
|
||||
|
||||
```typescript
|
||||
<button
|
||||
aria-label="Close dialog"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls="dropdown-menu"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="dropdown-menu"
|
||||
role="menu"
|
||||
aria-hidden={!isOpen}
|
||||
>
|
||||
{/* Menu items */}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Server First**: Default to Server Components
|
||||
2. **Semantic HTML**: Use the right element for the job
|
||||
3. **Optimize Images**: Always use next/image
|
||||
4. **Accessibility**: Include ARIA labels and keyboard support
|
||||
5. **Type Props**: Define TypeScript interfaces for all props
|
||||
6. **Composition**: Break large components into smaller pieces
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Using `div` for buttons and links
|
||||
- Using `img` instead of `next/image`
|
||||
- Adding `'use client'` at the top of every file
|
||||
- Inline styles instead of Tailwind classes
|
||||
- Missing accessibility attributes
|
||||
- Components with too many responsibilities
|
||||
381
.trellis/spec/frontend/css-layout.md
Normal file
381
.trellis/spec/frontend/css-layout.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# CSS & Layout Best Practices
|
||||
|
||||
This document covers CSS patterns, layout strategies, and cross-environment compatibility considerations.
|
||||
|
||||
## Flexbox Patterns
|
||||
|
||||
### Use items-stretch on Main Flex Containers
|
||||
|
||||
For full-height layouts where children should fill the available space:
|
||||
|
||||
```typescript
|
||||
// Good: items-stretch (default) allows children to fill height
|
||||
<div className="flex h-screen">
|
||||
<aside className="w-64 bg-gray-100">
|
||||
{/* Sidebar fills full height */}
|
||||
</aside>
|
||||
<main className="flex-1">
|
||||
{/* Main content fills full height */}
|
||||
</main>
|
||||
</div>
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Bad: items-center prevents children from filling container height
|
||||
<div className="flex h-screen items-center">
|
||||
<aside className="w-64 bg-gray-100">
|
||||
{/* Sidebar only as tall as its content */}
|
||||
</aside>
|
||||
<main className="flex-1">
|
||||
{/* Main content only as tall as its content */}
|
||||
</main>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Nested Flex Containers
|
||||
|
||||
```typescript
|
||||
<div className="flex h-screen flex-col">
|
||||
{/* Header - fixed height */}
|
||||
<header className="h-16 shrink-0 border-b">
|
||||
<nav>...</nav>
|
||||
</header>
|
||||
|
||||
{/* Main area - fills remaining space */}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Sidebar - fixed width, full height */}
|
||||
<aside className="w-64 shrink-0 overflow-y-auto border-r">
|
||||
<nav>...</nav>
|
||||
</aside>
|
||||
|
||||
{/* Content - fills remaining width */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="p-6">...</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### min-h-0 for Overflow Control
|
||||
|
||||
When using flex containers with scrollable children:
|
||||
|
||||
```typescript
|
||||
// Without min-h-0, content may overflow
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="flex-1">
|
||||
{/* This might overflow if content is tall */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// With min-h-0, overflow is properly contained
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{/* Content scrolls within container */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Parent-Child Styling Pattern
|
||||
|
||||
### Parent Provides External Styles
|
||||
|
||||
The parent component controls:
|
||||
- Positioning (absolute, relative, grid placement)
|
||||
- External spacing (margin, gap)
|
||||
- Size constraints (width, max-width)
|
||||
|
||||
```typescript
|
||||
// Parent component
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Card className="col-span-2" /> {/* Parent sets grid span */}
|
||||
<Card />
|
||||
</div>
|
||||
```
|
||||
|
||||
### Child Provides Internal Layout
|
||||
|
||||
The child component controls:
|
||||
- Internal padding
|
||||
- Internal layout (flex, grid)
|
||||
- Background, borders, shadows
|
||||
- Typography
|
||||
|
||||
```typescript
|
||||
// Child component
|
||||
export function Card({ className, children }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Internal styles owned by Card
|
||||
'rounded-lg border bg-white p-4 shadow-sm',
|
||||
// External styles from parent
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Complete Example
|
||||
|
||||
```typescript
|
||||
// Page layout (parent)
|
||||
export function DashboardPage() {
|
||||
return (
|
||||
<div className="grid gap-6 p-6 lg:grid-cols-3">
|
||||
{/* Parent controls: grid position, external spacing */}
|
||||
<StatsCard className="lg:col-span-2" />
|
||||
<ActivityFeed className="lg:row-span-2" />
|
||||
<RecentOrders />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Card component (child)
|
||||
export function StatsCard({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Child controls: internal padding, background, border
|
||||
'flex flex-col gap-4 rounded-xl bg-white p-6 shadow',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Internal layout */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Cross-Environment Testing
|
||||
|
||||
### Dev Mode (Turbopack) vs Production (Webpack)
|
||||
|
||||
CSS may behave differently between development and production builds:
|
||||
|
||||
```bash
|
||||
# Test in development (Turbopack)
|
||||
pnpm dev
|
||||
|
||||
# Test in production (Webpack)
|
||||
pnpm build && pnpm start
|
||||
```
|
||||
|
||||
### Common Differences
|
||||
|
||||
1. **CSS Order**: Tailwind classes may be applied in different orders
|
||||
2. **Purging**: Unused classes removed in production
|
||||
3. **Minification**: Class names optimized
|
||||
|
||||
### Testing Checklist
|
||||
|
||||
- [ ] Run `pnpm dev` and test all features
|
||||
- [ ] Run `pnpm build && pnpm start` and test again
|
||||
- [ ] Check for visual differences
|
||||
- [ ] Verify responsive breakpoints work
|
||||
- [ ] Test animations and transitions
|
||||
|
||||
## Mobile Touch Optimization
|
||||
|
||||
### Disable Tap Highlight
|
||||
|
||||
Prevent the default blue/gray highlight on mobile tap:
|
||||
|
||||
```typescript
|
||||
// Using Tailwind
|
||||
<button className="[-webkit-tap-highlight-color:transparent]">
|
||||
Tap me
|
||||
</button>
|
||||
|
||||
// Using inline styles (when needed)
|
||||
<button style={{ WebkitTapHighlightColor: 'transparent' }}>
|
||||
Tap me
|
||||
</button>
|
||||
|
||||
// Global reset in CSS
|
||||
@layer base {
|
||||
button, a, [role="button"] {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Touch-Friendly Sizing
|
||||
|
||||
```typescript
|
||||
// Minimum touch target: 44x44px
|
||||
<button className="min-h-[44px] min-w-[44px] p-3">
|
||||
<Icon size={20} />
|
||||
</button>
|
||||
|
||||
// For lists
|
||||
<ul className="divide-y">
|
||||
{items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<button className="w-full px-4 py-3 text-left">
|
||||
{item.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
```
|
||||
|
||||
### Prevent Pull-to-Refresh
|
||||
|
||||
When implementing custom scroll behaviors:
|
||||
|
||||
```typescript
|
||||
<div
|
||||
className="h-screen overflow-y-auto overscroll-contain"
|
||||
style={{ touchAction: 'pan-y' }}
|
||||
>
|
||||
{/* Scrollable content */}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Responsive Design Patterns
|
||||
|
||||
### Mobile-First Approach
|
||||
|
||||
```typescript
|
||||
// Start with mobile styles, add breakpoints for larger screens
|
||||
<div className="
|
||||
p-4 // Mobile: small padding
|
||||
md:p-6 // Tablet: medium padding
|
||||
lg:p-8 // Desktop: large padding
|
||||
">
|
||||
<h1 className="
|
||||
text-xl // Mobile: small heading
|
||||
md:text-2xl // Tablet: medium heading
|
||||
lg:text-3xl // Desktop: large heading
|
||||
">
|
||||
Title
|
||||
</h1>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Container Queries (Tailwind v4)
|
||||
|
||||
```typescript
|
||||
// Container-based responsive styles
|
||||
<div className="@container">
|
||||
<div className="
|
||||
flex flex-col
|
||||
@md:flex-row // Row layout when container >= md
|
||||
@lg:gap-6 // Larger gap when container >= lg
|
||||
">
|
||||
{/* Content */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Hiding/Showing Elements
|
||||
|
||||
```typescript
|
||||
// Hide on mobile, show on desktop
|
||||
<div className="hidden lg:block">
|
||||
Desktop only content
|
||||
</div>
|
||||
|
||||
// Show on mobile, hide on desktop
|
||||
<div className="lg:hidden">
|
||||
Mobile only content
|
||||
</div>
|
||||
```
|
||||
|
||||
## Z-Index Management
|
||||
|
||||
### Establish a Scale
|
||||
|
||||
```css
|
||||
/* In your CSS or Tailwind config */
|
||||
:root {
|
||||
--z-dropdown: 10;
|
||||
--z-sticky: 20;
|
||||
--z-fixed: 30;
|
||||
--z-modal-backdrop: 40;
|
||||
--z-modal: 50;
|
||||
--z-popover: 60;
|
||||
--z-tooltip: 70;
|
||||
}
|
||||
```
|
||||
|
||||
### Tailwind Config
|
||||
|
||||
```javascript
|
||||
// tailwind.config.js
|
||||
module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
zIndex: {
|
||||
dropdown: '10',
|
||||
sticky: '20',
|
||||
fixed: '30',
|
||||
modalBackdrop: '40',
|
||||
modal: '50',
|
||||
popover: '60',
|
||||
tooltip: '70',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```typescript
|
||||
<div className="z-modal">Modal content</div>
|
||||
<div className="z-tooltip">Tooltip</div>
|
||||
```
|
||||
|
||||
## Animation Best Practices
|
||||
|
||||
### Use CSS Transitions
|
||||
|
||||
```typescript
|
||||
<button className="
|
||||
transition-colors duration-200 ease-out
|
||||
hover:bg-primary-dark
|
||||
">
|
||||
Hover me
|
||||
</button>
|
||||
```
|
||||
|
||||
### Respect Motion Preferences
|
||||
|
||||
```typescript
|
||||
// Disable animations for users who prefer reduced motion
|
||||
<div className="
|
||||
transition-transform duration-300
|
||||
motion-reduce:transition-none
|
||||
hover:scale-105
|
||||
motion-reduce:hover:scale-100
|
||||
">
|
||||
Animated element
|
||||
</div>
|
||||
```
|
||||
|
||||
### Hardware Acceleration
|
||||
|
||||
```typescript
|
||||
// Use transform for smooth animations
|
||||
<div className="
|
||||
translate-x-0 transition-transform
|
||||
group-hover:translate-x-2
|
||||
">
|
||||
Slides on hover
|
||||
</div>
|
||||
```
|
||||
|
||||
## Best Practices Summary
|
||||
|
||||
1. **items-stretch**: Default for main flex containers
|
||||
2. **Parent External, Child Internal**: Clear separation of concerns
|
||||
3. **Test Both Modes**: Always verify in dev AND production
|
||||
4. **Touch Optimization**: Disable tap highlight, ensure touch targets
|
||||
5. **Mobile First**: Build up from smallest screens
|
||||
6. **Consistent Z-Index**: Use a defined scale
|
||||
7. **Respect Accessibility**: Honor motion preferences
|
||||
189
.trellis/spec/frontend/directory-structure.md
Normal file
189
.trellis/spec/frontend/directory-structure.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# Directory Structure
|
||||
|
||||
This document describes the module organization and folder conventions for the frontend application.
|
||||
|
||||
## Overview
|
||||
|
||||
```
|
||||
app/ # Next.js App Router
|
||||
├── (marketing)/ # Public marketing pages (i18n)
|
||||
│ └── [locale]/ # Locale-based routing
|
||||
└── (app)/ # Protected application routes
|
||||
└── app/ # Main application routes
|
||||
modules/ # Feature modules
|
||||
├── [feature]/ # Feature module
|
||||
│ ├── components/ # UI components
|
||||
│ ├── hooks/ # Custom hooks
|
||||
│ ├── context/ # React Context
|
||||
│ ├── lib/ # Utilities and data transforms
|
||||
│ └── types/ # Frontend view model types
|
||||
├── shared/ # Shared components across features
|
||||
└── ui/ # UI component library
|
||||
middleware.ts # Authentication & routing middleware
|
||||
```
|
||||
|
||||
## Module Structure
|
||||
|
||||
### Feature Module Pattern
|
||||
|
||||
Each feature module should follow this structure:
|
||||
|
||||
```
|
||||
modules/dashboard/
|
||||
├── components/
|
||||
│ ├── DashboardHeader.tsx
|
||||
│ ├── StatsCard.tsx
|
||||
│ ├── ActivityFeed.tsx
|
||||
│ └── index.ts # Barrel export
|
||||
├── hooks/
|
||||
│ ├── useDashboardStats.ts
|
||||
│ ├── useActivityFeed.ts
|
||||
│ └── index.ts
|
||||
├── context/
|
||||
│ ├── DashboardContext.tsx
|
||||
│ └── index.ts
|
||||
├── lib/
|
||||
│ ├── formatters.ts # Data formatting utilities
|
||||
│ ├── transformers.ts # API response transformers
|
||||
│ └── constants.ts # Feature-specific constants
|
||||
├── types/
|
||||
│ └── index.ts # View model types
|
||||
└── index.ts # Public API of the module
|
||||
```
|
||||
|
||||
### Component Organization
|
||||
|
||||
```
|
||||
components/
|
||||
├── [ComponentName].tsx # Main component file
|
||||
├── [ComponentName].test.tsx # Unit tests (if applicable)
|
||||
└── index.ts # Barrel export
|
||||
```
|
||||
|
||||
### Hooks Organization
|
||||
|
||||
```
|
||||
hooks/
|
||||
├── useFeatureData.ts # Data fetching hooks
|
||||
├── useFeatureActions.ts # Mutation hooks
|
||||
├── useFeatureState.ts # Local state hooks
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
## Shared Modules
|
||||
|
||||
### `modules/shared/`
|
||||
|
||||
Components and utilities shared across multiple features:
|
||||
|
||||
```
|
||||
shared/
|
||||
├── components/
|
||||
│ ├── Layout/ # Layout components
|
||||
│ ├── Navigation/ # Navigation components
|
||||
│ ├── DataTable/ # Reusable data tables
|
||||
│ └── Forms/ # Form components
|
||||
├── hooks/
|
||||
│ ├── useUser.ts # Current user hook
|
||||
│ ├── useOrganization.ts # Organization context
|
||||
│ └── usePermissions.ts # Permission checks
|
||||
└── lib/
|
||||
├── api.ts # API client configuration
|
||||
└── utils.ts # Shared utilities
|
||||
```
|
||||
|
||||
### `modules/ui/`
|
||||
|
||||
Low-level UI components (design system):
|
||||
|
||||
```
|
||||
ui/
|
||||
├── Button/
|
||||
├── Input/
|
||||
├── Select/
|
||||
├── Dialog/
|
||||
├── Toast/
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Files
|
||||
|
||||
| Type | Convention | Example |
|
||||
|------|------------|---------|
|
||||
| Components | PascalCase | `UserProfile.tsx` |
|
||||
| Hooks | camelCase with `use` prefix | `useUserProfile.ts` |
|
||||
| Context | PascalCase with `Context` suffix | `UserContext.tsx` |
|
||||
| Utilities | camelCase | `formatDate.ts` |
|
||||
| Constants | camelCase or SCREAMING_SNAKE_CASE | `constants.ts` |
|
||||
| Types | PascalCase | `types.ts` or `UserTypes.ts` |
|
||||
|
||||
### Exports
|
||||
|
||||
Use barrel exports (`index.ts`) for clean imports:
|
||||
|
||||
```typescript
|
||||
// modules/dashboard/components/index.ts
|
||||
export { DashboardHeader } from './DashboardHeader';
|
||||
export { StatsCard } from './StatsCard';
|
||||
export { ActivityFeed } from './ActivityFeed';
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Usage
|
||||
import { DashboardHeader, StatsCard } from '@/modules/dashboard/components';
|
||||
```
|
||||
|
||||
## Route-Module Mapping
|
||||
|
||||
Routes in `app/(app)/` should map to modules in `modules/`:
|
||||
|
||||
```
|
||||
app/(app)/
|
||||
├── dashboard/
|
||||
│ └── page.tsx -> modules/dashboard/
|
||||
├── users/
|
||||
│ ├── page.tsx -> modules/users/
|
||||
│ └── [id]/
|
||||
│ └── page.tsx -> modules/users/ (detail view)
|
||||
├── settings/
|
||||
│ └── page.tsx -> modules/settings/
|
||||
└── orders/
|
||||
├── page.tsx -> modules/orders/
|
||||
└── [id]/
|
||||
└── page.tsx -> modules/orders/ (detail view)
|
||||
```
|
||||
|
||||
## Import Path Aliases
|
||||
|
||||
Configure in `tsconfig.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@/modules/*": ["./modules/*"],
|
||||
"@/components/*": ["./components/*"],
|
||||
"@/lib/*": ["./lib/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Colocation**: Keep related files close together
|
||||
2. **Single Responsibility**: Each module should have one clear purpose
|
||||
3. **Explicit Dependencies**: Import what you need, avoid implicit globals
|
||||
4. **Barrel Exports**: Use `index.ts` for public APIs
|
||||
5. **Private by Default**: Only export what needs to be shared
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
- Deeply nested folder structures (max 3-4 levels)
|
||||
- Circular dependencies between modules
|
||||
- Mixing feature code with shared utilities
|
||||
- Importing internal module files directly (use barrel exports)
|
||||
- Creating "utils" folders that become dumping grounds
|
||||
328
.trellis/spec/frontend/hooks.md
Normal file
328
.trellis/spec/frontend/hooks.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# Hook Development Patterns
|
||||
|
||||
This document covers React hook patterns for data fetching, mutations, and state management using React Query with oRPC.
|
||||
|
||||
## Query Hooks
|
||||
|
||||
### Basic Query Pattern
|
||||
|
||||
```typescript
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { orpcClient } from '@/lib/orpc';
|
||||
|
||||
export function useUsers() {
|
||||
return useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => orpcClient.users.list(),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Query with Parameters
|
||||
|
||||
```typescript
|
||||
export function useUser(userId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['users', userId],
|
||||
queryFn: () => orpcClient.users.get({ id: userId }),
|
||||
enabled: !!userId, // Only fetch when userId is available
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Query with Filters
|
||||
|
||||
```typescript
|
||||
interface UseOrdersOptions {
|
||||
status?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export function useOrders(options: UseOrdersOptions = {}) {
|
||||
const { status, page = 1, pageSize = 20 } = options;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['orders', { status, page, pageSize }],
|
||||
queryFn: () => orpcClient.orders.list({ status, page, pageSize }),
|
||||
placeholderData: (previousData) => previousData, // Keep previous data while fetching
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Mutation Hooks
|
||||
|
||||
### Basic Mutation Pattern
|
||||
|
||||
```typescript
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { orpcClient } from '@/lib/orpc';
|
||||
|
||||
export function useCreateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateUserInput) => orpcClient.users.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Mutation with Optimistic Updates
|
||||
|
||||
```typescript
|
||||
type OrderListData = Awaited<ReturnType<typeof orpcClient.orders.list>>;
|
||||
|
||||
export function useUpdateOrderStatus() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||||
orpcClient.orders.updateStatus({ id, status }),
|
||||
|
||||
onMutate: async ({ id, status }) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: ['orders'] });
|
||||
|
||||
// Snapshot previous value
|
||||
const previousOrders = queryClient.getQueryData<OrderListData>(['orders']);
|
||||
|
||||
// Optimistically update
|
||||
queryClient.setQueryData<OrderListData>(['orders'], (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
items: old.items.map((order) =>
|
||||
order.id === id ? { ...order, status } : order
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return { previousOrders };
|
||||
},
|
||||
|
||||
onError: (_err, _variables, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousOrders) {
|
||||
queryClient.setQueryData(['orders'], context.previousOrders);
|
||||
}
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
// Always refetch after mutation
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Overriding Mutation Callbacks
|
||||
|
||||
When overriding mutation callbacks at the call site, you MUST add explicit generics to maintain type safety:
|
||||
|
||||
### Problem: Lost Type Safety
|
||||
|
||||
```typescript
|
||||
// Hook definition
|
||||
export function useDeleteUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => orpcClient.users.delete({ id }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Bad: Overriding without generics loses type safety
|
||||
const deleteUser = useDeleteUser();
|
||||
deleteUser.mutate(userId, {
|
||||
onSuccess: (data) => {
|
||||
// 'data' is typed as 'unknown' here!
|
||||
console.log(data.id); // TypeScript error or runtime error
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Solution: Explicit Generics
|
||||
|
||||
```typescript
|
||||
// Infer types for the mutation
|
||||
type DeleteUserData = Awaited<ReturnType<typeof orpcClient.users.delete>>;
|
||||
type DeleteUserVariables = string;
|
||||
|
||||
// Good: Add explicit generics when overriding callbacks
|
||||
deleteUser.mutate<DeleteUserData, Error, DeleteUserVariables>(userId, {
|
||||
onSuccess: (data) => {
|
||||
// 'data' is properly typed
|
||||
console.log(data.id); // Works correctly
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Alternative: Define Types in Hook
|
||||
|
||||
```typescript
|
||||
// Export types from the hook file
|
||||
export type DeleteUserMutationData = Awaited<
|
||||
ReturnType<typeof orpcClient.users.delete>
|
||||
>;
|
||||
|
||||
// Usage with exported types
|
||||
deleteUser.mutate(userId, {
|
||||
onSuccess: (data: DeleteUserMutationData) => {
|
||||
console.log(data.id);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Using orpcClient Directly in Hooks
|
||||
|
||||
Inside hooks, use `orpcClient` directly instead of wrapping with `useMutation`:
|
||||
|
||||
### DO: Direct orpcClient Usage
|
||||
|
||||
```typescript
|
||||
export function useOrderActions() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateOrder = useMutation({
|
||||
mutationFn: (data: UpdateOrderInput) => orpcClient.orders.update(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteOrder = useMutation({
|
||||
mutationFn: (id: string) => orpcClient.orders.delete({ id }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
updateOrder: updateOrder.mutate,
|
||||
deleteOrder: deleteOrder.mutate,
|
||||
isUpdating: updateOrder.isPending,
|
||||
isDeleting: deleteOrder.isPending,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### DON'T: Nested Hooks
|
||||
|
||||
```typescript
|
||||
// Bad: Don't create hooks that use other mutation hooks
|
||||
export function useOrderActions() {
|
||||
// Don't do this - creates unnecessary abstraction
|
||||
const updateMutation = useUpdateOrder();
|
||||
const deleteMutation = useDeleteOrder();
|
||||
|
||||
return {
|
||||
updateOrder: updateMutation.mutate,
|
||||
deleteOrder: deleteMutation.mutate,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Compound Hooks
|
||||
|
||||
Combine related queries and mutations into a single hook:
|
||||
|
||||
```typescript
|
||||
export function useProduct(productId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['products', productId],
|
||||
queryFn: () => orpcClient.products.get({ id: productId }),
|
||||
enabled: !!productId,
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: (data: UpdateProductInput) =>
|
||||
orpcClient.products.update({ id: productId, ...data }),
|
||||
onSuccess: (updatedProduct) => {
|
||||
queryClient.setQueryData(['products', productId], updatedProduct);
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => orpcClient.products.delete({ id: productId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
product: query.data,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
updateProduct: update.mutate,
|
||||
deleteProduct: remove.mutate,
|
||||
isUpdating: update.isPending,
|
||||
isDeleting: remove.isPending,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Infinite Query Pattern
|
||||
|
||||
```typescript
|
||||
export function useInfiniteOrders() {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ['orders', 'infinite'],
|
||||
queryFn: ({ pageParam = 1 }) =>
|
||||
orpcClient.orders.list({ page: pageParam, pageSize: 20 }),
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.hasMore ? lastPage.page + 1 : undefined,
|
||||
initialPageParam: 1,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Dependent Queries
|
||||
|
||||
```typescript
|
||||
export function useUserOrders(userId: string) {
|
||||
// First query: get user
|
||||
const userQuery = useQuery({
|
||||
queryKey: ['users', userId],
|
||||
queryFn: () => orpcClient.users.get({ id: userId }),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
// Second query: depends on user data
|
||||
const ordersQuery = useQuery({
|
||||
queryKey: ['orders', { userId }],
|
||||
queryFn: () => orpcClient.orders.list({ userId }),
|
||||
enabled: !!userQuery.data, // Only run when user is loaded
|
||||
});
|
||||
|
||||
return {
|
||||
user: userQuery.data,
|
||||
orders: ordersQuery.data,
|
||||
isLoading: userQuery.isLoading || ordersQuery.isLoading,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Single Responsibility**: Each hook should have one clear purpose
|
||||
2. **Consistent Naming**: `useXxx` for hooks, `useXxxQuery` for queries, `useXxxMutation` for mutations
|
||||
3. **Error Handling**: Always consider error states in your hooks
|
||||
4. **Loading States**: Expose loading states for UI feedback
|
||||
5. **Cache Keys**: Use consistent, hierarchical query keys
|
||||
6. **Type Safety**: Always maintain proper TypeScript types
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Forgetting to invalidate related queries after mutations
|
||||
- Not handling race conditions with `cancelQueries`
|
||||
- Missing `enabled` flag for conditional queries
|
||||
- Not providing explicit generics when overriding callbacks
|
||||
- Creating too many small hooks instead of compound hooks
|
||||
125
.trellis/spec/frontend/index.md
Normal file
125
.trellis/spec/frontend/index.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Next.js Frontend Development Guidelines
|
||||
|
||||
> Universal frontend development guidelines for Next.js full-stack applications with React + TypeScript + TailwindCSS.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: Next.js 15, React 19
|
||||
- **Language**: TypeScript (strict mode)
|
||||
- **Styling**: TailwindCSS 4, Radix UI
|
||||
- **API**: oRPC (OpenAPI RPC), React Query (TanStack Query)
|
||||
- **URL State**: nuqs
|
||||
- **Auth**: better-auth
|
||||
- **AI**: Vercel AI SDK (@ai-sdk/react)
|
||||
|
||||
---
|
||||
|
||||
## Documentation Files
|
||||
|
||||
| File | Description | Priority |
|
||||
| ---------------------------------------------------- | ---------------------------------------------------- | ------------- |
|
||||
| [components.md](./components.md) | Server/Client components, semantic HTML, next/image | **Must Read** |
|
||||
| [authentication.md](./authentication.md) | better-auth client, session, protected routes | **Must Read** |
|
||||
| [orpc-usage.md](./orpc-usage.md) | Type-safe API calls, React Query integration | **Must Read** |
|
||||
| [hooks.md](./hooks.md) | Query and mutation hook patterns | Reference |
|
||||
| [api-integration.md](./api-integration.md) | oRPC client, real-time, AI streaming | Reference |
|
||||
| [state-management.md](./state-management.md) | URL state with nuqs, React Context patterns | Reference |
|
||||
| [directory-structure.md](./directory-structure.md) | Project structure and module conventions | Reference |
|
||||
| [type-safety.md](./type-safety.md) | TypeScript guidelines, type inference, Zod | Reference |
|
||||
| [css-layout.md](./css-layout.md) | CSS patterns, flexbox, responsive, touch | Reference |
|
||||
| [ai-sdk-integration.md](./ai-sdk-integration.md) | useChat hook, streaming, tool call handling | Reference |
|
||||
| [quality.md](./quality.md) | Pre-commit checklist and code quality standards | Reference |
|
||||
|
||||
---
|
||||
|
||||
## Quick Navigation by Task
|
||||
|
||||
### Before Starting Development
|
||||
|
||||
| Task | Document |
|
||||
| --------------------------------- | -------------------------------------------------- |
|
||||
| Understand project structure | [directory-structure.md](./directory-structure.md) |
|
||||
| Learn Server vs Client components | [components.md](./components.md) |
|
||||
| Set up authentication | [authentication.md](./authentication.md) |
|
||||
|
||||
### During Development
|
||||
|
||||
| Task | Document |
|
||||
| --------------------------- | -------------------------------------------------- |
|
||||
| Make type-safe API calls | [orpc-usage.md](./orpc-usage.md) |
|
||||
| Create custom hooks | [hooks.md](./hooks.md) |
|
||||
| Manage application state | [state-management.md](./state-management.md) |
|
||||
| Build UI components | [components.md](./components.md) |
|
||||
| Ensure type safety | [type-safety.md](./type-safety.md) |
|
||||
| Integrate AI features | [ai-sdk-integration.md](./ai-sdk-integration.md) |
|
||||
| Handle CSS & layout | [css-layout.md](./css-layout.md) |
|
||||
|
||||
### Before Committing
|
||||
|
||||
| Task | Document |
|
||||
| ----------------------- | -------------------------------- |
|
||||
| Run quality checklist | [quality.md](./quality.md) |
|
||||
| Verify CSS in both envs | [css-layout.md](./css-layout.md) |
|
||||
| Check type safety | [type-safety.md](./type-safety.md) |
|
||||
|
||||
---
|
||||
|
||||
## Core Rules Summary
|
||||
|
||||
| Rule | Reference |
|
||||
| ------------------------------------------------------------ | -------------------------------------------------- |
|
||||
| **Default to Server Components** | [components.md](./components.md) |
|
||||
| **Use `<button>` for clickable actions, not `<div>`** | [components.md](./components.md) |
|
||||
| **Always use `next/image` instead of `<img>`** | [components.md](./components.md) |
|
||||
| **Import types from backend, never redefine them** | [type-safety.md](./type-safety.md) |
|
||||
| **No `any` types or `@ts-expect-error` in new code** | [type-safety.md](./type-safety.md) |
|
||||
| **Use oRPC client for API calls (not raw fetch)** | [orpc-usage.md](./orpc-usage.md) |
|
||||
| **Use oRPC generated query keys (not manual strings)** | [orpc-usage.md](./orpc-usage.md) |
|
||||
| **Store shareable state in URL with nuqs** | [state-management.md](./state-management.md) |
|
||||
| **Use `items-stretch` on main flex containers** | [css-layout.md](./css-layout.md) |
|
||||
| **Handle both tool call formats (streaming + history)** | [ai-sdk-integration.md](./ai-sdk-integration.md) |
|
||||
| **Always check session loading state before rendering** | [authentication.md](./authentication.md) |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
+--------------------------------------------------------------+
|
||||
| Next.js Application |
|
||||
| |
|
||||
| app/ modules/ |
|
||||
| ├── (marketing)/ ├── [feature]/ |
|
||||
| │ └── [locale]/ │ ├── components/ |
|
||||
| └── (app)/ │ ├── hooks/ |
|
||||
| └── [routes]/ │ ├── context/ |
|
||||
| │ └── lib/ |
|
||||
| ├── shared/ |
|
||||
| └── ui/ |
|
||||
+-------------------------------+------------------------------+
|
||||
|
|
||||
oRPC (type-safe RPC) | React Query (cache)
|
||||
|
|
||||
+-------------------------------+------------------------------+
|
||||
| API Layer (Server) |
|
||||
| +--------------+ +----------------+ +------------------+ |
|
||||
| | oRPC | | better-auth | | AI SDK | |
|
||||
| | Router | | Sessions | | Streaming | |
|
||||
| +--------------+ +----------------+ +------------------+ |
|
||||
+--------------------------------------------------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Read the Must-Read documents** - Components, authentication, and oRPC usage
|
||||
2. **Set up your project structure** - Follow [directory-structure.md](./directory-structure.md)
|
||||
3. **Configure TypeScript paths** - See [type-safety.md](./type-safety.md)
|
||||
4. **Set up API client** - Use patterns from [orpc-usage.md](./orpc-usage.md)
|
||||
5. **Build components** - Follow [components.md](./components.md) and [hooks.md](./hooks.md)
|
||||
6. **Before committing** - Complete the [quality.md](./quality.md) checklist
|
||||
|
||||
---
|
||||
|
||||
**Language**: All documentation is written in **English**.
|
||||
662
.trellis/spec/frontend/orpc-usage.md
Normal file
662
.trellis/spec/frontend/orpc-usage.md
Normal file
@@ -0,0 +1,662 @@
|
||||
# oRPC Frontend Usage Guidelines
|
||||
|
||||
This document provides comprehensive guidelines for using oRPC in frontend applications, covering client setup, React Query integration, and best practices.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
oRPC (OpenAPI RPC) provides type-safe RPC-style API calls with automatic TypeScript type inference. When combined with React Query (TanStack Query), it offers a powerful solution for data fetching, caching, and state synchronization.
|
||||
|
||||
**Key Benefits:**
|
||||
- End-to-end type safety from backend to frontend
|
||||
- Automatic query key generation
|
||||
- Seamless React Query integration
|
||||
- Built-in error handling
|
||||
|
||||
## 2. Client Setup
|
||||
|
||||
### Basic Client Configuration
|
||||
|
||||
```typescript
|
||||
// lib/orpc-client.ts
|
||||
import { createORPCClient, onError } from "@orpc/client";
|
||||
import { RPCLink } from "@orpc/client/fetch";
|
||||
import type { ApiRouterClient } from "@your-app/api/orpc/router"; // Replace with your monorepo package path
|
||||
|
||||
const link = new RPCLink({
|
||||
url: "/api/rpc",
|
||||
headers: async () => {
|
||||
// Client-side: return empty headers (cookies handled automatically)
|
||||
if (typeof window !== "undefined") {
|
||||
return {};
|
||||
}
|
||||
// Server-side: forward request headers for SSR
|
||||
const { headers } = await import("next/headers");
|
||||
return Object.fromEntries(await headers());
|
||||
},
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
// Ignore abort errors (e.g., from React strict mode)
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
console.error(error);
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export const orpcClient: ApiRouterClient = createORPCClient(link);
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- The `ApiRouterClient` type ensures full type safety
|
||||
- Headers handling differs between client and server environments
|
||||
- Error interceptors provide centralized error logging
|
||||
|
||||
## 3. React Query Integration
|
||||
|
||||
### Creating Query Utilities
|
||||
|
||||
```typescript
|
||||
// lib/orpc-query-utils.ts
|
||||
import { createTanstackQueryUtils } from "@orpc/tanstack-query";
|
||||
import { orpcClient } from "./orpc-client";
|
||||
|
||||
export const orpc = createTanstackQueryUtils(orpcClient);
|
||||
```
|
||||
|
||||
The `orpc` object provides utilities for generating query options and keys that integrate seamlessly with React Query.
|
||||
|
||||
## 4. Query Patterns
|
||||
|
||||
### 4.1 Basic Query with useQuery
|
||||
|
||||
```typescript
|
||||
import { orpc } from "@/lib/orpc-query-utils";
|
||||
import { orpcClient } from "@/lib/orpc-client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
// Derive types from the client
|
||||
type ItemResult = Awaited<ReturnType<(typeof orpcClient)["items"]["get"]>>;
|
||||
|
||||
export function useItem(itemId: string | null) {
|
||||
const hasItemId = typeof itemId === "string" && itemId.trim().length > 0;
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery<ItemResult | undefined>({
|
||||
...orpc.items.get.queryOptions({
|
||||
input: { itemId: itemId ?? "" },
|
||||
}),
|
||||
enabled: hasItemId,
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // Keep in garbage collection for 10 minutes
|
||||
});
|
||||
|
||||
return {
|
||||
item: data?.item ?? null,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Infinite Query with Cursor Pagination
|
||||
|
||||
```typescript
|
||||
import { orpc } from "@/lib/orpc-query-utils";
|
||||
import { orpcClient } from "@/lib/orpc-client";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import type { InfiniteData } from "@tanstack/react-query";
|
||||
|
||||
type ListResult = Awaited<ReturnType<(typeof orpcClient)["items"]["list"]>>;
|
||||
type ListCursor = { lastUpdatedAt: string; id: string } | undefined;
|
||||
type ListQueryKey = ReturnType<typeof orpc.items.list.queryKey>;
|
||||
|
||||
interface UseItemListOptions {
|
||||
categoryId: string | null;
|
||||
filters?: {
|
||||
isActive?: boolean;
|
||||
search?: string;
|
||||
};
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useItemList(options: UseItemListOptions) {
|
||||
const { categoryId, filters, enabled = true } = options;
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useInfiniteQuery<
|
||||
ListResult,
|
||||
Error,
|
||||
InfiniteData<ListResult, ListCursor>,
|
||||
ListQueryKey,
|
||||
ListCursor
|
||||
>({
|
||||
queryKey: orpc.items.list.queryKey({
|
||||
input: {
|
||||
categoryId: categoryId ?? "",
|
||||
filters,
|
||||
},
|
||||
}),
|
||||
queryFn: async ({ pageParam }): Promise<ListResult> => {
|
||||
if (!categoryId) {
|
||||
throw new Error("Category ID is required");
|
||||
}
|
||||
return await orpcClient.items.list({
|
||||
categoryId,
|
||||
limit: 20,
|
||||
cursor: pageParam,
|
||||
filters,
|
||||
});
|
||||
},
|
||||
initialPageParam: undefined,
|
||||
getNextPageParam: (lastPage): ListCursor =>
|
||||
lastPage.nextCursor ?? undefined,
|
||||
enabled: enabled && !!categoryId,
|
||||
});
|
||||
|
||||
// Flatten all pages into a single array
|
||||
const items = data?.pages.flatMap((page) => page.items) ?? [];
|
||||
|
||||
return {
|
||||
items,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Batch Queries with useQueries
|
||||
|
||||
```typescript
|
||||
import { orpc } from "@/lib/orpc-query-utils";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface UseBatchItemsOptions {
|
||||
itemIds: string[];
|
||||
enabled?: boolean;
|
||||
staleTime?: number;
|
||||
}
|
||||
|
||||
export function useBatchItems(options: UseBatchItemsOptions) {
|
||||
const { itemIds, enabled = true, staleTime = 5 * 60 * 1000 } = options;
|
||||
|
||||
const queries = useQueries({
|
||||
queries: itemIds.map((itemId) => ({
|
||||
...orpc.items.get.queryOptions({
|
||||
input: { itemId },
|
||||
}),
|
||||
enabled: enabled && !!itemId,
|
||||
staleTime,
|
||||
})),
|
||||
});
|
||||
|
||||
// Build a map for easy lookup
|
||||
const itemsMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
queries.forEach((query, index) => {
|
||||
const itemId = itemIds[index];
|
||||
if (itemId && query.data) {
|
||||
map.set(itemId, {
|
||||
data: query.data,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
});
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [queries, itemIds]);
|
||||
|
||||
return {
|
||||
itemsMap,
|
||||
isLoading: queries.some((q) => q.isLoading),
|
||||
isAllLoaded: queries.every((q) => q.isSuccess || q.isError),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 Query Key Management
|
||||
|
||||
oRPC provides automatic query key generation:
|
||||
|
||||
```typescript
|
||||
// Get query key with input parameters
|
||||
const queryKey = orpc.items.list.queryKey({
|
||||
input: { categoryId: "123", filters: { isActive: true } },
|
||||
});
|
||||
|
||||
// Get base key (without input) for broader invalidation
|
||||
const baseKey = orpc.items.list.key();
|
||||
|
||||
// Usage in cache invalidation
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.items.list.key(), // Invalidates all items.list queries
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.items.list.queryKey({
|
||||
input: { categoryId: "123" },
|
||||
}), // Invalidates specific query
|
||||
});
|
||||
```
|
||||
|
||||
## 5. Mutation Patterns
|
||||
|
||||
### 5.1 Basic Mutation
|
||||
|
||||
```typescript
|
||||
import { orpc } from "@/lib/orpc-query-utils";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface UseConnectServiceOptions {
|
||||
onSuccess?: (data: ConnectOutput) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export function useConnectService(options: UseConnectServiceOptions = {}) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<ConnectOutput, Error, ConnectInput>({
|
||||
...orpc.services.connect.mutationOptions(),
|
||||
onSuccess: (data) => {
|
||||
// Invalidate related queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.services.default.key(),
|
||||
});
|
||||
options.onSuccess?.(data);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Connection failed", {
|
||||
description: error.message,
|
||||
});
|
||||
options.onError?.(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Mutation with Optimistic Updates
|
||||
|
||||
```typescript
|
||||
import { orpc } from "@/lib/orpc-query-utils";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function useUpdateItem() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
{ success: boolean },
|
||||
Error,
|
||||
{ itemId: string; isActive: boolean },
|
||||
{ previousQueries: [readonly unknown[], unknown][] }
|
||||
>({
|
||||
...orpc.items.update.mutationOptions(),
|
||||
onMutate: async ({ itemId, isActive }) => {
|
||||
// Cancel outgoing refetches to avoid overwriting optimistic update
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: orpc.items.list.key(),
|
||||
});
|
||||
|
||||
// Snapshot current data for rollback
|
||||
const previousQueries = queryClient.getQueriesData({
|
||||
queryKey: orpc.items.list.key(),
|
||||
});
|
||||
|
||||
// Optimistically update the cache
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: orpc.items.list.key() },
|
||||
(old: unknown) => {
|
||||
const data = old as {
|
||||
pages?: Array<{
|
||||
items: Array<{ id: string; isActive: boolean }>;
|
||||
}>;
|
||||
};
|
||||
if (!data?.pages) return old;
|
||||
|
||||
return {
|
||||
...data,
|
||||
pages: data.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((item) =>
|
||||
item.id === itemId ? { ...item, isActive } : item
|
||||
),
|
||||
})),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return { previousQueries };
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousQueries) {
|
||||
for (const [queryKey, data] of context.previousQueries) {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
}
|
||||
}
|
||||
toast.error("Failed to update item");
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Optionally invalidate related queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.items.counts.key(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Optimistic Delete (Remove from List)
|
||||
|
||||
```typescript
|
||||
export function useDeleteItem() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
{ success: boolean },
|
||||
Error,
|
||||
{ itemId: string },
|
||||
{ previousQueries: [readonly unknown[], unknown][] }
|
||||
>({
|
||||
...orpc.items.delete.mutationOptions(),
|
||||
onMutate: async ({ itemId }) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: orpc.items.list.key(),
|
||||
});
|
||||
|
||||
const previousQueries = queryClient.getQueriesData({
|
||||
queryKey: orpc.items.list.key(),
|
||||
});
|
||||
|
||||
// Optimistically remove from all lists
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: orpc.items.list.key() },
|
||||
(old: unknown) => {
|
||||
const data = old as {
|
||||
pages?: Array<{
|
||||
items: Array<{ id: string }>;
|
||||
nextCursor: unknown;
|
||||
hasMore: boolean;
|
||||
}>;
|
||||
};
|
||||
if (!data?.pages) return old;
|
||||
|
||||
return {
|
||||
...data,
|
||||
pages: data.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((item) => item.id !== itemId),
|
||||
})),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return { previousQueries };
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
if (context?.previousQueries) {
|
||||
for (const [queryKey, data] of context.previousQueries) {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
}
|
||||
}
|
||||
toast.error("Failed to delete item");
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Item deleted");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.items.counts.key(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Direct Client Calls
|
||||
|
||||
### 6.1 When to Use Direct Client vs useMutation
|
||||
|
||||
**Use `useMutation` when:**
|
||||
- You need loading/error states in UI
|
||||
- You want automatic retry behavior
|
||||
- You need optimistic updates
|
||||
- You want built-in cache invalidation hooks
|
||||
|
||||
**Use direct `orpcClient` calls when:**
|
||||
- Inside `mutationFn` for custom logic (see 6.2)
|
||||
- In event handlers where you need sequential operations
|
||||
- When you need to transform input before calling API
|
||||
- In server components or API routes
|
||||
|
||||
### 6.2 Custom Mutation Function
|
||||
|
||||
When you need to add custom logic, transform inputs, or handle complex scenarios:
|
||||
|
||||
```typescript
|
||||
import { orpcClient } from "@/lib/orpc-client";
|
||||
import { orpc } from "@/lib/orpc-query-utils";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export function useCreateItem(options = {}) {
|
||||
const { user } = useSession();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
CreateItemOutput,
|
||||
Error,
|
||||
Omit<CreateItemInput, "userId"> // userId will be added automatically
|
||||
>({
|
||||
mutationKey: orpc.items.create.mutationKey(),
|
||||
mutationFn: async (input) => {
|
||||
// Add authentication
|
||||
if (!user?.id) {
|
||||
throw new Error("User not authenticated");
|
||||
}
|
||||
|
||||
// Transform input before calling API
|
||||
const fullInput: CreateItemInput = {
|
||||
...input,
|
||||
userId: user.id,
|
||||
};
|
||||
|
||||
// Direct client call with transformed input
|
||||
return orpcClient.items.create(fullInput);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data.success) {
|
||||
toast.success("Item created successfully");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.items.list.key(),
|
||||
});
|
||||
options.onSuccess?.(data);
|
||||
} else {
|
||||
// Handle API-level errors
|
||||
const errorMessage = data.error || "Failed to create item";
|
||||
toast.error("Failed to create item", {
|
||||
description: errorMessage,
|
||||
});
|
||||
options.onError?.(new Error(errorMessage));
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to create item", {
|
||||
description: error.message,
|
||||
});
|
||||
options.onError?.(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 Type Inference
|
||||
|
||||
Derive types directly from the client for maximum type safety:
|
||||
|
||||
```typescript
|
||||
import type { orpcClient } from "@/lib/orpc-client";
|
||||
|
||||
// Infer return type from client method
|
||||
type ItemResult = Awaited<ReturnType<(typeof orpcClient)["items"]["get"]>>;
|
||||
|
||||
// Infer input type from client method
|
||||
type CreateItemInput = Parameters<(typeof orpcClient)["items"]["create"]>[0];
|
||||
```
|
||||
|
||||
## 7. Best Practices
|
||||
|
||||
### 7.1 Query Key Consistency
|
||||
|
||||
Always use oRPC's generated query keys for consistency:
|
||||
|
||||
```typescript
|
||||
// GOOD: Use generated query keys
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.items.list.key(),
|
||||
});
|
||||
|
||||
// GOOD: Use specific query key with input
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.items.list.queryKey({ input: { categoryId: "123" } }),
|
||||
});
|
||||
|
||||
// BAD: Manually constructed keys
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["items", "list"], // Don't do this
|
||||
});
|
||||
```
|
||||
|
||||
### 7.2 Error Handling
|
||||
|
||||
Implement consistent error handling with toast notifications:
|
||||
|
||||
```typescript
|
||||
import { toast } from "sonner";
|
||||
|
||||
// In mutation hooks
|
||||
onError: (error) => {
|
||||
toast.error("Operation failed", {
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
|
||||
// Handle API-level errors in onSuccess
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
toast.error("Operation failed", {
|
||||
description: data.error || "Unknown error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Handle success...
|
||||
},
|
||||
```
|
||||
|
||||
### 7.3 Loading States
|
||||
|
||||
Use appropriate loading state properties:
|
||||
|
||||
```typescript
|
||||
const { isLoading, isFetching, isPending } = useQuery(...);
|
||||
const { isPending, isSuccess, isError } = useMutation(...);
|
||||
const { isFetchingNextPage, hasNextPage } = useInfiniteQuery(...);
|
||||
|
||||
// In components
|
||||
{isLoading && <Skeleton />}
|
||||
{isPending && <Button disabled>Saving...</Button>}
|
||||
{isFetchingNextPage && <LoadingSpinner />}
|
||||
```
|
||||
|
||||
### 7.4 Cache Configuration
|
||||
|
||||
Set appropriate cache times based on data characteristics:
|
||||
|
||||
```typescript
|
||||
// Frequently changing data
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
gcTime: 60 * 1000, // 1 minute
|
||||
|
||||
// Moderately stable data
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
|
||||
// Stable/static data
|
||||
staleTime: 30 * 60 * 1000, // 30 minutes
|
||||
gcTime: 60 * 60 * 1000, // 1 hour
|
||||
```
|
||||
|
||||
### 7.5 Input Validation in Hooks
|
||||
|
||||
Always validate inputs before making API calls:
|
||||
|
||||
```typescript
|
||||
export function useItem(itemId: string | null) {
|
||||
const hasItemId = typeof itemId === "string" && itemId.trim().length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasItemId) {
|
||||
console.warn("[useItem] Invalid itemId provided. Request skipped.");
|
||||
}
|
||||
}, [hasItemId]);
|
||||
|
||||
return useQuery({
|
||||
...orpc.items.get.queryOptions({
|
||||
input: { itemId: itemId ?? "" },
|
||||
}),
|
||||
enabled: hasItemId, // Prevent invalid requests
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 7.6 Partial Success Handling
|
||||
|
||||
Handle batch operations that may partially succeed:
|
||||
|
||||
```typescript
|
||||
onSuccess: (result) => {
|
||||
if (result.failed === 0) {
|
||||
toast.success(`${result.processed} items updated`);
|
||||
} else if (result.processed > 0) {
|
||||
toast.warning(
|
||||
`${result.processed} of ${result.total} items updated, ${result.failed} failed`
|
||||
);
|
||||
// Refresh to get correct state for failed items
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.items.list.key(),
|
||||
});
|
||||
} else {
|
||||
toast.error("Failed to update items");
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
## 8. Common Patterns Summary
|
||||
|
||||
| Pattern | Hook | Use Case |
|
||||
|---------|------|----------|
|
||||
| Single item fetch | `useQuery` | Detail pages, single record |
|
||||
| List with pagination | `useInfiniteQuery` | Lists, feeds, search results |
|
||||
| Multiple items | `useQueries` | Batch preloading, related items |
|
||||
| Create/Update/Delete | `useMutation` | Form submissions, actions |
|
||||
| Optimistic updates | `useMutation` + `onMutate` | Real-time UI updates |
|
||||
| Custom mutation logic | `useMutation` + `mutationFn` | Auth injection, input transformation |
|
||||
|
||||
## 9. Migration Notes
|
||||
|
||||
When migrating from other data fetching approaches:
|
||||
|
||||
1. Replace manual fetch calls with `orpcClient` methods
|
||||
2. Replace manual query keys with `orpc.xxx.queryKey()`
|
||||
3. Use `orpc.xxx.queryOptions()` and `mutationOptions()` for React Query integration
|
||||
4. Leverage TypeScript inference from the client types
|
||||
137
.trellis/spec/frontend/quality.md
Normal file
137
.trellis/spec/frontend/quality.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Pre-commit Checklist
|
||||
|
||||
Complete this checklist before committing frontend code changes.
|
||||
|
||||
## Type Safety
|
||||
|
||||
- [ ] No `@ts-expect-error` or `@ts-ignore` comments added
|
||||
- [ ] No `any` types in new code
|
||||
- [ ] API response types are inferred or imported from backend (not redefined)
|
||||
- [ ] Cache updates in React Query are properly typed
|
||||
- [ ] When overriding mutation callbacks, explicit generics are provided
|
||||
|
||||
## Component Development
|
||||
|
||||
- [ ] Server Components used by default; `'use client'` only when necessary
|
||||
- [ ] Semantic HTML elements used (button, not div for clicks)
|
||||
- [ ] `next/image` used instead of `<img>` tags
|
||||
- [ ] Proper ARIA labels and accessibility attributes added
|
||||
- [ ] Props have TypeScript interfaces defined
|
||||
|
||||
## API Integration
|
||||
|
||||
- [ ] API calls use oRPC client (not raw fetch for internal APIs)
|
||||
- [ ] React Query hooks follow established patterns
|
||||
- [ ] Loading and error states handled
|
||||
- [ ] Optimistic updates include rollback logic
|
||||
- [ ] Real-time subscriptions cleaned up on unmount
|
||||
|
||||
## State Management
|
||||
|
||||
- [ ] Shareable state stored in URL with nuqs
|
||||
- [ ] Context used sparingly (not for server data)
|
||||
- [ ] URL and Context synchronized where necessary
|
||||
- [ ] No duplicate state across different systems
|
||||
|
||||
## CSS & Layout
|
||||
|
||||
- [ ] `items-stretch` used on main flex containers (not `items-center`)
|
||||
- [ ] Parent provides external styles; child provides internal layout
|
||||
- [ ] Mobile touch: `WebkitTapHighlightColor: "transparent"` applied
|
||||
- [ ] Touch targets are minimum 44x44px
|
||||
- [ ] Responsive breakpoints tested
|
||||
|
||||
## Cross-Environment Testing
|
||||
|
||||
- [ ] Tested in development mode (`pnpm dev`)
|
||||
- [ ] Tested in production mode (`pnpm build && pnpm start`)
|
||||
- [ ] No visual differences between dev and prod
|
||||
- [ ] Animations respect `prefers-reduced-motion`
|
||||
|
||||
## Code Quality
|
||||
|
||||
- [ ] No console.log statements left in code
|
||||
- [ ] Unused imports removed
|
||||
- [ ] Components follow single responsibility principle
|
||||
- [ ] File and function names follow conventions
|
||||
- [ ] Barrel exports updated if new files added
|
||||
|
||||
## Documentation
|
||||
|
||||
- [ ] Complex logic has inline comments
|
||||
- [ ] New hooks have JSDoc comments
|
||||
- [ ] API changes reflected in backend documentation
|
||||
|
||||
---
|
||||
|
||||
## Quick Commands
|
||||
|
||||
```bash
|
||||
# Type check
|
||||
pnpm type-check
|
||||
|
||||
# Lint
|
||||
pnpm lint
|
||||
|
||||
# Format
|
||||
pnpm format
|
||||
|
||||
# Build (catches production-only issues)
|
||||
pnpm build
|
||||
|
||||
# Run all checks
|
||||
pnpm lint && pnpm type-check && pnpm build
|
||||
```
|
||||
|
||||
## Common Issues to Watch
|
||||
|
||||
### Type Safety
|
||||
```typescript
|
||||
// Bad
|
||||
queryClient.setQueryData(['users'], (old: any) => ...)
|
||||
|
||||
// Good
|
||||
queryClient.setQueryData<UserListData>(['users'], (old) => ...)
|
||||
```
|
||||
|
||||
### Components
|
||||
```typescript
|
||||
// Bad
|
||||
<div onClick={handleClick}>Click me</div>
|
||||
|
||||
// Good
|
||||
<button onClick={handleClick}>Click me</button>
|
||||
```
|
||||
|
||||
### Images
|
||||
```typescript
|
||||
// Bad
|
||||
<img src="/hero.jpg" alt="Hero" />
|
||||
|
||||
// Good
|
||||
import Image from 'next/image';
|
||||
<Image src="/hero.jpg" alt="Hero" width={1200} height={600} />
|
||||
```
|
||||
|
||||
### Layout
|
||||
```typescript
|
||||
// Bad - children won't fill height
|
||||
<div className="flex h-screen items-center">
|
||||
|
||||
// Good - children fill available height
|
||||
<div className="flex h-screen">
|
||||
```
|
||||
|
||||
### Mobile Touch
|
||||
```typescript
|
||||
// Bad - shows tap highlight on mobile
|
||||
<button onClick={handleClick}>Tap</button>
|
||||
|
||||
// Good - no tap highlight
|
||||
<button
|
||||
onClick={handleClick}
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
Tap
|
||||
</button>
|
||||
```
|
||||
372
.trellis/spec/frontend/state-management.md
Normal file
372
.trellis/spec/frontend/state-management.md
Normal file
@@ -0,0 +1,372 @@
|
||||
# State Management
|
||||
|
||||
This document covers state management patterns including URL state with nuqs, React Context guidelines, and synchronization strategies.
|
||||
|
||||
## State Categories
|
||||
|
||||
| Category | Tool | When to Use |
|
||||
|----------|------|-------------|
|
||||
| Server State | React Query | API data, cached responses |
|
||||
| URL State | nuqs | Filters, pagination, selected items |
|
||||
| Local UI State | useState | Transient UI (modals, dropdowns) |
|
||||
| Shared UI State | Context | Cross-component UI state |
|
||||
|
||||
## URL State with nuqs
|
||||
|
||||
### Why URL State?
|
||||
|
||||
- Shareable: Users can share links with specific state
|
||||
- Bookmarkable: Browser history navigation works
|
||||
- SEO-friendly: Search engines can index different states
|
||||
- Persistent: Survives page refreshes
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { useQueryState } from 'nuqs';
|
||||
|
||||
export function useOrderFilters() {
|
||||
const [status, setStatus] = useQueryState('status');
|
||||
const [page, setPage] = useQueryState('page', {
|
||||
parse: (value) => parseInt(value, 10) || 1,
|
||||
serialize: String,
|
||||
});
|
||||
|
||||
return { status, setStatus, page, setPage };
|
||||
}
|
||||
```
|
||||
|
||||
### With Default Values
|
||||
|
||||
```typescript
|
||||
import { useQueryState, parseAsInteger, parseAsString } from 'nuqs';
|
||||
|
||||
export function useProductFilters() {
|
||||
const [category, setCategory] = useQueryState('category', {
|
||||
defaultValue: 'all',
|
||||
parse: parseAsString,
|
||||
});
|
||||
|
||||
const [page, setPage] = useQueryState('page', {
|
||||
defaultValue: 1,
|
||||
parse: parseAsInteger,
|
||||
});
|
||||
|
||||
const [sortBy, setSortBy] = useQueryState('sort', {
|
||||
defaultValue: 'newest',
|
||||
});
|
||||
|
||||
return {
|
||||
category,
|
||||
setCategory,
|
||||
page,
|
||||
setPage,
|
||||
sortBy,
|
||||
setSortBy,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Complex Filter Objects
|
||||
|
||||
```typescript
|
||||
import { useQueryStates, parseAsString, parseAsInteger } from 'nuqs';
|
||||
|
||||
const filterParsers = {
|
||||
search: parseAsString.withDefault(''),
|
||||
category: parseAsString.withDefault('all'),
|
||||
minPrice: parseAsInteger,
|
||||
maxPrice: parseAsInteger,
|
||||
page: parseAsInteger.withDefault(1),
|
||||
};
|
||||
|
||||
export function useAdvancedFilters() {
|
||||
const [filters, setFilters] = useQueryStates(filterParsers);
|
||||
|
||||
const updateFilter = <K extends keyof typeof filters>(
|
||||
key: K,
|
||||
value: (typeof filters)[K]
|
||||
) => {
|
||||
setFilters({ [key]: value, page: 1 }); // Reset page on filter change
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setFilters({
|
||||
search: '',
|
||||
category: 'all',
|
||||
minPrice: null,
|
||||
maxPrice: null,
|
||||
page: 1,
|
||||
});
|
||||
};
|
||||
|
||||
return { filters, updateFilter, resetFilters };
|
||||
}
|
||||
```
|
||||
|
||||
### Shallow Routing
|
||||
|
||||
Prevent full page reloads when updating URL state:
|
||||
|
||||
```typescript
|
||||
const [tab, setTab] = useQueryState('tab', {
|
||||
shallow: true, // Default is true in nuqs
|
||||
history: 'push', // or 'replace'
|
||||
});
|
||||
```
|
||||
|
||||
## React Context Guidelines
|
||||
|
||||
### When to Use Context
|
||||
|
||||
- Theme/appearance settings
|
||||
- User preferences
|
||||
- Feature flags
|
||||
- Cross-cutting concerns (toast notifications, modals)
|
||||
|
||||
### When NOT to Use Context
|
||||
|
||||
- Server data (use React Query instead)
|
||||
- Form state (use form libraries)
|
||||
- Single-component state (use useState)
|
||||
- State that should be in URL
|
||||
|
||||
### Context Pattern
|
||||
|
||||
```typescript
|
||||
// context/DashboardContext.tsx
|
||||
import { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
interface DashboardState {
|
||||
sidebarCollapsed: boolean;
|
||||
activeWidget: string | null;
|
||||
}
|
||||
|
||||
interface DashboardContextValue extends DashboardState {
|
||||
toggleSidebar: () => void;
|
||||
setActiveWidget: (widget: string | null) => void;
|
||||
}
|
||||
|
||||
const DashboardContext = createContext<DashboardContextValue | null>(null);
|
||||
|
||||
export function DashboardProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<DashboardState>({
|
||||
sidebarCollapsed: false,
|
||||
activeWidget: null,
|
||||
});
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
sidebarCollapsed: !prev.sidebarCollapsed,
|
||||
}));
|
||||
};
|
||||
|
||||
const setActiveWidget = (widget: string | null) => {
|
||||
setState((prev) => ({ ...prev, activeWidget: widget }));
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardContext.Provider
|
||||
value={{ ...state, toggleSidebar, setActiveWidget }}
|
||||
>
|
||||
{children}
|
||||
</DashboardContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDashboard() {
|
||||
const context = useContext(DashboardContext);
|
||||
if (!context) {
|
||||
throw new Error('useDashboard must be used within DashboardProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
```
|
||||
|
||||
### Split Context for Performance
|
||||
|
||||
Separate frequently-changing values to prevent unnecessary re-renders:
|
||||
|
||||
```typescript
|
||||
// Separate contexts for state and actions
|
||||
const DashboardStateContext = createContext<DashboardState | null>(null);
|
||||
const DashboardActionsContext = createContext<DashboardActions | null>(null);
|
||||
|
||||
export function DashboardProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<DashboardState>(initialState);
|
||||
|
||||
// Memoize actions to prevent re-renders
|
||||
const actions = useMemo(
|
||||
() => ({
|
||||
toggleSidebar: () =>
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
sidebarCollapsed: !prev.sidebarCollapsed,
|
||||
})),
|
||||
setActiveWidget: (widget: string | null) =>
|
||||
setState((prev) => ({ ...prev, activeWidget: widget })),
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardStateContext.Provider value={state}>
|
||||
<DashboardActionsContext.Provider value={actions}>
|
||||
{children}
|
||||
</DashboardActionsContext.Provider>
|
||||
</DashboardStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// Separate hooks for state and actions
|
||||
export function useDashboardState() {
|
||||
const context = useContext(DashboardStateContext);
|
||||
if (!context) throw new Error('Missing DashboardProvider');
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useDashboardActions() {
|
||||
const context = useContext(DashboardActionsContext);
|
||||
if (!context) throw new Error('Missing DashboardProvider');
|
||||
return context;
|
||||
}
|
||||
```
|
||||
|
||||
## Context and URL Synchronization
|
||||
|
||||
When state needs to be both in context (for easy access) and URL (for shareability):
|
||||
|
||||
### Pattern: URL as Source of Truth
|
||||
|
||||
```typescript
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { createContext, useContext, ReactNode } from 'react';
|
||||
|
||||
interface FilterContextValue {
|
||||
selectedId: string | null;
|
||||
setSelectedId: (id: string | null) => void;
|
||||
view: 'grid' | 'list';
|
||||
setView: (view: 'grid' | 'list') => void;
|
||||
}
|
||||
|
||||
const FilterContext = createContext<FilterContextValue | null>(null);
|
||||
|
||||
export function FilterProvider({ children }: { children: ReactNode }) {
|
||||
// URL state as the single source of truth
|
||||
const [selectedId, setSelectedId] = useQueryState('selected');
|
||||
const [view, setView] = useQueryState('view', {
|
||||
defaultValue: 'grid' as const,
|
||||
parse: (v) => (v === 'list' ? 'list' : 'grid'),
|
||||
});
|
||||
|
||||
return (
|
||||
<FilterContext.Provider
|
||||
value={{
|
||||
selectedId,
|
||||
setSelectedId,
|
||||
view,
|
||||
setView,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</FilterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useFilters() {
|
||||
const context = useContext(FilterContext);
|
||||
if (!context) throw new Error('Missing FilterProvider');
|
||||
return context;
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Sync Context to URL
|
||||
|
||||
When context state needs to be reflected in URL for specific scenarios:
|
||||
|
||||
```typescript
|
||||
export function useSyncToUrl() {
|
||||
const { selectedId } = useItemSelection(); // From context
|
||||
const [, setUrlSelectedId] = useQueryState('selected');
|
||||
|
||||
// Sync context changes to URL
|
||||
useEffect(() => {
|
||||
setUrlSelectedId(selectedId);
|
||||
}, [selectedId, setUrlSelectedId]);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Initialize Context from URL
|
||||
|
||||
```typescript
|
||||
export function SelectionProvider({ children }: { children: ReactNode }) {
|
||||
// Read initial value from URL
|
||||
const [urlSelectedId] = useQueryState('selected');
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(
|
||||
urlSelectedId // Initialize from URL
|
||||
);
|
||||
|
||||
// Keep context in sync with URL changes
|
||||
useEffect(() => {
|
||||
setSelectedId(urlSelectedId);
|
||||
}, [urlSelectedId]);
|
||||
|
||||
return (
|
||||
<SelectionContext.Provider value={{ selectedId, setSelectedId }}>
|
||||
{children}
|
||||
</SelectionContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## State Debugging
|
||||
|
||||
### React Query DevTools
|
||||
|
||||
```typescript
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<AppContent />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Context Debug Component
|
||||
|
||||
```typescript
|
||||
function DebugContext() {
|
||||
const state = useDashboardState();
|
||||
|
||||
if (process.env.NODE_ENV !== 'development') return null;
|
||||
|
||||
return (
|
||||
<pre className="fixed bottom-4 right-4 p-2 bg-black/80 text-white text-xs">
|
||||
{JSON.stringify(state, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **URL First**: Default to URL state for shareable data
|
||||
2. **Minimal Context**: Keep context small and focused
|
||||
3. **Separate Concerns**: Don't mix server state with UI state
|
||||
4. **Type Everything**: Use TypeScript for all state types
|
||||
5. **Default Values**: Always provide sensible defaults
|
||||
6. **Single Source**: Avoid duplicating state across systems
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Storing server data in context (use React Query)
|
||||
- Using context for form state (use form libraries)
|
||||
- Deep nesting of providers
|
||||
- Not memoizing context actions
|
||||
- Duplicating URL state in useState
|
||||
278
.trellis/spec/frontend/type-safety.md
Normal file
278
.trellis/spec/frontend/type-safety.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# Type Safety Guidelines
|
||||
|
||||
This document covers TypeScript best practices for maintaining type safety across the frontend application.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Import types from backend, never redefine them**
|
||||
2. **Use type inference wherever possible**
|
||||
3. **Avoid type assertions and escape hatches**
|
||||
4. **Leverage oRPC for end-to-end type safety**
|
||||
|
||||
## Importing Backend Types
|
||||
|
||||
### DO: Import from API Package
|
||||
|
||||
```typescript
|
||||
// Good: Import types from the API package
|
||||
import type { User, Order, Product } from '@your-app/api/modules/users/types'; // Replace with your monorepo package path
|
||||
import type { OrderStatus } from '@your-app/api/modules/orders/types'; // Replace with your monorepo package path
|
||||
```
|
||||
|
||||
### DON'T: Redefine Backend Types
|
||||
|
||||
```typescript
|
||||
// Bad: Redefining types that exist in backend
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// Bad: Creating parallel type definitions
|
||||
type OrderStatus = 'pending' | 'processing' | 'completed';
|
||||
```
|
||||
|
||||
## Type Inference from API Client
|
||||
|
||||
### Using `Awaited<ReturnType>` Pattern
|
||||
|
||||
Infer types directly from API client calls to ensure frontend types stay in sync with backend:
|
||||
|
||||
```typescript
|
||||
import { orpcClient } from '@/lib/orpc';
|
||||
|
||||
// Infer the response type from the API client
|
||||
type UsersResponse = Awaited<ReturnType<typeof orpcClient.users.list>>;
|
||||
|
||||
// Infer a single item type from array response
|
||||
type User = UsersResponse['items'][number];
|
||||
|
||||
// Infer input types
|
||||
type CreateUserInput = Parameters<typeof orpcClient.users.create>[0];
|
||||
```
|
||||
|
||||
### Type Inference in Hooks
|
||||
|
||||
```typescript
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { orpcClient } from '@/lib/orpc';
|
||||
|
||||
// The return type is automatically inferred
|
||||
export function useUsers() {
|
||||
return useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => orpcClient.users.list(),
|
||||
});
|
||||
}
|
||||
|
||||
// For complex transformations, use explicit inference
|
||||
type UserListData = Awaited<ReturnType<typeof orpcClient.users.list>>;
|
||||
|
||||
export function useFormattedUsers() {
|
||||
return useQuery({
|
||||
queryKey: ['users', 'formatted'],
|
||||
queryFn: async () => {
|
||||
const data = await orpcClient.users.list();
|
||||
return transformUsers(data);
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Forbidden Patterns
|
||||
|
||||
### NO @ts-expect-error for Custom Fields
|
||||
|
||||
Never use type suppression to access fields that don't exist in the type:
|
||||
|
||||
```typescript
|
||||
// Bad: Suppressing type errors
|
||||
// @ts-expect-error - customField exists at runtime
|
||||
const value = user.customField;
|
||||
|
||||
// Bad: Using any to bypass type checking
|
||||
const value = (user as any).customField;
|
||||
```
|
||||
|
||||
**Solution**: If a field exists at runtime but not in types, update the backend type definition.
|
||||
|
||||
### NO `any` Type in Cache Updates
|
||||
|
||||
React Query cache updates must maintain type safety:
|
||||
|
||||
```typescript
|
||||
// Bad: Using any in cache updates
|
||||
queryClient.setQueryData(['users'], (old: any) => {
|
||||
return old.map((user: any) => /* ... */);
|
||||
});
|
||||
|
||||
// Good: Properly typed cache updates
|
||||
queryClient.setQueryData<UserListData>(['users'], (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
items: old.items.map((user) => /* ... */),
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
### NO Type Assertions Without Validation
|
||||
|
||||
```typescript
|
||||
// Bad: Blind type assertion
|
||||
const user = data as User;
|
||||
|
||||
// Good: Runtime validation with Zod
|
||||
import { userSchema } from '@your-app/api/modules/users/types'; // Replace with your monorepo package path
|
||||
const user = userSchema.parse(data);
|
||||
|
||||
// Good: Type guard
|
||||
function isUser(data: unknown): data is User {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'id' in data &&
|
||||
'email' in data
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## View Model Types
|
||||
|
||||
When the frontend needs additional computed properties, create view models that extend backend types:
|
||||
|
||||
```typescript
|
||||
// types/index.ts
|
||||
import type { Order } from '@your-app/api/modules/orders/types'; // Replace with your monorepo package path
|
||||
|
||||
// Extend backend type with frontend-specific computed properties
|
||||
export interface OrderViewModel extends Order {
|
||||
formattedTotal: string;
|
||||
statusLabel: string;
|
||||
isEditable: boolean;
|
||||
}
|
||||
|
||||
// Transform function
|
||||
export function toOrderViewModel(order: Order): OrderViewModel {
|
||||
return {
|
||||
...order,
|
||||
formattedTotal: formatCurrency(order.total),
|
||||
statusLabel: getStatusLabel(order.status),
|
||||
isEditable: order.status === 'draft',
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Generic Type Patterns
|
||||
|
||||
### API Response Wrapper
|
||||
|
||||
```typescript
|
||||
// Generic paginated response type
|
||||
type PaginatedResponse<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
// Usage with inference
|
||||
type UserListResponse = PaginatedResponse<User>;
|
||||
```
|
||||
|
||||
### Hook Return Types
|
||||
|
||||
```typescript
|
||||
// Explicit return type for complex hooks
|
||||
interface UseOrderActionsReturn {
|
||||
updateOrder: (id: string, data: UpdateOrderInput) => Promise<void>;
|
||||
deleteOrder: (id: string) => Promise<void>;
|
||||
isUpdating: boolean;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
export function useOrderActions(): UseOrderActionsReturn {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
## Working with External Data
|
||||
|
||||
### API Responses
|
||||
|
||||
```typescript
|
||||
// Always validate external data
|
||||
import { z } from 'zod';
|
||||
|
||||
const externalDataSchema = z.object({
|
||||
id: z.string(),
|
||||
value: z.number(),
|
||||
});
|
||||
|
||||
async function fetchExternalData() {
|
||||
const response = await fetch('/api/external');
|
||||
const data = await response.json();
|
||||
return externalDataSchema.parse(data);
|
||||
}
|
||||
```
|
||||
|
||||
### Local Storage
|
||||
|
||||
```typescript
|
||||
// Type-safe local storage wrapper
|
||||
function getStoredValue<T>(key: string, schema: z.ZodType<T>): T | null {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (!stored) return null;
|
||||
|
||||
try {
|
||||
return schema.parse(JSON.parse(stored));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## TypeScript Configuration
|
||||
|
||||
Ensure strict mode is enabled in `tsconfig.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUncheckedIndexedAccess": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Type Utilities
|
||||
|
||||
```typescript
|
||||
// Extract array element type
|
||||
type ArrayElement<T> = T extends (infer E)[] ? E : never;
|
||||
|
||||
// Make specific properties optional
|
||||
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||
|
||||
// Make specific properties required
|
||||
type RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
|
||||
|
||||
// Non-nullable
|
||||
type NonNullableFields<T> = {
|
||||
[K in keyof T]: NonNullable<T[K]>;
|
||||
};
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
Before committing, verify:
|
||||
|
||||
- [ ] No `@ts-expect-error` or `@ts-ignore` comments added
|
||||
- [ ] No `any` types in new code
|
||||
- [ ] All API response types are inferred or imported from backend
|
||||
- [ ] Cache updates are properly typed
|
||||
- [ ] External data is validated with Zod schemas
|
||||
417
.trellis/spec/guides/cross-layer-thinking-guide.md
Normal file
417
.trellis/spec/guides/cross-layer-thinking-guide.md
Normal file
@@ -0,0 +1,417 @@
|
||||
# Cross-Layer Thinking Guide
|
||||
|
||||
> **Purpose**: Pre-implementation checklist for features that span multiple layers.
|
||||
>
|
||||
> **Core Principle**: 30 minutes of thinking saves 3 hours of debugging.
|
||||
|
||||
---
|
||||
|
||||
## When to Use This Guide
|
||||
|
||||
Use this guide when your feature:
|
||||
|
||||
- Touches 3+ layers (Server Component, Client Component, oRPC, Database)
|
||||
- Involves data transformation between layers
|
||||
- Has real-time or event-driven components
|
||||
- Receives data from external sources (APIs, webhooks, file uploads)
|
||||
|
||||
---
|
||||
|
||||
## Pre-Implementation Checklist
|
||||
|
||||
Before writing code, answer these questions:
|
||||
|
||||
### 1. Layer Identification
|
||||
|
||||
**Which layers does this feature touch?**
|
||||
|
||||
- [ ] Server Components (RSC - data fetching, static rendering)
|
||||
- [ ] Client Components (interactivity, browser APIs, React hooks)
|
||||
- [ ] API Routes / oRPC Procedures (validation, business logic)
|
||||
- [ ] Middleware (auth checks, redirects, header manipulation)
|
||||
- [ ] Database (Drizzle ORM queries, migrations)
|
||||
- [ ] Server Actions (form handling, progressive enhancement)
|
||||
- [ ] External Services (third-party APIs, webhooks)
|
||||
|
||||
### 2. Data Flow Direction
|
||||
|
||||
**How does data flow?**
|
||||
|
||||
```
|
||||
Read Flow: DB -> Drizzle -> oRPC Handler -> API Response -> React Query -> Component -> UI
|
||||
Write Flow: UI -> Form/Action -> oRPC Mutation -> Handler -> Drizzle -> DB
|
||||
SSR Flow: DB -> Drizzle -> oRPC Handler -> Server Component -> HTML -> Client Hydration
|
||||
```
|
||||
|
||||
- [ ] Read-only (data flows from DB to UI)
|
||||
- [ ] Write-only (data flows from UI to DB)
|
||||
- [ ] Bidirectional (both directions)
|
||||
- [ ] Server-rendered (data fetched in Server Components)
|
||||
- [ ] Client-fetched (data fetched via React Query in Client Components)
|
||||
|
||||
### 3. Data Format at Each Layer
|
||||
|
||||
**What format is the data at each boundary?**
|
||||
|
||||
| Layer | Format | Example |
|
||||
| ---------------- | ----------------------- | ----------------------------------------------- |
|
||||
| Database | SQL types | `TEXT`, `INTEGER`, `TIMESTAMP`, `JSONB` |
|
||||
| Drizzle ORM | TypeScript types | `string`, `number`, `Date`, `Record<>` |
|
||||
| oRPC Handler | Zod-validated objects | `{ id: string, createdAt: Date }` |
|
||||
| oRPC Response | Serialized JSON | `{ id: "abc", createdAt: "2024-01-01T..." }` |
|
||||
| React Query | Cached response | Same as oRPC response (deserialized) |
|
||||
| Server Component | Props (must serialize) | No functions, no Date objects, no class instances |
|
||||
| Client Component | React state | Component props, hook return values |
|
||||
| UI | Rendered output | HTML, Tailwind-styled elements |
|
||||
|
||||
### 3.1 Serialization Boundary (CRITICAL!)
|
||||
|
||||
**Design Principle**: Data crossing the Server/Client Component boundary must be serializable.
|
||||
|
||||
| Serializable (OK) | NOT Serializable (WILL BREAK) |
|
||||
| ----------------------- | --------------------------------- |
|
||||
| `string`, `number` | `Date` objects |
|
||||
| `boolean`, `null` | `Map`, `Set` |
|
||||
| Plain objects, arrays | Functions, class instances |
|
||||
| `undefined` (as absent) | `BigInt`, `Symbol` |
|
||||
|
||||
**Common serialization trap**:
|
||||
|
||||
```typescript
|
||||
// BAD - Date objects don't serialize across RSC boundary
|
||||
async function ItemPage() {
|
||||
const item = await orpcClient.items.get({ itemId: "123" });
|
||||
// item.createdAt might be a Date object from Drizzle
|
||||
return <ClientItem item={item} />; // Date becomes string or breaks!
|
||||
}
|
||||
|
||||
// GOOD - Convert to serializable format before passing to Client Component
|
||||
async function ItemPage() {
|
||||
const item = await orpcClient.items.get({ itemId: "123" });
|
||||
return <ClientItem item={{
|
||||
...item,
|
||||
createdAt: item.createdAt.toISOString(), // Explicit string conversion
|
||||
}} />;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Data Transformation Points
|
||||
|
||||
**Where does format change? Who is responsible?**
|
||||
|
||||
| From | To | Transformer | Location |
|
||||
| ----------------- | ------------------- | ------------------- | -------------------------- |
|
||||
| DB timestamp | JS Date | Drizzle ORM | Automatic |
|
||||
| JS Date | ISO string | oRPC serialization | API response |
|
||||
| ISO string | Display string | React component | UI layer |
|
||||
| User input | Validated data | Zod schema | oRPC input validation |
|
||||
| JSONB column | TypeScript object | Drizzle + cast | Query layer |
|
||||
|
||||
### 5. Boundary Questions (Critical!)
|
||||
|
||||
For each layer boundary, ask:
|
||||
|
||||
**RSC / Client Component Boundary:**
|
||||
|
||||
- What data is the Server Component passing as props?
|
||||
- Is all of it serializable? (no functions, no Date objects, no Maps)
|
||||
- Could this data be fetched directly in the Client Component via React Query instead?
|
||||
- Does the Client Component need to refetch or mutate this data?
|
||||
|
||||
**Client Component / oRPC Boundary:**
|
||||
|
||||
- What format does the oRPC response return?
|
||||
- How does React Query cache and deserialize it?
|
||||
- What happens if the response format changes?
|
||||
- Are query keys consistent for cache invalidation?
|
||||
|
||||
**oRPC Handler / Database Boundary:**
|
||||
|
||||
- Are timestamps handled consistently? (ISO strings vs Date objects)
|
||||
- Are IDs strings or numbers?
|
||||
- What about null vs undefined?
|
||||
- Does Drizzle transform types automatically?
|
||||
- Are JSONB columns properly cast?
|
||||
|
||||
**Middleware / Route Boundary:**
|
||||
|
||||
- Is auth checked in middleware, oRPC procedure, or both?
|
||||
- What happens if middleware redirects but the API call continues?
|
||||
- Are headers properly forwarded in SSR context?
|
||||
|
||||
### 6. Authentication Context
|
||||
|
||||
**Where is auth available?**
|
||||
|
||||
| Layer | Auth Method | Notes |
|
||||
| ---------------- | -------------------------------------- | --------------------------------------- |
|
||||
| Middleware | `getSession()` from headers/cookies | Runs before route handler |
|
||||
| Server Component | `getSession()` or `auth()` helper | Can redirect on the server |
|
||||
| Client Component | `useSession()` hook | May need loading state |
|
||||
| oRPC Procedure | `protectedProcedure` middleware | Throws UNAUTHORIZED if no session |
|
||||
| API Route | `getSession()` from request headers | Manual check needed |
|
||||
|
||||
**Common auth pitfall**:
|
||||
|
||||
```typescript
|
||||
// BAD - Auth checked in middleware but not in oRPC procedure
|
||||
// If someone calls the API directly, auth is bypassed!
|
||||
export const middleware = NextResponse.next(); // auth check here
|
||||
export const getSecret = publicProcedure.handler(...); // no auth check!
|
||||
|
||||
// GOOD - Auth in oRPC procedure (always enforced)
|
||||
export const getSecret = protectedProcedure.handler(...);
|
||||
```
|
||||
|
||||
### 7. Edge Cases
|
||||
|
||||
- [ ] What if the data is empty/null?
|
||||
- [ ] What if the database query fails?
|
||||
- [ ] What if the oRPC call times out?
|
||||
- [ ] What if a referenced entity doesn't exist?
|
||||
- [ ] What if the user navigates away mid-mutation?
|
||||
- [ ] What if React Query returns stale data?
|
||||
- [ ] What if the user's session expires mid-operation?
|
||||
- [ ] What if the same mutation fires twice (double-click)?
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern A: Server Component Data Fetch
|
||||
|
||||
**Layers**: Server Component -> oRPC Client -> Handler -> Database
|
||||
|
||||
**Data Flow**:
|
||||
|
||||
```
|
||||
1. Server Component: Calls oRPC client directly (server-side)
|
||||
2. oRPC Handler: Validates auth, queries database
|
||||
3. Drizzle: Returns typed results
|
||||
4. Server Component: Renders HTML with data
|
||||
5. Client: Receives pre-rendered HTML
|
||||
```
|
||||
|
||||
**Common Issues**:
|
||||
|
||||
- **Serialization**: Server Components can render Date objects directly, but cannot pass them as props to Client Components
|
||||
- **No cache**: Server-side oRPC calls bypass React Query cache; consider prefetching
|
||||
- **Waterfall**: Sequential server-side calls create request waterfalls; use `Promise.all` for parallel fetching
|
||||
|
||||
### Pattern B: Client Component with React Query
|
||||
|
||||
**Layers**: Client Component -> React Query -> oRPC Client -> Handler -> Database
|
||||
|
||||
**Data Flow**:
|
||||
|
||||
```
|
||||
1. Client Component: Mounts, triggers useQuery
|
||||
2. React Query: Checks cache, calls oRPC client if stale
|
||||
3. oRPC Client: Sends HTTP request to API route
|
||||
4. oRPC Handler: Validates input/auth, queries DB
|
||||
5. Response: JSON back through React Query
|
||||
6. Client Component: Re-renders with data
|
||||
```
|
||||
|
||||
**Common Issues**:
|
||||
|
||||
- **Loading states**: Must handle `isLoading`, `isError`, `isPending` properly
|
||||
- **Stale data**: Configure `staleTime` and `gcTime` appropriately
|
||||
- **Cache invalidation**: Use `orpc.xxx.key()` for consistent invalidation after mutations
|
||||
- **Enabled flag**: Disable queries when required parameters are missing
|
||||
|
||||
### Pattern C: Mutation with Optimistic Update
|
||||
|
||||
**Layers**: Client Component -> useMutation -> oRPC Client -> Handler -> Database
|
||||
|
||||
**Data Flow**:
|
||||
|
||||
```
|
||||
1. User: Triggers action (click, form submit)
|
||||
2. onMutate: Optimistically update React Query cache
|
||||
3. oRPC Client: Sends mutation request
|
||||
4. Handler: Validates, writes to DB
|
||||
5. onSuccess: Invalidate related queries
|
||||
6. onError: Rollback optimistic update from snapshot
|
||||
```
|
||||
|
||||
**Common Issues**:
|
||||
|
||||
- **Rollback complexity**: Must snapshot all affected queries before optimistic update
|
||||
- **Type safety**: Cache manipulation needs explicit type annotations
|
||||
- **Race conditions**: Cancel outgoing refetches before optimistic update (`cancelQueries`)
|
||||
- **Partial failures**: Batch operations may partially succeed
|
||||
|
||||
### Pattern D: Server Action (Form Handling)
|
||||
|
||||
**Layers**: Form -> Server Action -> oRPC Client / DB -> Revalidate
|
||||
|
||||
**Data Flow**:
|
||||
|
||||
```
|
||||
1. User: Submits form
|
||||
2. Server Action: Receives FormData, validates
|
||||
3. Action: Calls oRPC client or DB directly
|
||||
4. Action: Calls revalidatePath/revalidateTag
|
||||
5. Page: Re-renders with updated data
|
||||
```
|
||||
|
||||
**Common Issues**:
|
||||
|
||||
- **Progressive enhancement**: Forms work without JS when using Server Actions
|
||||
- **Validation**: Validate on both client (UX) and server (security)
|
||||
- **Redirect vs revalidate**: Choose the right post-action behavior
|
||||
- **Error handling**: Server Action errors need proper error boundaries
|
||||
|
||||
### Pattern E: Middleware + API Route Auth
|
||||
|
||||
**Layers**: Request -> Middleware -> API Route / oRPC -> Handler
|
||||
|
||||
**Data Flow**:
|
||||
|
||||
```
|
||||
1. Request: Arrives at Next.js server
|
||||
2. Middleware: Checks auth, may redirect to login
|
||||
3. API Route: Handles oRPC request
|
||||
4. oRPC Middleware: Validates session (protectedProcedure)
|
||||
5. Handler: Executes business logic
|
||||
```
|
||||
|
||||
**Common Issues**:
|
||||
|
||||
- **Double auth check**: Middleware protects pages, oRPC protects API; both are needed
|
||||
- **Header forwarding**: SSR requests must forward cookies/headers to oRPC client
|
||||
- **Middleware scope**: Don't run auth middleware on public assets or API routes that handle their own auth
|
||||
|
||||
---
|
||||
|
||||
## Lessons from Common Bugs
|
||||
|
||||
| Bug | Root Cause | Prevention |
|
||||
| ------------------------------- | --------------------------------------------------------- | --------------------------------------------------- |
|
||||
| `Date` props break hydration | Date objects passed from Server to Client Component | Convert to ISO string before passing as props |
|
||||
| Stale data after mutation | Forgot to invalidate React Query cache | Always invalidate with `orpc.xxx.key()` in onSuccess |
|
||||
| Auth bypass on API | Auth only in middleware, not in oRPC procedure | Always use `protectedProcedure` for protected data |
|
||||
| `BigInt` serialization error | Database returns BigInt, JSON.stringify fails | Cast to number or string before response |
|
||||
| Query fires with null ID | `enabled` flag not set on conditional queries | Always guard with `enabled: !!requiredParam` |
|
||||
| Cache key mismatch | Manual query key doesn't match oRPC generated key | Always use `orpc.xxx.key()` or `orpc.xxx.queryKey()` |
|
||||
| N+1 queries in handler | Fetching related data in a loop | Use `inArray()` for batch queries |
|
||||
| Hydration mismatch | Server and client render different output (e.g., locale) | Ensure consistent data between server and client |
|
||||
| Headers not forwarded in SSR | oRPC client doesn't forward cookies in server context | Configure client to forward headers in SSR mode |
|
||||
|
||||
---
|
||||
|
||||
## Checklist Template
|
||||
|
||||
Copy this for your feature:
|
||||
|
||||
```markdown
|
||||
## Feature: [Name]
|
||||
|
||||
### Layers Involved
|
||||
|
||||
- [ ] Server Component
|
||||
- [ ] Client Component
|
||||
- [ ] oRPC Procedure
|
||||
- [ ] Middleware
|
||||
- [ ] Database
|
||||
- [ ] Server Action
|
||||
- [ ] External Service
|
||||
|
||||
### Data Flow
|
||||
|
||||
[Describe the flow]
|
||||
|
||||
### Format at Each Layer
|
||||
|
||||
| Layer | Format |
|
||||
| ----- | ------ |
|
||||
| ... | ... |
|
||||
|
||||
### Transformation Points
|
||||
|
||||
| From | To | Who |
|
||||
| ---- | --- | --- |
|
||||
| ... | ... | ... |
|
||||
|
||||
### Auth Strategy
|
||||
|
||||
- Middleware: [yes/no, what it checks]
|
||||
- oRPC: [publicProcedure/protectedProcedure/adminProcedure]
|
||||
|
||||
### Edge Cases Considered
|
||||
|
||||
- [ ] Empty/null data
|
||||
- [ ] Invalid format / serialization
|
||||
- [ ] Operation failure / timeout
|
||||
- [ ] User cancellation / navigation
|
||||
- [ ] Session expiry mid-operation
|
||||
- [ ] Double submission
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cross-Layer Review Mindset
|
||||
|
||||
### The Comparison Trap
|
||||
|
||||
**Wrong thinking**: "This line wasn't changed, so it must be correct."
|
||||
|
||||
```
|
||||
Comparison thinking (surface level):
|
||||
Before: new Date() -> After: new Date() -> "No change, must be fine"
|
||||
|
||||
Global thinking (design level):
|
||||
Design intent: ISO strings across RSC boundary -> Current: Date object -> "This is a bug"
|
||||
```
|
||||
|
||||
**Key insight**: Review validates "system state is correct", not just "change is correct".
|
||||
|
||||
### Data Outlet Checklist
|
||||
|
||||
Every review must cover ALL data outlets:
|
||||
|
||||
```
|
||||
Data Outlets:
|
||||
|-- oRPC Response (handler -> client)
|
||||
|-- Server Component Props (RSC -> Client Component)
|
||||
|-- React Query Cache (shared across components)
|
||||
|-- URL State (nuqs, searchParams)
|
||||
|-- Server Action Return (action -> form)
|
||||
|-- Any external interface
|
||||
```
|
||||
|
||||
Ask: **"Is the format correct at EACH outlet?"**
|
||||
|
||||
### Review Three Questions
|
||||
|
||||
Before finishing any cross-layer review:
|
||||
|
||||
1. **Outlet Question**: Have I checked ALL data outlets, not just the "core" one?
|
||||
2. **Design Question**: Does existing code match design principles? (Not "is the change correct?")
|
||||
3. **Checklist Question**: Could my checklist itself be wrong?
|
||||
|
||||
### Validation vs Verification
|
||||
|
||||
| Approach | Focus | Risk |
|
||||
| --------------- | ---------------------------- | ------------------------------------ |
|
||||
| **Incremental** | "Is this change correct?" | Misses pre-existing bugs |
|
||||
| **Global** | "Is the system correct now?" | More thorough, catches legacy issues |
|
||||
|
||||
Always prefer global verification for cross-layer features.
|
||||
|
||||
---
|
||||
|
||||
## When Things Go Wrong
|
||||
|
||||
If you encounter a cross-layer bug:
|
||||
|
||||
1. **Identify the boundary** - Where exactly does it fail?
|
||||
2. **Log at boundaries** - Add logging before and after each transformation
|
||||
3. **Check assumptions** - What format did you expect vs what you got?
|
||||
4. **Test in isolation** - Can you reproduce with a simple test case?
|
||||
5. **Document the fix** - Add to "Lessons from Common Bugs" table
|
||||
|
||||
---
|
||||
|
||||
**Language**: All documentation should be written in **English**.
|
||||
122
.trellis/spec/guides/index.md
Normal file
122
.trellis/spec/guides/index.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Thinking Guides for Next.js Full-Stack Projects
|
||||
|
||||
> **Purpose**: Systematic thinking guides to catch issues before they become bugs.
|
||||
>
|
||||
> **Core Philosophy**: 30 minutes of thinking saves 3 hours of debugging.
|
||||
|
||||
---
|
||||
|
||||
## 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 Thinking Guides
|
||||
|
||||
| Guide | Purpose | When to Use |
|
||||
| ----------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------ |
|
||||
| [Cross-Layer Thinking](./cross-layer-thinking-guide.md) | Think through data flow across layers | Before implementing features that span 3+ layers |
|
||||
| [Pre-Implementation Checklist](./pre-implementation-checklist.md) | Verify readiness before coding | Before starting any feature implementation |
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: When to Use Which Guide
|
||||
|
||||
### Cross-Layer Issues
|
||||
|
||||
Use [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) when:
|
||||
|
||||
- [ ] Feature touches 3+ layers (Server Component, Client Component, oRPC, Database)
|
||||
- [ ] Data format changes between layers
|
||||
- [ ] Multiple consumers need the same data
|
||||
- [ ] You're not sure where to put some logic
|
||||
- [ ] Integrates with external services or third-party APIs
|
||||
|
||||
### Before Writing Code
|
||||
|
||||
Use [Pre-Implementation Checklist](./pre-implementation-checklist.md) when:
|
||||
|
||||
- [ ] About to add a constant or config value
|
||||
- [ ] About to implement new logic
|
||||
- [ ] About to define a type or Zod schema
|
||||
- [ ] About to create a component or hook
|
||||
- [ ] About to add an oRPC procedure
|
||||
- [ ] Feels like you've seen similar code before
|
||||
|
||||
---
|
||||
|
||||
## The Pre-Modification Rule (CRITICAL)
|
||||
|
||||
> **Before changing ANY value, ALWAYS search first!**
|
||||
|
||||
```bash
|
||||
# Search for the value you're about to change
|
||||
rg "value_to_change" --type ts
|
||||
|
||||
# Check how many files define this value
|
||||
rg "CONFIG_NAME" --type ts -c
|
||||
```
|
||||
|
||||
This single habit prevents most "forgot to update X" bugs.
|
||||
|
||||
---
|
||||
|
||||
## Next.js-Specific Layers
|
||||
|
||||
In Next.js full-stack projects with oRPC and Drizzle, these are the typical layers:
|
||||
|
||||
```
|
||||
Server Components (RSC - data fetching, static rendering)
|
||||
|
|
||||
v
|
||||
Client Components ('use client' - interactivity, React Query)
|
||||
|
|
||||
v
|
||||
API Routes / oRPC Router (type-safe RPC, middleware, validation)
|
||||
|
|
||||
v
|
||||
Service / Business Logic (shared utilities, domain rules)
|
||||
|
|
||||
v
|
||||
Database Layer (Drizzle ORM, PostgreSQL, migrations)
|
||||
```
|
||||
|
||||
Each boundary is a potential source of bugs due to:
|
||||
|
||||
- **Serialization** - Only serializable data crosses the RSC/Client boundary (no functions, no Date objects, no Maps)
|
||||
- **Type mismatches** - Zod schemas on oRPC may not match what the frontend expects
|
||||
- **Auth context** - Session availability differs between Server Components, API routes, and middleware
|
||||
- **Rendering mode** - Server Components vs Client Components have different capabilities and constraints
|
||||
- **Async timing** - React Query caching, stale data, and race conditions
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Search Before Write** - Always search for existing patterns before creating new ones
|
||||
2. **Think Before Code** - 5 minutes of checklist saves 50 minutes of debugging
|
||||
3. **Document Assumptions** - Make implicit assumptions explicit
|
||||
4. **Verify All Layers** - Changes often need updates in multiple places
|
||||
5. **Learn From Bugs** - Add lessons to these guides after fixing non-trivial bugs
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Found a new "didn't think of that" moment? Add it:
|
||||
|
||||
1. If it's a **general thinking pattern** -> Add to existing guide or create new one
|
||||
2. If it caused a bug -> Add to "Lessons Learned" section in the relevant guide
|
||||
3. If it's **project-specific** -> Create a separate project-specific guide
|
||||
|
||||
---
|
||||
|
||||
**Language**: All documentation should be written in **English**.
|
||||
289
.trellis/spec/guides/pre-implementation-checklist.md
Normal file
289
.trellis/spec/guides/pre-implementation-checklist.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# Pre-Implementation Checklist
|
||||
|
||||
> **Purpose**: Ask the right questions **before** writing code to avoid common architectural mistakes.
|
||||
|
||||
---
|
||||
|
||||
## Why This Checklist?
|
||||
|
||||
Most code quality issues aren't caught during implementation--they're **designed in** from the start:
|
||||
|
||||
| Problem | Root Cause | Cost |
|
||||
| -------------------------------------- | --------------------------------------------- | -------------------------- |
|
||||
| Constants duplicated across 5 files | Didn't ask "will this be used elsewhere?" | Refactoring + testing |
|
||||
| Same logic repeated in multiple hooks | Didn't ask "does this pattern exist?" | Creating abstraction later |
|
||||
| Cross-layer type mismatches | Didn't ask "who else consumes this?" | Debugging + fixing |
|
||||
| Zod schema redefined in frontend | Didn't ask "is this type already exported?" | Inconsistent validation |
|
||||
| oRPC procedure duplicates existing one | Didn't ask "does a similar endpoint exist?" | API surface bloat |
|
||||
|
||||
**This checklist catches these issues before they become code.**
|
||||
|
||||
---
|
||||
|
||||
## The Checklist
|
||||
|
||||
### 1. Constants & Configuration
|
||||
|
||||
Before adding any constant or config value:
|
||||
|
||||
- [ ] **Cross-package usage?** Will this value be used in both frontend app and API package?
|
||||
- If yes -> Put in a shared package (e.g., `@your-app/utils` or `@your-app/config`)
|
||||
- Example: `MAX_UPLOAD_SIZE` used by both file upload UI and oRPC validation
|
||||
|
||||
- [ ] **Multiple consumers?** Will this value be used in 2+ files within the same package?
|
||||
- If yes -> Put in a shared constants file for that package
|
||||
- Example: Don't define `DEBOUNCE_MS = 300` in each hook file
|
||||
|
||||
- [ ] **Magic number?** Is this a hardcoded value that could change?
|
||||
- If yes -> Extract to named constant with comment explaining why
|
||||
- Example: `PAGINATION_LIMIT: 50 // oRPC default page size`
|
||||
|
||||
- [ ] **Environment-dependent?** Does this differ between dev/staging/production?
|
||||
- If yes -> Use environment variables with proper validation
|
||||
- Example: API URLs, feature flags, third-party API keys
|
||||
|
||||
### 2. Logic & Patterns
|
||||
|
||||
Before implementing any logic:
|
||||
|
||||
- [ ] **Pattern exists?** Search for similar patterns in the codebase first
|
||||
|
||||
```bash
|
||||
# Example: Before implementing debounced search
|
||||
rg "debounce" src/ packages/
|
||||
rg "useDebounce" src/ packages/
|
||||
```
|
||||
|
||||
- [ ] **Will repeat?** Will this exact logic be needed in 2+ places?
|
||||
- If yes -> Create a shared hook/utility **first**, then use it
|
||||
- Example: `useDebounce` instead of repeating debounce logic in 5 hooks
|
||||
|
||||
- [ ] **React Query pattern?** Is there an existing query/mutation hook for this data?
|
||||
- Search before creating: `rg "orpc.items" src/`
|
||||
- Check if you can extend an existing hook rather than creating a new one
|
||||
|
||||
- [ ] **Server or client?** Does this logic need interactivity?
|
||||
- If no -> Keep it in a Server Component (default)
|
||||
- If yes -> Extract only the interactive part into a Client Component
|
||||
|
||||
### 3. Types & Schemas
|
||||
|
||||
Before defining types:
|
||||
|
||||
- [ ] **Zod schema exists?** Is there already a Zod schema for this data shape?
|
||||
- Check the API module's `types.ts`: `rg "Schema = z.object" packages/api/`
|
||||
- Derive TypeScript types from Zod schemas with `z.infer<typeof schema>`
|
||||
- Never manually define a TypeScript interface that duplicates a Zod schema
|
||||
|
||||
- [ ] **Existing type?** Does a similar type already exist?
|
||||
- Search before creating: `rg "interface.*YourTypeName\|type.*YourTypeName" src/ packages/`
|
||||
|
||||
- [ ] **Cross-layer type?** Is this type used across the oRPC boundary?
|
||||
- If yes -> Define the Zod schema in the API module's `types.ts`, export the inferred type
|
||||
- Frontend should import types from the API package, not redefine them
|
||||
|
||||
- [ ] **Derived from client?** Can you infer the type from the oRPC client?
|
||||
```typescript
|
||||
// Prefer this over manually defining types
|
||||
type ItemResult = Awaited<ReturnType<(typeof orpcClient)["items"]["get"]>>;
|
||||
```
|
||||
|
||||
### 4. UI Components
|
||||
|
||||
Before creating UI components:
|
||||
|
||||
- [ ] **Server or Client Component?** Does this component need:
|
||||
- Event handlers (onClick, onChange)? -> `'use client'`
|
||||
- React hooks (useState, useEffect)? -> `'use client'`
|
||||
- Browser APIs (window, document)? -> `'use client'`
|
||||
- None of the above? -> Keep as Server Component (default)
|
||||
|
||||
- [ ] **Similar component exists?** Search before creating
|
||||
- `rg "function.*YourComponent\|export.*YourComponent" src/`
|
||||
|
||||
- [ ] **Visual-logic consistency?** If there's already a visual distinction (icon, color, label) for a concept, does your logic match?
|
||||
|
||||
- [ ] **State lifecycle?** Will this component unmount during normal user flow?
|
||||
- If yes -> Consider where state should persist (URL params with nuqs, parent, context)
|
||||
|
||||
### 5. API Routes & oRPC Procedures
|
||||
|
||||
Before writing an API route or oRPC procedure:
|
||||
|
||||
- [ ] **Existing procedure?** Does a similar oRPC procedure already exist?
|
||||
- Check the module's `router.ts`: `rg "Router = {" packages/api/`
|
||||
- Can you extend an existing procedure rather than creating a new one?
|
||||
|
||||
- [ ] **Correct HTTP method?**
|
||||
- GET for read operations (queries)
|
||||
- POST for create operations (mutations)
|
||||
- PUT/PATCH for update operations
|
||||
- DELETE for removal operations
|
||||
|
||||
- [ ] **Authentication level?** Which base procedure to use?
|
||||
- Public data -> `publicProcedure`
|
||||
- User-specific data -> `protectedProcedure`
|
||||
- Admin operations -> `adminProcedure`
|
||||
|
||||
- [ ] **Input/output schemas defined?** Both should be Zod schemas in `types.ts`
|
||||
|
||||
### 6. Dependencies
|
||||
|
||||
Before adding a dependency:
|
||||
|
||||
- [ ] **Already installed?** Check `package.json` across all packages
|
||||
```bash
|
||||
rg "\"dependency-name\"" package.json packages/*/package.json
|
||||
```
|
||||
|
||||
- [ ] **Built-in alternative?** Can you use a native API or existing utility instead?
|
||||
- Example: `structuredClone()` instead of `lodash.cloneDeep`
|
||||
|
||||
- [ ] **Bundle impact?** Will this significantly increase the client bundle?
|
||||
- If yes -> Consider dynamic import or server-only usage
|
||||
|
||||
---
|
||||
|
||||
## Quick Decision Tree
|
||||
|
||||
```
|
||||
Adding a value/constant?
|
||||
|-- Used in both app AND api package? -> shared package (@your-app/utils)
|
||||
|-- Used in 2+ files within same package? -> shared constants file
|
||||
+-- Single file only? -> Local constant is fine
|
||||
|
||||
Adding logic/behavior?
|
||||
|-- Similar pattern exists? -> Extend or reuse existing
|
||||
|-- Will be used in 2+ places? -> Create shared hook/utility first
|
||||
+-- Single use only? -> Implement directly (but document pattern)
|
||||
|
||||
Adding a type?
|
||||
|-- Zod schema exists? -> Use z.infer<typeof schema>
|
||||
|-- Crosses oRPC boundary? -> Define in API types.ts, import elsewhere
|
||||
|-- Can derive from client? -> Use Awaited<ReturnType<...>>
|
||||
+-- Local only? -> Define locally
|
||||
|
||||
Adding a component?
|
||||
|-- Needs interactivity? -> 'use client'
|
||||
|-- Pure display? -> Server Component (default)
|
||||
+-- Mix of both? -> Split into Server wrapper + Client interactive part
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What to Verify Across Layers
|
||||
|
||||
When implementing a feature that spans Server Component -> API -> Database, verify:
|
||||
|
||||
| Layer | Check |
|
||||
| ---------------- | ------------------------------------------------------------------ |
|
||||
| Server Component | Data fetched correctly? Props serializable? No client-only APIs? |
|
||||
| Client Component | Loading/error states handled? React Query cache invalidated? |
|
||||
| oRPC Procedure | Input validated? Auth checked? Output schema matches? |
|
||||
| Database Query | No N+1 queries? Proper indexes? Transactions where needed? |
|
||||
| Zod Schemas | Input and output schemas consistent? Date/null handling correct? |
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
### Redefining Backend Types
|
||||
|
||||
```typescript
|
||||
// DON'T: Manually define types that mirror Zod schemas
|
||||
interface Item {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// DO: Import or infer from the source of truth
|
||||
import type { Item } from "@your-app/api/modules/items/types";
|
||||
// or
|
||||
type Item = Awaited<ReturnType<(typeof orpcClient)["items"]["get"]>>["item"];
|
||||
```
|
||||
|
||||
### Manual Query Keys
|
||||
|
||||
```typescript
|
||||
// DON'T: Manually construct query keys
|
||||
queryClient.invalidateQueries({ queryKey: ["items", "list"] });
|
||||
|
||||
// DO: Use oRPC generated keys
|
||||
queryClient.invalidateQueries({ queryKey: orpc.items.list.key() });
|
||||
```
|
||||
|
||||
### Unnecessary Client Components
|
||||
|
||||
```typescript
|
||||
// DON'T: Mark everything as 'use client'
|
||||
'use client';
|
||||
export function ItemCard({ item }) {
|
||||
return <div>{item.name}</div>; // No interactivity needed!
|
||||
}
|
||||
|
||||
// DO: Keep as Server Component when possible
|
||||
export function ItemCard({ item }) {
|
||||
return <div>{item.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Fetch in Client Components When Server Would Work
|
||||
|
||||
```typescript
|
||||
// DON'T: Fetch in Client Component when data could come from Server Component
|
||||
'use client';
|
||||
export function ItemList() {
|
||||
const { data } = useQuery(orpc.items.list.queryOptions({ input: {} }));
|
||||
return <ul>{data?.items.map(...)}</ul>;
|
||||
}
|
||||
|
||||
// DO: Fetch in Server Component, pass as props (when no interactivity needed)
|
||||
export async function ItemList() {
|
||||
const data = await orpcClient.items.list({});
|
||||
return <ul>{data.items.map(...)}</ul>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## When to Use This Checklist
|
||||
|
||||
| Trigger | Action |
|
||||
| ------------------------------------------ | ------------------------- |
|
||||
| About to add a constant | Run through Section 1 |
|
||||
| About to implement logic | Run through Section 2 |
|
||||
| About to define a type or schema | Run through Section 3 |
|
||||
| About to create a component | Run through Section 4 |
|
||||
| About to add an oRPC procedure | Run through Section 5 |
|
||||
| About to add a dependency | Run through Section 6 |
|
||||
| Feels like you've seen similar code before | **STOP** and search first |
|
||||
|
||||
---
|
||||
|
||||
## Relationship to Other Guides
|
||||
|
||||
| Guide | Focus | Timing |
|
||||
| ------------------------------------------------------------- | ------------------------- | --------------------------- |
|
||||
| **Pre-Implementation Checklist** (this) | Questions before coding | Before writing code |
|
||||
| [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) | Data flow across layers | Complex feature planning |
|
||||
|
||||
**Ideal workflow:**
|
||||
|
||||
1. Read this checklist before coding
|
||||
2. Use Cross-Layer guide for features spanning multiple layers
|
||||
|
||||
---
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
| Date | Issue | Lesson |
|
||||
| ---- | ---------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| - | Zod schema redefined in frontend and backend | Always derive frontend types from the API package's Zod schemas |
|
||||
| - | `useQuery` used where Server Component sufficed | Ask "does this need interactivity?" before reaching for React Query |
|
||||
| - | Manual query keys diverged from oRPC keys | Always use `orpc.xxx.key()` or `orpc.xxx.queryKey()` for cache ops |
|
||||
| - | Type defined in both app and api package | Cross-boundary types must be defined once in the API module |
|
||||
|
||||
---
|
||||
|
||||
**Core Principle**: 5 minutes of checklist thinking saves 50 minutes of refactoring.
|
||||
315
.trellis/spec/shared/code-quality.md
Normal file
315
.trellis/spec/shared/code-quality.md
Normal file
@@ -0,0 +1,315 @@
|
||||
# Code Quality Guidelines
|
||||
|
||||
> Mandatory code quality rules for all Next.js full-stack applications.
|
||||
|
||||
---
|
||||
|
||||
## No Non-Null Assertions
|
||||
|
||||
**NEVER** use non-null assertions (`!`). They bypass TypeScript's null checking and lead to runtime errors.
|
||||
|
||||
```typescript
|
||||
// FORBIDDEN
|
||||
const name = user!.name;
|
||||
const value = data!.items![0]!;
|
||||
|
||||
// REQUIRED - Use explicit checks
|
||||
const user = getUser();
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
const name = user.name;
|
||||
|
||||
// REQUIRED - Use optional chaining with fallback
|
||||
const value = data?.items?.[0] ?? defaultValue;
|
||||
|
||||
// REQUIRED - Use local variable after null check
|
||||
const project = getProject(id);
|
||||
if (!project) {
|
||||
return { success: false, reason: 'Project not found' };
|
||||
}
|
||||
const projectName = project.name;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## No `any` Type
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
function process(data: any) { ... }
|
||||
|
||||
// GOOD - Use proper types
|
||||
function process(data: ProcessInput) { ... }
|
||||
|
||||
// GOOD - Use unknown for truly unknown data
|
||||
function parseJSON(input: string): unknown {
|
||||
return JSON.parse(input);
|
||||
}
|
||||
|
||||
// BAD - any in cache updates
|
||||
queryClient.setQueryData(['users'], (old: any) => ...);
|
||||
|
||||
// GOOD - Properly typed cache updates
|
||||
queryClient.setQueryData<UserListData>(['users'], (old) => {
|
||||
if (!old) return old;
|
||||
return { ...old, items: old.items.filter((u) => u.id !== deletedId) };
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## No `@ts-expect-error` / `@ts-ignore`
|
||||
|
||||
```typescript
|
||||
// FORBIDDEN
|
||||
// @ts-expect-error - field exists at runtime
|
||||
const value = user.customField;
|
||||
|
||||
// @ts-ignore
|
||||
doSomething(invalidArg);
|
||||
|
||||
// REQUIRED - Fix the type issue at the source
|
||||
// If a field exists at runtime but not in types, update the type definition.
|
||||
doSomething(validArg);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## No `console.log`
|
||||
|
||||
Use structured logging instead of `console.log`. This applies to both frontend and backend code.
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
console.log('User created:', userId);
|
||||
console.log('Error:', error);
|
||||
|
||||
// GOOD - Backend: use structured logger
|
||||
logger.info('user_created', { userId });
|
||||
logger.error('operation_failed', { error, operationId });
|
||||
|
||||
// GOOD - Frontend: remove debug logs before commit
|
||||
// Use browser dev tools for debugging, not console.log
|
||||
```
|
||||
|
||||
**Exception**: `console.warn` and `console.error` are acceptable in frontend code for development-time warnings that will not appear in production.
|
||||
|
||||
---
|
||||
|
||||
## Import Ordering
|
||||
|
||||
Organize imports in this order, separated by blank lines:
|
||||
|
||||
```typescript
|
||||
// 1. Node built-ins
|
||||
import path from 'node:path';
|
||||
|
||||
// 2. External packages
|
||||
import { z } from 'zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
// 3. Internal workspace packages
|
||||
import type { User } from '@your-app/api/modules/users/types';
|
||||
|
||||
// 4. Local imports (relative paths)
|
||||
import { formatDate } from './utils';
|
||||
import type { Props } from './types';
|
||||
```
|
||||
|
||||
Always use `import type` for type-only imports:
|
||||
|
||||
```typescript
|
||||
// GOOD
|
||||
import type { User, Project } from './types';
|
||||
import { createUser } from './procedures';
|
||||
|
||||
// BAD - Mixed imports without type annotation
|
||||
import { User, createUser } from './types';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Files and Directories
|
||||
|
||||
| Type | Convention | Example |
|
||||
| --------------- | --------------------------- | --------------------------- |
|
||||
| React Component | PascalCase | `UserProfile.tsx` |
|
||||
| Hook | camelCase with `use` prefix | `useProject.ts` |
|
||||
| Utility | kebab-case | `date-utils.ts` |
|
||||
| Type file | kebab-case or `types.ts` | `types.ts`, `user-types.ts` |
|
||||
| Test file | Same name + `.test` | `date-utils.test.ts` |
|
||||
| Directory | kebab-case | `user-profile/` |
|
||||
|
||||
### Variables and Functions
|
||||
|
||||
| Type | Convention | Example |
|
||||
| -------------- | ------------------------------------------- | ---------------------------------- |
|
||||
| Variable | camelCase | `userName`, `isActive` |
|
||||
| Constant | SCREAMING_SNAKE_CASE | `MAX_RETRY_COUNT` |
|
||||
| Function | camelCase | `getUserById` |
|
||||
| Class | PascalCase | `UserService` |
|
||||
| Type/Interface | PascalCase | `UserInput`, `ProjectOutput` |
|
||||
| Enum | PascalCase (type), SCREAMING_SNAKE (values) | `enum Status { ACTIVE, INACTIVE }` |
|
||||
|
||||
### Boolean Variables
|
||||
|
||||
Use `is`, `has`, `should`, `can` prefixes:
|
||||
|
||||
```typescript
|
||||
// GOOD
|
||||
const isLoading = true;
|
||||
const hasPermission = user.role === 'admin';
|
||||
const shouldRefresh = Date.now() > expiresAt;
|
||||
const canEdit = isOwner || hasPermission;
|
||||
|
||||
// BAD
|
||||
const loading = true;
|
||||
const permission = user.role === 'admin';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Never Swallow Errors
|
||||
|
||||
```typescript
|
||||
// BAD - Silent failure
|
||||
try {
|
||||
await dangerousOperation();
|
||||
} catch (e) {
|
||||
// nothing
|
||||
}
|
||||
|
||||
// GOOD - Log and handle
|
||||
try {
|
||||
await dangerousOperation();
|
||||
} catch (error) {
|
||||
logger.error('operation_failed', { error });
|
||||
throw new AppError('Operation failed', 'OPERATION_FAILED');
|
||||
}
|
||||
```
|
||||
|
||||
### Consistent Error Response Format
|
||||
|
||||
All API responses must use the standard `success` + `reason` format:
|
||||
|
||||
```typescript
|
||||
// Success
|
||||
return {
|
||||
success: true,
|
||||
reason: 'Operation completed successfully',
|
||||
data: result,
|
||||
};
|
||||
|
||||
// Error
|
||||
return {
|
||||
success: false,
|
||||
reason: 'Insufficient permissions to perform this action',
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dead Code Elimination
|
||||
|
||||
- Remove unused imports (Biome enforces this automatically)
|
||||
- Remove commented-out code blocks
|
||||
- Remove unused variables, functions, and types
|
||||
- Remove unreachable code after `return`, `throw`, `break`, `continue`
|
||||
|
||||
```typescript
|
||||
// BAD - Dead code
|
||||
function processOrder(order: Order) {
|
||||
// const oldLogic = order.items.map(...);
|
||||
const result = newLogic(order);
|
||||
return result;
|
||||
cleanup(); // unreachable
|
||||
}
|
||||
|
||||
// GOOD - Clean
|
||||
function processOrder(order: Order) {
|
||||
return newLogic(order);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lint and Type Check Before Commit
|
||||
|
||||
```bash
|
||||
# MUST pass before every commit
|
||||
pnpm lint
|
||||
pnpm type-check
|
||||
|
||||
# Production build check (catches additional issues)
|
||||
pnpm build
|
||||
|
||||
# Or combined
|
||||
pnpm lint && pnpm type-check && pnpm build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
### Test File Location
|
||||
|
||||
```
|
||||
src/
|
||||
__tests__/ # Integration tests
|
||||
api.test.ts
|
||||
app/
|
||||
feature/
|
||||
page.tsx
|
||||
page.test.tsx # Co-located test (when appropriate)
|
||||
```
|
||||
|
||||
### Test Structure (AAA Pattern)
|
||||
|
||||
```typescript
|
||||
describe('OrderService', () => {
|
||||
describe('createOrder', () => {
|
||||
it('should create an order with valid input', async () => {
|
||||
// Arrange
|
||||
const input = { items: [{ productId: '1', quantity: 2 }] };
|
||||
|
||||
// Act
|
||||
const result = await createOrder(input);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.order.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should reject empty order', async () => {
|
||||
// Arrange
|
||||
const input = { items: [] };
|
||||
|
||||
// Act
|
||||
const result = await createOrder(input);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Rule | Reason |
|
||||
| ------------------------------ | ------------------- |
|
||||
| No `!` assertions | Runtime errors |
|
||||
| No `any` type | Type safety |
|
||||
| No `@ts-expect-error` | Masks real issues |
|
||||
| No `console.log` | Use structured logs |
|
||||
| Lint + typecheck before commit | Consistent code |
|
||||
| Structured errors | Consistent handling |
|
||||
| Never swallow errors | Debuggability |
|
||||
| Remove dead code | Maintainability |
|
||||
173
.trellis/spec/shared/dependencies.md
Normal file
173
.trellis/spec/shared/dependencies.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Dependencies & Versions
|
||||
|
||||
> Adjust versions to your project. These represent a known-working combination as of the time of writing. Pin or widen ranges to match your stability requirements.
|
||||
|
||||
---
|
||||
|
||||
## Runtime Environment
|
||||
|
||||
| Dependency | Version | Description |
|
||||
|------------|---------|-------------|
|
||||
| Node.js | >=20 | JavaScript runtime |
|
||||
| pnpm | ^10.x | Package manager |
|
||||
|
||||
---
|
||||
|
||||
## Core Framework
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| next | ^15.x | React framework for production |
|
||||
| react | ^19.x | UI library |
|
||||
| react-dom | ^19.x | React DOM renderer |
|
||||
| typescript | ^5.x | TypeScript language |
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
### API Layer
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| hono | ^4.x | Lightweight web framework |
|
||||
| @orpc/server | ^1.x | oRPC server implementation |
|
||||
| @orpc/client | ^1.x | oRPC client |
|
||||
| @orpc/zod | ^1.x | oRPC Zod integration |
|
||||
| @orpc/openapi | ^1.x | OpenAPI schema generation |
|
||||
| zod | ^4.x | Schema validation |
|
||||
|
||||
### Database
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| drizzle-orm | ^0.44.x | TypeScript ORM |
|
||||
| drizzle-kit | ^0.31.x | Drizzle CLI tools |
|
||||
| drizzle-zod | ^0.8.x | Drizzle + Zod integration |
|
||||
| pg | ^8.x | PostgreSQL client |
|
||||
|
||||
### Authentication
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| better-auth | ^1.x | Authentication library |
|
||||
|
||||
### Caching & Queue
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| @upstash/redis | ^1.x | Redis client (Upstash) |
|
||||
| @upstash/qstash | ^2.x | Message queue |
|
||||
|
||||
---
|
||||
|
||||
## AI Integration
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| ai | ^5.x | Vercel AI SDK core |
|
||||
| @ai-sdk/react | ^2.x | AI SDK React hooks |
|
||||
| @ai-sdk/openai | ^2.x | OpenAI provider |
|
||||
| @ai-sdk/anthropic | ^2.x | Anthropic provider |
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
### UI Components
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| @radix-ui/* | latest | Headless UI primitives |
|
||||
| lucide-react | ^0.x | Icon library |
|
||||
| cmdk | ^1.x | Command palette |
|
||||
| sonner | ^2.x | Toast notifications |
|
||||
|
||||
### Styling
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| tailwindcss | ^4.x | Utility-first CSS (v4 config format) |
|
||||
| @tailwindcss/postcss | ^4.x | PostCSS plugin |
|
||||
| tailwind-merge | ^3.x | Tailwind class merging |
|
||||
| class-variance-authority | ^0.7.x | Variant management |
|
||||
| clsx | ^2.x | Class name utility |
|
||||
|
||||
### State Management
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| @tanstack/react-query | ^5.x | Data fetching & caching |
|
||||
| @orpc/tanstack-query | ^1.x | oRPC + React Query bridge |
|
||||
| nuqs | ^2.x | URL state management |
|
||||
| react-hook-form | ^7.x | Form state management |
|
||||
| @hookform/resolvers | ^5.x | Form validation resolvers |
|
||||
|
||||
### Internationalization
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| next-intl | ^4.x | Next.js i18n |
|
||||
|
||||
### Utilities
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| date-fns | ^4.x | Date utilities |
|
||||
| es-toolkit | ^1.x | Utility functions |
|
||||
| nanoid | ^5.x | ID generation |
|
||||
| p-limit | ^7.x | Concurrency control |
|
||||
|
||||
---
|
||||
|
||||
## Monitoring & Logging
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| @sentry/nextjs | ^10.x | Error tracking |
|
||||
|
||||
---
|
||||
|
||||
## Development Tools
|
||||
|
||||
### Build & Bundling
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| turbo | ^2.x | Monorepo build system |
|
||||
| tsx | ^4.x | TypeScript executor |
|
||||
|
||||
### Code Quality
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| @biomejs/biome | ^2.x | Linter & formatter |
|
||||
| husky | ^9.x | Git hooks |
|
||||
|
||||
### Testing
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| @playwright/test | ^1.x | E2E testing |
|
||||
|
||||
---
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **React 19**: Major version with breaking changes from React 18
|
||||
2. **Next.js 15**: App Router is the primary routing pattern
|
||||
3. **TailwindCSS 4**: Uses the new v4 configuration format (not `tailwind.config.js`)
|
||||
4. **Zod 4**: Latest version with improved TypeScript support
|
||||
5. **Monorepo**: Use `@your-app/*` for internal workspace package references
|
||||
|
||||
---
|
||||
|
||||
## Updating Dependencies
|
||||
|
||||
When updating dependencies:
|
||||
|
||||
1. Check compatibility with React 19 and Next.js 15
|
||||
2. Update pnpm overrides if changing React or Drizzle versions
|
||||
3. Run `pnpm install` from the root directory
|
||||
4. Run `pnpm type-check` to verify TypeScript compatibility
|
||||
5. Run `pnpm build` to ensure production build works
|
||||
66
.trellis/spec/shared/index.md
Normal file
66
.trellis/spec/shared/index.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Shared Development Guidelines
|
||||
|
||||
> These guidelines apply to all Next.js full-stack applications using this architecture.
|
||||
|
||||
---
|
||||
|
||||
## Documentation Files
|
||||
|
||||
| File | Description | When to Read |
|
||||
| -------------------------------------- | ------------------------------------ | ----------------------- |
|
||||
| [code-quality.md](./code-quality.md) | Code quality mandatory rules | Always |
|
||||
| [typescript.md](./typescript.md) | TypeScript best practices | Type-related decisions |
|
||||
| [dependencies.md](./dependencies.md) | Dependency versions and constraints | Adding/updating deps |
|
||||
|
||||
---
|
||||
|
||||
## Quick Navigation
|
||||
|
||||
| Task | File |
|
||||
| --------------------------- | -------------------------------------- |
|
||||
| Code quality rules | [code-quality.md](./code-quality.md) |
|
||||
| Type annotations | [typescript.md](./typescript.md) |
|
||||
| Dependency management | [dependencies.md](./dependencies.md) |
|
||||
|
||||
---
|
||||
|
||||
## Core Rules (MANDATORY)
|
||||
|
||||
| Rule | File |
|
||||
| ----------------------------------------- | -------------------------------------- |
|
||||
| No non-null assertions (`!`) | [code-quality.md](./code-quality.md) |
|
||||
| No `any` type | [code-quality.md](./code-quality.md) |
|
||||
| No `@ts-expect-error` / `@ts-ignore` | [code-quality.md](./code-quality.md) |
|
||||
| No `console.log` (use structured logging) | [code-quality.md](./code-quality.md) |
|
||||
| Zod-first type definitions | [typescript.md](./typescript.md) |
|
||||
| Import types from backend, never redefine | [typescript.md](./typescript.md) |
|
||||
| Standard response format (`success` + `reason`) | [typescript.md](./typescript.md) |
|
||||
|
||||
---
|
||||
|
||||
## Before Every Commit
|
||||
|
||||
- [ ] `pnpm lint` - 0 errors
|
||||
- [ ] `pnpm type-check` - 0 errors
|
||||
- [ ] `pnpm build` - production build succeeds
|
||||
- [ ] No `any` types in new code
|
||||
- [ ] No non-null assertions (`!`)
|
||||
- [ ] No `@ts-expect-error` or `@ts-ignore` comments
|
||||
- [ ] No `console.log` statements (use `logger`)
|
||||
- [ ] Tests pass (if applicable)
|
||||
|
||||
---
|
||||
|
||||
## Code Review Checklist
|
||||
|
||||
- [ ] Types are explicit, not `any`
|
||||
- [ ] API inputs/outputs have Zod schemas
|
||||
- [ ] Error handling returns structured responses
|
||||
- [ ] No duplicate type definitions (import from source of truth)
|
||||
- [ ] Naming follows conventions (files: kebab-case, components: PascalCase)
|
||||
- [ ] Unused imports and dead code removed
|
||||
- [ ] No swallowed errors (silent `catch` blocks)
|
||||
|
||||
---
|
||||
|
||||
**Language**: All documentation must be written in **English**.
|
||||
441
.trellis/spec/shared/typescript.md
Normal file
441
.trellis/spec/shared/typescript.md
Normal file
@@ -0,0 +1,441 @@
|
||||
# TypeScript Best Practices
|
||||
|
||||
> TypeScript guidelines for Next.js full-stack applications.
|
||||
|
||||
---
|
||||
|
||||
## Zod-First Type Definitions
|
||||
|
||||
Define Zod schemas first, then infer TypeScript types from them. Never define types manually when a Zod schema exists.
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
// 1. Define the schema (single source of truth)
|
||||
export const createUserInputSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
role: z.enum(['admin', 'member', 'viewer']),
|
||||
});
|
||||
|
||||
export const createUserOutputSchema = z.object({
|
||||
success: z.boolean(),
|
||||
reason: z.string(),
|
||||
user: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
// 2. Derive types from schemas
|
||||
export type CreateUserInput = z.infer<typeof createUserInputSchema>;
|
||||
export type CreateUserOutput = z.infer<typeof createUserOutputSchema>;
|
||||
|
||||
// BAD - Manual type that duplicates schema
|
||||
interface CreateUserInput {
|
||||
name: string;
|
||||
email: string;
|
||||
role: 'admin' | 'member' | 'viewer';
|
||||
}
|
||||
```
|
||||
|
||||
### Reusable Base Schemas
|
||||
|
||||
```typescript
|
||||
const paginationSchema = z.object({
|
||||
page: z.number().min(1).default(1),
|
||||
limit: z.number().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
const timestampSchema = z.object({
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
// Compose into larger schemas
|
||||
export const listOrdersInputSchema = paginationSchema.extend({
|
||||
status: orderStatusZodSchema.optional(),
|
||||
customerId: z.string().optional(),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Type Inference from API
|
||||
|
||||
Import types from the backend or infer them from the API client. Never redefine backend types on the frontend.
|
||||
|
||||
### Import from Backend Package
|
||||
|
||||
```typescript
|
||||
// GOOD - Import from the API package
|
||||
import type { User, Order } from '@your-app/api/modules/users/types';
|
||||
import type { OrderStatus } from '@your-app/api/modules/orders/types';
|
||||
|
||||
// BAD - Redefining types that exist in backend
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Infer from API Client
|
||||
|
||||
```typescript
|
||||
import { orpcClient } from '@/lib/orpc';
|
||||
|
||||
// Infer the response type from the API client
|
||||
type UsersResponse = Awaited<ReturnType<typeof orpcClient.users.list>>;
|
||||
|
||||
// Infer a single item type from array response
|
||||
type User = UsersResponse['items'][number];
|
||||
|
||||
// Infer input types
|
||||
type CreateUserInput = Parameters<typeof orpcClient.users.create>[0];
|
||||
```
|
||||
|
||||
### Type Inference in Hooks
|
||||
|
||||
```typescript
|
||||
// The return type is automatically inferred from oRPC
|
||||
export function useUsers() {
|
||||
return useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => orpcClient.users.list(),
|
||||
});
|
||||
}
|
||||
|
||||
// For complex transformations, use explicit inference
|
||||
type UserListData = Awaited<ReturnType<typeof orpcClient.users.list>>;
|
||||
|
||||
export function useFormattedUsers() {
|
||||
return useQuery({
|
||||
queryKey: ['users', 'formatted'],
|
||||
queryFn: async () => {
|
||||
const data = await orpcClient.users.list();
|
||||
return transformUsers(data);
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Discriminated Unions
|
||||
|
||||
Use discriminated unions for types that can be one of several shapes. Use strict equality (`=== true`) for narrowing.
|
||||
|
||||
### TypeScript Discriminated Union
|
||||
|
||||
```typescript
|
||||
type Result<T> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: string };
|
||||
|
||||
const result: Result<User> = doSomething();
|
||||
|
||||
// CORRECT: Use === true for narrowing
|
||||
if (result.success === true) {
|
||||
console.log(result.data); // TypeScript knows data exists
|
||||
} else {
|
||||
console.log(result.error); // TypeScript knows error exists
|
||||
}
|
||||
```
|
||||
|
||||
### Zod Discriminated Union
|
||||
|
||||
```typescript
|
||||
export const notificationSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('email'),
|
||||
recipient: z.string().email(),
|
||||
subject: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('sms'),
|
||||
phoneNumber: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('push'),
|
||||
deviceToken: z.string(),
|
||||
title: z.string(),
|
||||
body: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
type Notification = z.infer<typeof notificationSchema>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Generic Patterns
|
||||
|
||||
### Generic Result Type
|
||||
|
||||
```typescript
|
||||
type Result<T> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: string };
|
||||
|
||||
function createResult<T>(data: T): Result<T> {
|
||||
return { success: true, data };
|
||||
}
|
||||
```
|
||||
|
||||
### Generic Paginated Response
|
||||
|
||||
```typescript
|
||||
type PaginatedResponse<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
type UserListResponse = PaginatedResponse<User>;
|
||||
```
|
||||
|
||||
### Generic with Constraints
|
||||
|
||||
```typescript
|
||||
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
|
||||
return obj[key];
|
||||
}
|
||||
```
|
||||
|
||||
### Common Utility Types
|
||||
|
||||
```typescript
|
||||
// Extract array element type
|
||||
type ArrayElement<T> = T extends (infer E)[] ? E : never;
|
||||
|
||||
// Make specific properties optional
|
||||
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||
|
||||
// Make specific properties required
|
||||
type RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Standard Response Format
|
||||
|
||||
All API responses must include `success` and `reason` fields.
|
||||
|
||||
```typescript
|
||||
// Output schema pattern
|
||||
export const operationResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
reason: z.string(),
|
||||
data: z.unknown().optional(),
|
||||
});
|
||||
|
||||
// Success response
|
||||
return {
|
||||
success: true,
|
||||
reason: 'User created successfully',
|
||||
user: { id, name, email },
|
||||
};
|
||||
|
||||
// Error response
|
||||
return {
|
||||
success: false,
|
||||
reason: 'Email address is already in use',
|
||||
};
|
||||
```
|
||||
|
||||
### Batch Operation Response
|
||||
|
||||
```typescript
|
||||
export const batchOperationResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
total: z.number(),
|
||||
processed: z.number(),
|
||||
failed: z.number(),
|
||||
errors: z.array(z.object({
|
||||
itemId: z.string(),
|
||||
error: z.string(),
|
||||
})).optional(),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Forbidden Patterns
|
||||
|
||||
### No `any`
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
function process(data: any) { ... }
|
||||
|
||||
// GOOD
|
||||
function process(data: unknown) { ... }
|
||||
function process(data: ProcessInput) { ... }
|
||||
```
|
||||
|
||||
### No Non-null Assertion
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
const name = user!.name;
|
||||
const first = items[0]!;
|
||||
|
||||
// GOOD
|
||||
if (user) {
|
||||
const name = user.name;
|
||||
}
|
||||
|
||||
const first = items[0];
|
||||
if (!first) {
|
||||
return { success: false, reason: 'No items found' };
|
||||
}
|
||||
```
|
||||
|
||||
### No `@ts-expect-error` / `@ts-ignore`
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
// @ts-expect-error - customField exists at runtime
|
||||
const value = user.customField;
|
||||
|
||||
// GOOD - Update the type definition instead
|
||||
interface User {
|
||||
customField: string;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### No Type Assertions Without Validation
|
||||
|
||||
```typescript
|
||||
// BAD - Blind assertion
|
||||
const user = data as User;
|
||||
|
||||
// GOOD - Runtime validation with Zod
|
||||
const user = userSchema.parse(data);
|
||||
|
||||
// GOOD - Type guard
|
||||
function isUser(data: unknown): data is User {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'id' in data &&
|
||||
'email' in data
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Type Imports
|
||||
|
||||
Always use `import type` for type-only imports:
|
||||
|
||||
```typescript
|
||||
// GOOD
|
||||
import type { User, Project } from './types';
|
||||
import { createUser } from './procedures';
|
||||
|
||||
// Also acceptable
|
||||
import { type User, createUser } from './types';
|
||||
|
||||
// BAD
|
||||
import { User, createUser } from './types';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Explicit Return Types for Exports
|
||||
|
||||
Always annotate explicit return types on exported functions:
|
||||
|
||||
```typescript
|
||||
// BAD - Implicit return type
|
||||
export function getUser(id: string) {
|
||||
return db.query.users.findFirst({ where: eq(users.id, id) });
|
||||
}
|
||||
|
||||
// GOOD - Explicit return type
|
||||
export function getUser(id: string): Promise<User | undefined> {
|
||||
return db.query.users.findFirst({ where: eq(users.id, id) });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TypeScript Configuration
|
||||
|
||||
Ensure strict mode is enabled:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUncheckedIndexedAccess": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Drizzle Type Inference
|
||||
|
||||
```typescript
|
||||
// Infer types from Drizzle tables
|
||||
type User = typeof userTable.$inferSelect;
|
||||
type NewUser = typeof userTable.$inferInsert;
|
||||
|
||||
// Combine with Zod via drizzle-zod
|
||||
import { createSelectSchema, createInsertSchema } from 'drizzle-zod';
|
||||
const userSelectSchema = createSelectSchema(userTable);
|
||||
const userInsertSchema = createInsertSchema(userTable);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## View Model Types
|
||||
|
||||
When the frontend needs computed properties, extend backend types rather than redefining them:
|
||||
|
||||
```typescript
|
||||
import type { Order } from '@your-app/api/modules/orders/types';
|
||||
|
||||
export interface OrderViewModel extends Order {
|
||||
formattedTotal: string;
|
||||
statusLabel: string;
|
||||
isEditable: boolean;
|
||||
}
|
||||
|
||||
export function toOrderViewModel(order: Order): OrderViewModel {
|
||||
return {
|
||||
...order,
|
||||
formattedTotal: formatCurrency(order.total),
|
||||
statusLabel: getStatusLabel(order.status),
|
||||
isEditable: order.status === 'draft',
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Practice | Reason |
|
||||
| --------------------------- | ----------------------------- |
|
||||
| Zod-first types | Single source of truth |
|
||||
| Import, don't redefine | No type drift |
|
||||
| `=== true` for unions | Proper narrowing |
|
||||
| Generics for reuse | DRY, type-safe |
|
||||
| `success` + `reason` format | Consistent API responses |
|
||||
| No `any` | Type safety |
|
||||
| No `!` assertions | Runtime safety |
|
||||
| No `@ts-expect-error` | Masks real issues |
|
||||
| `import type` | Clear separation, tree-shake |
|
||||
| Explicit return types | Documentation, catch errors |
|
||||
139
.trellis/tasks/00-bootstrap-guidelines/prd.md
Normal file
139
.trellis/tasks/00-bootstrap-guidelines/prd.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# Bootstrap Task: Fill Project Development Guidelines
|
||||
|
||||
**You (the AI) are running this task. The developer does not read this file.**
|
||||
|
||||
The developer just ran `trellis init` on this project for the first time.
|
||||
`.trellis/` now exists with empty spec scaffolding, and this bootstrap task
|
||||
exists under `.trellis/tasks/`. When they want to work on it, they should start
|
||||
this task from a session that provides Trellis session identity.
|
||||
|
||||
**Your job**: help them populate `.trellis/spec/` with the team's real
|
||||
coding conventions. Every future AI session — this project's
|
||||
`trellis-implement` and `trellis-check` sub-agents — auto-loads spec files
|
||||
listed in per-task jsonl manifests. Empty spec = sub-agents write generic
|
||||
code. Real spec = sub-agents match the team's actual patterns.
|
||||
|
||||
Don't dump instructions. Open with a short greeting, figure out if the repo
|
||||
has any existing convention docs (CLAUDE.md, .cursorrules, etc.), and drive
|
||||
the rest conversationally.
|
||||
|
||||
---
|
||||
|
||||
## Status (update the checkboxes as you complete each item)
|
||||
|
||||
- [ ] Fill backend guidelines
|
||||
- [ ] Fill frontend guidelines
|
||||
- [ ] Add code examples
|
||||
|
||||
---
|
||||
|
||||
## Spec files to populate
|
||||
|
||||
|
||||
### Backend guidelines
|
||||
|
||||
| File | What to document |
|
||||
|------|------------------|
|
||||
| `.trellis/spec/backend/directory-structure.md` | Where different file types go (routes, services, utils) |
|
||||
| `.trellis/spec/backend/database-guidelines.md` | ORM, migrations, query patterns, naming conventions |
|
||||
| `.trellis/spec/backend/error-handling.md` | How errors are caught, logged, and returned |
|
||||
| `.trellis/spec/backend/logging-guidelines.md` | Log levels, format, what to log |
|
||||
| `.trellis/spec/backend/quality-guidelines.md` | Code review standards, testing requirements |
|
||||
|
||||
|
||||
### Frontend guidelines
|
||||
|
||||
| File | What to document |
|
||||
|------|------------------|
|
||||
| `.trellis/spec/frontend/directory-structure.md` | Component/page/hook organization |
|
||||
| `.trellis/spec/frontend/component-guidelines.md` | Component patterns, props conventions |
|
||||
| `.trellis/spec/frontend/hook-guidelines.md` | Custom hook naming, patterns |
|
||||
| `.trellis/spec/frontend/state-management.md` | State library, patterns, what goes where |
|
||||
| `.trellis/spec/frontend/type-safety.md` | TypeScript conventions, type organization |
|
||||
| `.trellis/spec/frontend/quality-guidelines.md` | Linting, testing, accessibility |
|
||||
|
||||
|
||||
### Thinking guides (already populated)
|
||||
|
||||
`.trellis/spec/guides/` contains general thinking guides pre-filled with
|
||||
best practices. Customize only if something clearly doesn't fit this project.
|
||||
|
||||
---
|
||||
|
||||
## How to fill the spec
|
||||
|
||||
### Step 1: Import from existing convention files first (preferred)
|
||||
|
||||
Search the repo for existing convention docs. If any exist, read them and
|
||||
extract the relevant rules into the matching `.trellis/spec/` files —
|
||||
usually much faster than documenting from scratch.
|
||||
|
||||
| File / Directory | Tool |
|
||||
|------|------|
|
||||
| `CLAUDE.md` / `CLAUDE.local.md` | Claude Code |
|
||||
| `AGENTS.md` | Codex / Claude Code / agent-compatible tools |
|
||||
| `.cursorrules` | Cursor |
|
||||
| `.cursor/rules/*.mdc` | Cursor (rules directory) |
|
||||
| `.windsurfrules` | Windsurf |
|
||||
| `.clinerules` | Cline |
|
||||
| `.roomodes` | Roo Code |
|
||||
| `.github/copilot-instructions.md` | GitHub Copilot |
|
||||
| `.vscode/settings.json` → `github.copilot.chat.codeGeneration.instructions` | VS Code Copilot |
|
||||
| `CONVENTIONS.md` / `.aider.conf.yml` | aider |
|
||||
| `CONTRIBUTING.md` | General project conventions |
|
||||
| `.editorconfig` | Editor formatting rules |
|
||||
|
||||
### Step 2: Analyze the codebase for anything not covered by existing docs
|
||||
|
||||
Scan real code to discover patterns. Before writing each spec file:
|
||||
- Find 2-3 real examples of each pattern in the codebase.
|
||||
- Reference real file paths (not hypothetical ones).
|
||||
- Document anti-patterns the team clearly avoids.
|
||||
|
||||
### Step 3: Document reality, not ideals
|
||||
|
||||
**Critical**: write what the code *actually does*, not what it should do.
|
||||
Sub-agents match the spec, so aspirational patterns that don't exist in the
|
||||
codebase will cause sub-agents to write code that looks out of place.
|
||||
|
||||
If the team has known tech debt, document the current state — improvement
|
||||
is a separate conversation, not a bootstrap concern.
|
||||
|
||||
---
|
||||
|
||||
## Quick explainer of the runtime (share when they ask "why do we need spec at all")
|
||||
|
||||
- Every AI coding task spawns two sub-agents: `trellis-implement` (writes
|
||||
code) and `trellis-check` (verifies quality).
|
||||
- Each task has `implement.jsonl` / `check.jsonl` manifests listing which
|
||||
spec files to load.
|
||||
- The platform hook auto-injects those spec files + the task's `prd.md`
|
||||
into every sub-agent prompt, so the sub-agent codes/reviews per team
|
||||
conventions without anyone pasting them manually.
|
||||
- Source of truth: `.trellis/spec/`. That's why filling it well now pays
|
||||
off forever.
|
||||
|
||||
---
|
||||
|
||||
## Completion
|
||||
|
||||
When the developer confirms the checklist items above are done with real
|
||||
examples (not placeholders), guide them to run:
|
||||
|
||||
```bash
|
||||
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?"
|
||||
29
.trellis/tasks/00-bootstrap-guidelines/task.json
Normal file
29
.trellis/tasks/00-bootstrap-guidelines/task.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"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": "TalexDreamSoul",
|
||||
"assignee": "TalexDreamSoul",
|
||||
"createdAt": "2026-07-01",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": null,
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [
|
||||
".trellis/spec/backend/",
|
||||
".trellis/spec/frontend/"
|
||||
],
|
||||
"notes": "First-time setup task created by trellis init (fullstack project)",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"_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."}
|
||||
113
.trellis/tasks/07-01-nextjs-fullstack-crud-rbac-audit/design.md
Normal file
113
.trellis/tasks/07-01-nextjs-fullstack-crud-rbac-audit/design.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Design
|
||||
|
||||
## Architecture
|
||||
|
||||
This task keeps the app in a single Next.js project and adds a small server-side domain layer inside `modules/`:
|
||||
|
||||
- `modules/core/server/`: server-only persistence, session, permissions, and audit helpers.
|
||||
- `modules/auth/`: UI and client interactions for setup, login, logout, and session state.
|
||||
- `modules/elders/`: elder schemas/types, server actions or API client helpers, and UI components.
|
||||
- `modules/settings/`: role/account/audit display components.
|
||||
- `app/api/.../route.ts`: Route Handlers for auth, session, elders, accounts, and audit APIs.
|
||||
|
||||
The persistence boundary should be centralized behind helper functions that read/write JSON files. UI and route handlers must not know file paths directly.
|
||||
|
||||
## Data Storage
|
||||
|
||||
Use a JSON file store under `.data/teatea.json` by default. The file should contain:
|
||||
|
||||
```ts
|
||||
type AppData = {
|
||||
accounts: Account[];
|
||||
sessions: Session[];
|
||||
elders: Elder[];
|
||||
auditLogs: AuditLog[];
|
||||
};
|
||||
```
|
||||
|
||||
The store module owns initialization, read, write, and update operations. Updates should read the current snapshot, apply a synchronous mutation callback, and write the full file back. This is enough for the MVP and gives a clear future replacement boundary for Drizzle/Postgres.
|
||||
|
||||
## API Contracts
|
||||
|
||||
Route Handlers return a consistent response shape:
|
||||
|
||||
```ts
|
||||
type ApiResult<T> =
|
||||
| ({ success: true; reason: string } & T)
|
||||
| { success: false; reason: string };
|
||||
```
|
||||
|
||||
Planned endpoints:
|
||||
|
||||
- `GET /api/auth/bootstrap`: returns whether setup is required.
|
||||
- `POST /api/auth/setup`: creates the first admin account, creates a session, logs setup.
|
||||
- `POST /api/auth/login`: validates credentials, creates a session, logs login.
|
||||
- `POST /api/auth/logout`: deletes current session cookie/session, logs logout.
|
||||
- `GET /api/auth/session`: returns current account and permissions.
|
||||
- `GET /api/elders`: lists elder profiles.
|
||||
- `POST /api/elders`: creates an elder profile.
|
||||
- `PATCH /api/elders/[id]`: updates an elder profile.
|
||||
- `DELETE /api/elders/[id]`: deletes an elder profile.
|
||||
- `GET /api/settings/accounts`: lists accounts for authorized roles.
|
||||
- `GET /api/settings/roles`: lists built-in role definitions.
|
||||
- `GET /api/audit-logs`: lists recent audit events.
|
||||
|
||||
## Authentication
|
||||
|
||||
Sessions use an HTTP-only cookie. Route Handlers read and write cookies with `await cookies()` from `next/headers`, matching current Next.js behavior.
|
||||
|
||||
Passwords are never stored in plain text. For the MVP, use Node built-in crypto with a per-account salt and `scryptSync` or `scrypt` for password hashing. This avoids adding a dependency while keeping the current local milestone meaningfully better than browser-only auth.
|
||||
|
||||
## Permissions
|
||||
|
||||
Define built-in role permissions in one server/shared constants module:
|
||||
|
||||
- `account:read`
|
||||
- `account:manage`
|
||||
- `audit:read`
|
||||
- `elder:read`
|
||||
- `elder:create`
|
||||
- `elder:update`
|
||||
- `elder:delete`
|
||||
|
||||
Route Handlers call a shared `requirePermission(permission)` helper. This helper returns an authenticated context or a structured forbidden/unauthorized result and writes denied audit entries.
|
||||
|
||||
## Audit Logging
|
||||
|
||||
Audit logging is implemented as a server helper that appends immutable records to the store:
|
||||
|
||||
- timestamp
|
||||
- actor account ID/email if known
|
||||
- action
|
||||
- target type
|
||||
- target ID
|
||||
- result: `success` or `denied` or `failure`
|
||||
- reason
|
||||
|
||||
Audit writes are part of the route handler flow. The MVP accepts best-effort logging for logout when a session has already expired.
|
||||
|
||||
## UI Flow
|
||||
|
||||
Protected app pages should use server session state where practical instead of a client-only `AuthGate`. The app layout can fetch session data and redirect unauthenticated users before rendering protected content.
|
||||
|
||||
The elder page becomes a real data view:
|
||||
|
||||
- Server Component loads initial elders.
|
||||
- Client component handles create/edit/delete forms and refreshes after mutations.
|
||||
- Controls are disabled or hidden based on current permissions.
|
||||
|
||||
The settings page becomes a server-loaded administrative view:
|
||||
|
||||
- Role definitions table.
|
||||
- Accounts table.
|
||||
- Recent audit log table.
|
||||
|
||||
## Compatibility
|
||||
|
||||
Static module pages not in scope remain untouched except for auth/layout integration. Existing visual design should be preserved: dense operational screens, restrained cards/tables, and Tailwind/Radix-compatible UI components.
|
||||
|
||||
## Risks and Rollback
|
||||
|
||||
- File persistence is not safe for high-concurrency production writes. This is acceptable for MVP and documented as a migration boundary.
|
||||
- Replacing `AuthGate` with server-side auth can affect all `/app` routes. Rollback point: keep the old component until server session redirect is working.
|
||||
- Password hashing must use Node runtime APIs, so affected Route Handlers should run in the Node runtime if needed.
|
||||
@@ -0,0 +1 @@
|
||||
{"_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."}
|
||||
@@ -0,0 +1,89 @@
|
||||
# Implementation Plan
|
||||
|
||||
## Checklist
|
||||
|
||||
1. Add core server modules:
|
||||
- JSON store read/write helpers.
|
||||
- Account/session/password helpers.
|
||||
- Role and permission definitions.
|
||||
- Audit logging helper.
|
||||
- Shared API response helpers.
|
||||
|
||||
2. Add auth APIs:
|
||||
- `GET /api/auth/bootstrap`
|
||||
- `POST /api/auth/setup`
|
||||
- `POST /api/auth/login`
|
||||
- `POST /api/auth/logout`
|
||||
- `GET /api/auth/session`
|
||||
|
||||
3. Migrate auth UI:
|
||||
- Update `AuthPanel` to call server APIs.
|
||||
- Update `SignOutButton` to call logout API.
|
||||
- Replace or bypass localStorage-only `AuthGate` with server session protection in the app layout.
|
||||
- Keep unauthenticated redirects and setup redirects working.
|
||||
|
||||
4. Add elder APIs and UI:
|
||||
- Create elder types and input validators.
|
||||
- Implement list/create/update/delete Route Handlers.
|
||||
- Replace `app/(app)/app/elders/page.tsx` static content with server-loaded CRUD UI.
|
||||
|
||||
5. Add settings/audit UI:
|
||||
- Implement settings/account, role, and audit APIs.
|
||||
- Replace `app/(app)/app/settings/page.tsx` static content with server-loaded tables.
|
||||
|
||||
6. Wire audit events:
|
||||
- Account setup/create.
|
||||
- Login/logout.
|
||||
- Elder create/update/delete.
|
||||
- Denied permission checks.
|
||||
|
||||
7. Verification:
|
||||
- Run `pnpm lint`.
|
||||
- Run `pnpm type-check`.
|
||||
- Run `pnpm build`.
|
||||
- Start dev server and manually exercise setup/login/CRUD/settings if build passes.
|
||||
|
||||
## Files Expected to Change
|
||||
|
||||
- `modules/auth/components/AuthPanel.tsx`
|
||||
- `modules/auth/components/AuthGate.tsx`
|
||||
- `modules/auth/components/SignOutButton.tsx`
|
||||
- `app/(app)/app/layout.tsx`
|
||||
- `app/(app)/app/elders/page.tsx`
|
||||
- `app/(app)/app/settings/page.tsx`
|
||||
|
||||
## Files Expected to Be Created
|
||||
|
||||
- `modules/core/server/store.ts`
|
||||
- `modules/core/server/auth.ts`
|
||||
- `modules/core/server/permissions.ts`
|
||||
- `modules/core/server/audit.ts`
|
||||
- `modules/core/server/api.ts`
|
||||
- `modules/elders/types.ts`
|
||||
- `modules/elders/components/EldersClient.tsx`
|
||||
- `modules/settings/components/SettingsOverview.tsx`
|
||||
- `app/api/auth/bootstrap/route.ts`
|
||||
- `app/api/auth/setup/route.ts`
|
||||
- `app/api/auth/login/route.ts`
|
||||
- `app/api/auth/logout/route.ts`
|
||||
- `app/api/auth/session/route.ts`
|
||||
- `app/api/elders/route.ts`
|
||||
- `app/api/elders/[id]/route.ts`
|
||||
- `app/api/settings/accounts/route.ts`
|
||||
- `app/api/settings/roles/route.ts`
|
||||
- `app/api/audit-logs/route.ts`
|
||||
|
||||
## Validation Commands
|
||||
|
||||
```bash
|
||||
pnpm lint
|
||||
pnpm type-check
|
||||
pnpm build
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Rollback Points
|
||||
|
||||
- If server auth blocks all app routes, revert only layout/AuthGate changes while keeping APIs.
|
||||
- If elder CRUD UI is unstable, keep APIs and temporarily render a read-only server table.
|
||||
- If file store causes build/runtime issues, move the data directory to `/tmp` behind the same store API for verification, then restore `.data` once filesystem assumptions are fixed.
|
||||
104
.trellis/tasks/07-01-nextjs-fullstack-crud-rbac-audit/prd.md
Normal file
104
.trellis/tasks/07-01-nextjs-fullstack-crud-rbac-audit/prd.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Next.js Full-Stack CRUD, RBAC, and Audit Logs
|
||||
|
||||
## Goal
|
||||
|
||||
Turn the current static/local-storage Next.js app into a minimal real full-stack application for the first operational slice: account setup/login, built-in RBAC, elder profile CRUD, and auditable administrative actions.
|
||||
|
||||
The first release should remove mock/local-only behavior from the core flow and persist data through server-side APIs so the app can be exercised end-to-end without browser localStorage state.
|
||||
|
||||
## Confirmed Facts
|
||||
|
||||
- The repository is already a Next.js 15 App Router project with routes under `app/`.
|
||||
- Protected app screens are currently guarded by `modules/auth/components/AuthGate.tsx`, which reads localStorage through `modules/auth/lib/local-auth.ts`.
|
||||
- Login/register/setup UI exists in `modules/auth/components/AuthPanel.tsx`, but password input is not validated server-side and accounts are browser-local.
|
||||
- The "老人档案" route at `app/(app)/app/elders/page.tsx` is currently a static `ModulePage`.
|
||||
- The "权限设置" route at `app/(app)/app/settings/page.tsx` is currently a static `ModulePage`.
|
||||
- The project has no installed database/auth dependencies such as Drizzle, PostgreSQL client, oRPC, Zod, or better-auth.
|
||||
- Current `package.json` already supports `pnpm lint`, `pnpm type-check`, and `pnpm build`.
|
||||
|
||||
## MVP Scope
|
||||
|
||||
- Implement a real server-side persistence layer using JSON files under a server-owned data directory for this milestone. This is not mock data: API mutations must write durable data on disk during local/runtime execution.
|
||||
- Use Next.js Route Handlers for the first API surface instead of adding oRPC/Drizzle/better-auth in this milestone.
|
||||
- Replace localStorage authentication with server-side account/session APIs and HTTP-only cookie sessions.
|
||||
- Provide built-in roles and permission groups without user-defined custom role creation in this milestone.
|
||||
- Implement full CRUD for elder profiles as the first business entity.
|
||||
- Implement a permissions/settings page that shows accounts, roles, permission coverage, and audit logs from server data.
|
||||
- Record audit log entries for login, logout, account creation, elder create/update/delete, and permission-sensitive denied actions.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Authentication
|
||||
|
||||
- Setup must create the first administrator account on the server.
|
||||
- Login must validate account credentials on the server and set an HTTP-only session cookie.
|
||||
- Logout must clear the session cookie and record an audit event when possible.
|
||||
- App routes must no longer depend on localStorage for authentication.
|
||||
|
||||
### Roles and Permissions
|
||||
|
||||
- The system must include built-in roles:
|
||||
- `admin`: full access to accounts, permissions, audit logs, and elder CRUD.
|
||||
- `manager`: elder CRUD and audit log viewing, but no account/role administration.
|
||||
- `caregiver`: elder read/update access for care-facing fields, no delete or account administration.
|
||||
- `viewer`: read-only elder access.
|
||||
- Permission checks must run on the server for all protected APIs.
|
||||
- UI navigation or controls may hide unavailable actions, but hidden UI must not be the only enforcement.
|
||||
|
||||
### Elder CRUD
|
||||
|
||||
- Users with permission can list, create, update, and delete elder profiles.
|
||||
- Elder records must include at minimum: name, gender, birth date or age, care level, room/bed, status, primary contact, phone, medical notes, created/updated timestamps.
|
||||
- The elder page must show real server data and support create/edit/delete interactions.
|
||||
- Invalid input must return structured API errors and show usable feedback in the UI.
|
||||
|
||||
### Audit Logs
|
||||
|
||||
- Audit logs must be persisted server-side and visible in the settings page.
|
||||
- Each audit record must include timestamp, actor account ID/email when available, action, target type, target ID when available, result, and human-readable reason.
|
||||
- Failed permission checks must be logged without exposing sensitive internals to the client.
|
||||
|
||||
### Data and API
|
||||
|
||||
- API responses must use a consistent `{ success, reason, ... }` shape.
|
||||
- Server-side data helpers must avoid browser APIs.
|
||||
- All file persistence operations must be centralized so future Drizzle/Postgres migration has a single boundary to replace.
|
||||
- Do not use hard-coded UI counters for the implemented CRUD/settings/audit areas once server data exists.
|
||||
|
||||
### Quality
|
||||
|
||||
- Keep TypeScript strict without `any`, non-null assertions, or `@ts-ignore`/`@ts-expect-error`.
|
||||
- Prefer Server Components for initial data loading and small Client Components only for forms/mutations.
|
||||
- `pnpm lint`, `pnpm type-check`, and `pnpm build` should pass before marking implementation complete.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] First-run setup creates a server-persisted admin account and redirects into the app.
|
||||
- [ ] Login/logout uses server APIs and an HTTP-only cookie session, not localStorage.
|
||||
- [ ] Visiting `/app/elders` while authenticated loads elder records from the server persistence layer.
|
||||
- [ ] An authorized user can create, edit, and delete elder records from the UI, and changes survive page reloads.
|
||||
- [ ] Unauthorized role attempts against protected elder/account/audit APIs return a forbidden response and create audit log entries.
|
||||
- [ ] `/app/settings` displays built-in roles, account list, and recent audit log entries from server data.
|
||||
- [ ] Audit logs are written for account creation, login, logout, elder create/update/delete, and denied permission checks.
|
||||
- [ ] Existing static module pages outside the MVP continue to render.
|
||||
- [ ] `pnpm lint` passes.
|
||||
- [ ] `pnpm type-check` passes.
|
||||
- [ ] `pnpm build` passes.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- PostgreSQL/Drizzle migration.
|
||||
- oRPC adoption.
|
||||
- better-auth adoption.
|
||||
- Password reset, email verification, OAuth, multi-factor authentication, or account invitations.
|
||||
- Custom role builder UI.
|
||||
- CRUD implementation for every module in the sidebar.
|
||||
- Production-grade password hashing beyond Node built-in cryptographic hashing suitable for this local MVP.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None blocking. The MVP assumes "basic CRUD" means the first core business entity, elder profiles, plus real account/session/audit support.
|
||||
|
||||
## Notes
|
||||
|
||||
- Next.js documentation confirms App Router Route Handlers support `GET`, `POST`, `PATCH`, and `DELETE`, `Response.json`, `request.json()`, and async `cookies()` from `next/headers` for cookie reads/writes in current versions.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "nextjs-fullstack-crud-rbac-audit",
|
||||
"name": "nextjs-fullstack-crud-rbac-audit",
|
||||
"title": "Next.js Full-Stack CRUD, RBAC, and Audit Logs",
|
||||
"description": "",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "TalexDreamSoul",
|
||||
"assignee": "TalexDreamSoul",
|
||||
"createdAt": "2026-07-01",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
708
.trellis/workflow.md
Normal file
708
.trellis/workflow.md
Normal file
@@ -0,0 +1,708 @@
|
||||
# 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, ZCode, Reasonix (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/Gemini/Qoder/Copilot/ZCode/Reasonix/Trae 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, ZCode, Reasonix, Trae]
|
||||
|
||||
- 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, ZCode, Reasonix, Trae]
|
||||
|
||||
[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, ZCode, Reasonix, Trae]
|
||||
|
||||
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, ZCode, Reasonix, Trae]
|
||||
|
||||
[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, ZCode, Reasonix, Trae]
|
||||
|
||||
Curate `implement.jsonl` and `check.jsonl` so the Phase 2 sub-agents get the right spec/research context. These files were seeded on `task create` with a single self-describing `_example` line; your job here is to fill in real entries.
|
||||
|
||||
**Location**: `{TASK_DIR}/implement.jsonl` and `{TASK_DIR}/check.jsonl` (already exist).
|
||||
|
||||
**Format**: one JSON object per line — `{"file": "<path>", "reason": "<why>"}`. Paths are repo-root relative.
|
||||
|
||||
**What to put in**:
|
||||
- **Spec files** — `.trellis/spec/<package>/<layer>/index.md` and any specific guideline files (`error-handling.md`, `conventions.md`, etc.) relevant to this task
|
||||
- **Research files** — `{TASK_DIR}/research/*.md` that the sub-agent will need to consult
|
||||
|
||||
**What NOT to put in**:
|
||||
- Code files (`src/**`, `packages/**/*.ts`, etc.) — those are read by the sub-agent during implementation, not pre-registered here
|
||||
- Files you're about to modify — same reason
|
||||
|
||||
**Split between the two files**:
|
||||
- `implement.jsonl` → specs + research the implement sub-agent needs to write code correctly
|
||||
- `check.jsonl` → specs for the check sub-agent (quality guidelines, check conventions, same research if needed)
|
||||
|
||||
These manifests do not replace `implement.md`. `implement.md` is the human-readable execution plan for a complex task; jsonl files only list context files to inject or load.
|
||||
|
||||
**How to discover relevant specs**:
|
||||
|
||||
```bash
|
||||
python3 ./.trellis/scripts/get_context.py --mode packages
|
||||
```
|
||||
|
||||
Lists every package + its spec layers with paths. Pick the entries that match this task's domain.
|
||||
|
||||
**How to append entries**:
|
||||
|
||||
Either edit the jsonl file directly in your editor, or use:
|
||||
|
||||
```bash
|
||||
python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" implement "<path>" "<reason>"
|
||||
python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" check "<path>" "<reason>"
|
||||
```
|
||||
|
||||
Delete the seed `_example` line once real entries exist (optional — it's skipped automatically by consumers).
|
||||
|
||||
Ready gate: both `implement.jsonl` and `check.jsonl` must contain at least one real `{"file": "...", "reason": "..."}` entry before `task.py start`. The seed `_example` row alone is not ready.
|
||||
|
||||
Skip this step only when both files already have real curated entries.
|
||||
|
||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
|
||||
|
||||
[codex-inline, Kilo, Antigravity, Devin]
|
||||
|
||||
Skip this step. Context is loaded directly by the `trellis-before-dev` skill in Phase 2.
|
||||
|
||||
[/codex-inline, Kilo, Antigravity, Devin]
|
||||
|
||||
#### 1.4 Activate task `[required · once]`
|
||||
|
||||
After artifact review, flip the task status to `in_progress`:
|
||||
|
||||
```bash
|
||||
python3 ./.trellis/scripts/task.py start <task-dir>
|
||||
```
|
||||
|
||||
For lightweight tasks, `prd.md` can be enough. For complex tasks, `prd.md`, `design.md`, and `implement.md` must exist and be reviewed before start. On sub-agent-dispatch platforms, `implement.jsonl` and `check.jsonl` must both have real curated entries before start. Runtime consumers tolerate missing or seed-only manifests for compatibility, but that tolerance is not a planning-ready state.
|
||||
|
||||
After this command succeeds, the breadcrumb auto-switches to `[workflow-state:in_progress]`, and the rest of Phase 2 / 3 follows.
|
||||
|
||||
If `task.py start` errors with a session-identity message (no context key from hook input, `TRELLIS_CONTEXT_ID`, or platform-native session env), follow the hint in the error to set up session identity, then retry.
|
||||
|
||||
#### 1.5 Completion criteria
|
||||
|
||||
| Condition | Required |
|
||||
|------|:---:|
|
||||
| `prd.md` exists | ✅ |
|
||||
| User confirms task should enter implementation | ✅ |
|
||||
| `task.py start` has been run (status = in_progress) | ✅ |
|
||||
| `research/` has artifacts (complex tasks) | recommended |
|
||||
| `design.md` exists (complex tasks) | ✅ |
|
||||
| `implement.md` exists (complex tasks) | ✅ |
|
||||
|
||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
|
||||
|
||||
| `implement.jsonl` and `check.jsonl` each contain at least one real curated entry (seed row does not count) | ✅ |
|
||||
|
||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Execute
|
||||
|
||||
Goal: turn reviewed planning artifacts into code that passes quality checks.
|
||||
|
||||
#### 2.1 Implement `[required · repeatable]`
|
||||
|
||||
[Claude Code, Cursor, OpenCode, CodeBuddy, 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, CodeBuddy, Droid, Pi]
|
||||
|
||||
[codex-sub-agent, Gemini, Qoder, Copilot, ZCode, Reasonix, Trae]
|
||||
|
||||
Spawn the implement sub-agent:
|
||||
|
||||
- **Agent type**: `trellis-implement`
|
||||
- **Task description**: Implement the reviewed task artifacts, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check
|
||||
- **Dispatch prompt guard**: The prompt MUST start with `Active task: <task path>`, then explicitly say the spawned agent is already `trellis-implement` and must implement directly without spawning another `trellis-implement` / `trellis-check`.
|
||||
|
||||
The pull-based sub-agent definition auto-handles the context load requirement:
|
||||
- Resolves the active task with `task.py current --source`, then reads `prd.md`, `design.md` if present, and `implement.md` if present
|
||||
- Reads `implement.jsonl` and requires the agent to load each referenced spec/research file before coding
|
||||
|
||||
[/codex-sub-agent, Gemini, Qoder, Copilot, ZCode, Reasonix, Trae]
|
||||
|
||||
[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, ZCode, Reasonix, Trae]
|
||||
|
||||
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, ZCode, Reasonix, Trae]
|
||||
|
||||
[codex-inline, Kilo, Antigravity, Devin]
|
||||
|
||||
Load the `trellis-check` skill and verify the code per its guidance:
|
||||
- Spec compliance
|
||||
- lint / type-check / tests
|
||||
- Cross-layer consistency (when changes span layers)
|
||||
|
||||
If issues are found → fix → re-check, until green.
|
||||
|
||||
[/codex-inline, Kilo, Antigravity, Devin]
|
||||
|
||||
**Final pass (before Phase 3.4 commit)**: the last 2.2 of a task must run full-scope, not just on the latest implement chunk. List all affected packages with `python3 ./.trellis/scripts/get_context.py --mode packages`, then load each package's spec index Quality Check section. This catches cross-layer / multi-package issues a mid-iteration local 2.2 cannot.
|
||||
|
||||
#### 2.3 Rollback `[on demand]`
|
||||
|
||||
- `check` reveals a prd defect → return to Phase 1, fix `prd.md`, then redo 2.1
|
||||
- Implementation went wrong → revert code, redo 2.1
|
||||
- Need more research → research (same as Phase 1.2), write findings into `research/`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Finish
|
||||
|
||||
Goal: ensure code quality, capture lessons, record the work.
|
||||
|
||||
#### 3.2 Debug retrospective `[on demand]`
|
||||
|
||||
If this task involved repeated debugging (the same issue was fixed multiple times), load the `trellis-break-loop` skill to:
|
||||
- Classify the root cause
|
||||
- Explain why earlier fixes failed
|
||||
- Propose prevention
|
||||
|
||||
The goal is to capture debugging lessons so the same class of issue doesn't recur.
|
||||
|
||||
#### 3.3 Spec update `[required · once]`
|
||||
|
||||
Load the `trellis-update-spec` skill and review whether this task produced new knowledge worth recording:
|
||||
- Newly discovered patterns or conventions
|
||||
- Pitfalls you hit
|
||||
- New technical decisions
|
||||
|
||||
Update the docs under `.trellis/spec/` accordingly. Even if the conclusion is "nothing to update", walk through the judgment.
|
||||
|
||||
#### 3.4 Commit changes `[required · once]`
|
||||
|
||||
**Spec-sync preamble**: before drafting commits, ask: did this task fix a bug or surface non-obvious knowledge that should land in `.trellis/spec/` so future-you (or future-AI) doesn't repeat the mistake? If yes, return to Phase 3.3 first — spec writes belong in the same task's commit batch, not as a forgotten follow-up.
|
||||
|
||||
The AI drives a batched commit of this task's code changes so `/finish-work` can run cleanly afterwards. Goal: produce work commits FIRST, then bookkeeping (archive + journal) commits land after — never interleaved.
|
||||
|
||||
**Step-by-step**:
|
||||
|
||||
1. **Inspect dirty state**:
|
||||
```bash
|
||||
git status --porcelain
|
||||
```
|
||||
Snapshot every dirty path. If the working tree is clean, skip to 3.5.
|
||||
|
||||
2. **Learn commit style** from recent history (so drafted messages blend in):
|
||||
```bash
|
||||
git log --oneline -5
|
||||
```
|
||||
Note the prefix convention (`feat:` / `fix:` / `chore:` / `docs:` ...), language (中文/English), and length style.
|
||||
|
||||
3. **Classify dirty files into two groups**:
|
||||
- **AI-edited this session** — files you wrote/edited via Edit/Write/Bash tool calls in this session. You know what changed and why.
|
||||
- **Unrecognized** — dirty files you did NOT touch this session (could be the user's manual edits, leftover WIP from a previous session, or unrelated work). Do NOT silently include these.
|
||||
|
||||
4. **Draft a commit plan**. Group AI-edited files into logical commits (1 commit per coherent change unit, not 1 commit per file). Each entry: `<commit message>` + file list. List unrecognized files separately at the bottom.
|
||||
|
||||
5. **Present the plan once, ask for one-shot confirmation**. Format:
|
||||
```
|
||||
Proposed commits (in order):
|
||||
1. <message>
|
||||
- <file>
|
||||
- <file>
|
||||
2. <message>
|
||||
- <file>
|
||||
|
||||
Unrecognized dirty files (NOT in any commit — confirm include/exclude):
|
||||
- <file>
|
||||
- <file>
|
||||
|
||||
Reply 'ok' / '行' to execute. Reply with edits, or '我自己来' / 'manual' to abort.
|
||||
```
|
||||
|
||||
6. **On confirmation**: run `git add <files>` + `git commit -m "<msg>"` for each batch in order. Do not amend. Do not push.
|
||||
|
||||
7. **On rejection** (user replies "不行" / "我自己来" / "manual" / any pushback on the plan): stop. Do not attempt a second plan. The user will commit by hand; you skip ahead to 3.5 once they confirm.
|
||||
|
||||
**Rules**:
|
||||
- No `git commit --amend` anywhere — three-stage three-commit flow (work commits → archive commit → journal commit).
|
||||
- Never push to remote in this step.
|
||||
- If the user wants different message wording but accepts the file grouping, edit the message and re-confirm once — but if they reject the grouping, exit to manual mode.
|
||||
- The batched plan is one prompt; do not prompt per commit.
|
||||
|
||||
#### 3.5 Wrap-up reminder
|
||||
|
||||
After the above, remind the user they can run `/finish-work` to wrap up (archive the task, record the session).
|
||||
|
||||
---
|
||||
|
||||
## Customizing Trellis (for forks)
|
||||
|
||||
This section is for developers who want to modify the Trellis workflow itself. All customization is done by editing this file; the scripts are parsers only.
|
||||
|
||||
### Changing what a step means
|
||||
|
||||
Edit the corresponding step's walkthrough body in the Phase 1 / 2 / 3 sections above. Critical invariants:
|
||||
- No active task must triage first and ask for task-creation consent before creating a Trellis task.
|
||||
- Planning must distinguish lightweight PRD-only tasks from complex tasks that require `prd.md`, `design.md`, and `implement.md` before start.
|
||||
- Every required execution path must keep the Phase 3.4 commit reminder reachable before `/trellis:finish-work`.
|
||||
|
||||
All tag blocks live in the `## Phase Index` section above, immediately after each phase summary:
|
||||
|
||||
| Scope | Corresponding tag |
|
||||
|---|---|
|
||||
| No active task (before Phase 1) | `[workflow-state:no_task]` (after the Phase Index ASCII art) |
|
||||
| All of Phase 1 (task created → ready for implementation) | `[workflow-state:planning]` (after Phase 1 summary) |
|
||||
| Codex inline Phase 1 | `[workflow-state:planning-inline]` |
|
||||
| Phase 2 + Phase 3.2–3.4 (implementation + check + wrap-up) | `[workflow-state:in_progress]` (after Phase 2 summary) |
|
||||
| Codex inline Phase 2 + Phase 3.2–3.4 | `[workflow-state:in_progress-inline]` |
|
||||
| After Phase 3.5 (archived) | `[workflow-state:completed]` (after Phase 3 summary; **currently DEAD**) |
|
||||
|
||||
### Changing the per-turn prompt text
|
||||
|
||||
Directly edit the body of the corresponding `[workflow-state:STATUS]` block. After editing, run `trellis update` (if you're a template maintainer) or restart your AI session (if you're customizing your own project) — no script changes required.
|
||||
|
||||
### Adding a custom status
|
||||
|
||||
Add a new block:
|
||||
|
||||
```
|
||||
[workflow-state:my-status]
|
||||
your per-turn prompt text
|
||||
[/workflow-state:my-status]
|
||||
```
|
||||
|
||||
Constraints:
|
||||
- STATUS charset: `[A-Za-z0-9_-]+` (underscores and hyphens allowed, e.g. `in-review`, `blocked-by-team`)
|
||||
- A lifecycle hook must write `task.json.status` to your custom value, otherwise the tag is never read
|
||||
- Lifecycle hooks live in `task.json.hooks.after_*` and bind to one of `after_create / after_start / after_finish / after_archive`
|
||||
|
||||
### Adding a lifecycle hook
|
||||
|
||||
Add a `hooks` field to your `task.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"after_finish": [
|
||||
"your-script-or-command-here"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Supported events: `after_create / after_start / after_finish / after_archive`. Note that `after_finish` ≠ a status change (it only clears the active-task pointer); use `after_archive` for "task is done" notifications.
|
||||
|
||||
### Full contract
|
||||
|
||||
For the workflow state machine's runtime contract, the locations of all status writers, pseudo-statuses (`no_task` / `stale_<source_type>`), the hook reachability matrix, and other deep details, see:
|
||||
|
||||
- `.trellis/spec/cli/backend/workflow-state-contract.md` — runtime contract + writer table + test invariants
|
||||
- `.trellis/scripts/inject-workflow-state.py` — actual parser (reads workflow.md only, no embedded text)
|
||||
40
.trellis/workspace/TalexDreamSoul/index.md
Normal file
40
.trellis/workspace/TalexDreamSoul/index.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Workspace Index - TalexDreamSoul
|
||||
|
||||
> Journal tracking for AI development sessions.
|
||||
|
||||
---
|
||||
|
||||
## Current Status
|
||||
|
||||
<!-- @@@auto:current-status -->
|
||||
- **Active File**: `journal-1.md`
|
||||
- **Total Sessions**: 0
|
||||
- **Last Active**: -
|
||||
<!-- @@@/auto:current-status -->
|
||||
|
||||
---
|
||||
|
||||
## Active Documents
|
||||
|
||||
<!-- @@@auto:active-documents -->
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| `journal-1.md` | ~0 | Active |
|
||||
<!-- @@@/auto:active-documents -->
|
||||
|
||||
---
|
||||
|
||||
## Session History
|
||||
|
||||
<!-- @@@auto:session-history -->
|
||||
| # | Date | Title | Commits | Branch |
|
||||
|---|------|-------|---------|--------|
|
||||
<!-- @@@/auto:session-history -->
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Sessions are appended to journal files
|
||||
- New journal file created when current exceeds 2000 lines
|
||||
- Use `add_session.py` to record sessions
|
||||
7
.trellis/workspace/TalexDreamSoul/journal-1.md
Normal file
7
.trellis/workspace/TalexDreamSoul/journal-1.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Journal - TalexDreamSoul (Part 1)
|
||||
|
||||
> AI development session journal
|
||||
> Started: 2026-07-01
|
||||
|
||||
---
|
||||
|
||||
125
.trellis/workspace/index.md
Normal file
125
.trellis/workspace/index.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Workspace Index
|
||||
|
||||
> Records of all AI Agent work records across all developers
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This directory tracks records for all developers working with AI Agents on this project.
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
workspace/
|
||||
|-- index.md # This file - main index
|
||||
+-- {developer}/ # Per-developer directory
|
||||
|-- index.md # Personal index with session history
|
||||
|-- tasks/ # Task files
|
||||
| |-- *.json # Active tasks
|
||||
| +-- archive/ # Archived tasks by month
|
||||
+-- journal-N.md # Journal files (sequential: 1, 2, 3...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Active Developers
|
||||
|
||||
| Developer | Last Active | Sessions | Active File |
|
||||
|-----------|-------------|----------|-------------|
|
||||
| (none yet) | - | - | - |
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### For New Developers
|
||||
|
||||
Run the initialization script:
|
||||
|
||||
```bash
|
||||
python3 ./.trellis/scripts/init_developer.py <your-name>
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Create your identity file (gitignored)
|
||||
2. Create your progress directory
|
||||
3. Create your personal index
|
||||
4. Create initial journal file
|
||||
|
||||
### For Returning Developers
|
||||
|
||||
1. Get your developer name:
|
||||
```bash
|
||||
python3 ./.trellis/scripts/get_developer.py
|
||||
```
|
||||
|
||||
2. Read your personal index:
|
||||
```bash
|
||||
cat .trellis/workspace/$(python3 ./.trellis/scripts/get_developer.py)/index.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Guidelines
|
||||
|
||||
### Journal File Rules
|
||||
|
||||
- **Max 2000 lines** per journal file
|
||||
- When limit is reached, create `journal-{N+1}.md`
|
||||
- Update your personal `index.md` when creating new files
|
||||
|
||||
### Session Record Format
|
||||
|
||||
Each session should include:
|
||||
- Summary: One-line description
|
||||
- Branch: Which branch the work was done on
|
||||
- Main Changes: What was modified
|
||||
- Git Commits: Commit hashes and messages
|
||||
- Next Steps: What to do next
|
||||
|
||||
---
|
||||
|
||||
## Session Template
|
||||
|
||||
Use this template when recording sessions:
|
||||
|
||||
```markdown
|
||||
## Session {N}: {Title}
|
||||
|
||||
**Date**: YYYY-MM-DD
|
||||
**Task**: {task-name}
|
||||
**Branch**: `{branch-name}`
|
||||
|
||||
### Summary
|
||||
|
||||
{One-line summary}
|
||||
|
||||
### Main Changes
|
||||
|
||||
- {Change 1}
|
||||
- {Change 2}
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| `abc1234` | {commit message} |
|
||||
|
||||
### Testing
|
||||
|
||||
- [OK] {Test result}
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed** / # **In Progress** / [P] **Blocked**
|
||||
|
||||
### Next Steps
|
||||
|
||||
- {Next step 1}
|
||||
- {Next step 2}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Language**: All documentation must be written in **English**.
|
||||
Reference in New Issue
Block a user