docs: add Trellis planning and project specs

This commit is contained in:
2026-07-01 06:27:55 -07:00
parent 7f227c5f2a
commit aafd9caaac
349 changed files with 55801 additions and 0 deletions

32
.trellis/.gitignore vendored Normal file
View 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

View 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
View File

@@ -0,0 +1 @@
0.6.5

70
.trellis/agents/check.md Normal file
View 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.
```

View 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
View 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
View 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
View 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())

View 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,
)

View 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

View 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
View 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

View 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
View 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)

View 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
View 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
View 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}")

View 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
View 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)}")

View 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,
)

View 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))

View 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

View 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']}")

View 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

View 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
View 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]"

View 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
View 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

View 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
View 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()

View 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()

View 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)

View 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
View 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
View 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

View 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-...
```

View 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")` |

View 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,
});
```

View 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/`)

View 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**.

View 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: "/",
});
```

View 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,
});
```

View 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();
});
});
```

View 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);
}
```

View 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" });
}
```

View 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">>;
```

View 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

View 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)

View 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)

View 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)

View 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/)

View 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 |

View 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

View 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,
});
};
```

View 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

View 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

View 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

View 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

View 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**.

View 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

View 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>
```

View 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

View 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

View 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**.

View 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**.

View 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.

View 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 |

View 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

View 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**.

View 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 |

View 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?"

View 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": {}
}

View File

@@ -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."}

View 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.

View File

@@ -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."}

View File

@@ -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.

View 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.

View File

@@ -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
View 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.23.4 (implementation + check + wrap-up) | `[workflow-state:in_progress]` (after Phase 2 summary) |
| Codex inline Phase 2 + Phase 3.23.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)

View 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

View File

@@ -0,0 +1,7 @@
# Journal - TalexDreamSoul (Part 1)
> AI development session journal
> Started: 2026-07-01
---

125
.trellis/workspace/index.md Normal file
View 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**.