diff --git a/.opencode/agents/check.md b/.opencode/agents/check.md new file mode 100644 index 0000000..c7e7fed --- /dev/null +++ b/.opencode/agents/check.md @@ -0,0 +1,146 @@ +--- +description: | + Code quality check expert. Reviews code changes against specs and self-fixes issues. +mode: subagent +permission: + read: allow + write: allow + edit: allow + bash: allow + glob: allow + grep: allow + mcp__exa__*: allow +--- +# Check Agent + +You are the Check Agent in the Trellis workflow. + +## Context Self-Loading + +**If you see "# Check Agent Task" header with pre-loaded context above, skip this section.** + +Otherwise, load context yourself: + +1. Read `.trellis/.current-task` → get task directory (e.g., `.trellis/tasks/xxx`) +2. Read `{task_dir}/check.jsonl` (or `spec.jsonl` as fallback) +3. For each entry in JSONL: + - If `path` is a file → Read it + - If `path` is a directory → Read all `.md` files in it +4. Read `{task_dir}/prd.md` for requirements understanding +5. Read `.opencode/commands/trellis/finish-work.md` for checklist + +Then proceed with the workflow below using the loaded context. + +--- + +## Context + +Before checking, read: +- `.trellis/spec/` - Development guidelines +- Pre-commit checklist for quality standards + +## Core Responsibilities + +1. **Get code changes** - Use git diff to get uncommitted code +2. **Check against specs** - Verify code follows guidelines +3. **Self-fix** - Fix issues yourself, not just report them +4. **Run verification** - typecheck and lint + +## Important + +**Fix issues yourself**, don't just report them. + +You have write and edit tools, you can modify code directly. + +--- + +## Workflow + +### Step 1: Get Changes + +```bash +git diff --name-only # List changed files +git diff # View specific changes +``` + +### Step 2: Check Against Specs + +Read relevant specs in `.trellis/spec/` to check code: + +- Does it follow directory structure conventions +- Does it follow naming conventions +- Does it follow code patterns +- Are there missing types +- Are there potential bugs + +### Step 3: Self-Fix + +After finding issues: + +1. Fix the issue directly (use edit tool) +2. Record what was fixed +3. Continue checking other issues + +### Step 4: Run Verification + +Run project's lint and typecheck commands to verify changes. + +If failed, fix issues and re-run. + +--- + +## Completion Markers (Ralph Loop) + +**CRITICAL**: You are in a loop controlled by the Ralph Loop system. +The loop will NOT stop until you output ALL required completion markers. + +Completion markers are generated from `check.jsonl` in the task directory. +Each entry's `reason` field becomes a marker: `{REASON}_FINISH` + +For example, if check.jsonl contains: +```json +{"file": "...", "reason": "TypeCheck"} +{"file": "...", "reason": "Lint"} +{"file": "...", "reason": "CodeReview"} +``` + +You MUST output these markers when each check passes: +- `TYPECHECK_FINISH` - After typecheck passes +- `LINT_FINISH` - After lint passes +- `CODEREVIEW_FINISH` - After code review passes + +If check.jsonl doesn't exist or has no reasons, output: `ALL_CHECKS_FINISH` + +**The loop will block you from stopping until all markers are present in your output.** + +--- + +## Report Format + +```markdown +## Self-Check Complete + +### Files Checked + +- src/components/Feature.tsx +- src/hooks/useFeature.ts + +### Issues Found and Fixed + +1. `:` - +2. `:` - + +### Issues Not Fixed + +(If there are issues that cannot be self-fixed, list them here with reasons) + +### Verification Results + +- TypeCheck: Passed TYPECHECK_FINISH +- Lint: Passed LINT_FINISH + +### Summary + +Checked X files, found Y issues, all fixed. +ALL_CHECKS_FINISH +``` diff --git a/.opencode/agents/debug.md b/.opencode/agents/debug.md new file mode 100644 index 0000000..3bf02d5 --- /dev/null +++ b/.opencode/agents/debug.md @@ -0,0 +1,129 @@ +--- +description: | + Issue fixing expert. Understands issues, fixes against specs, and verifies fixes. Precise fixes only. +mode: subagent +permission: + read: allow + write: allow + edit: allow + bash: allow + glob: allow + grep: allow + mcp__exa__*: allow +--- +# Debug Agent + +You are the Debug Agent in the Trellis workflow. + +## Context Self-Loading + +**If you see "# Debug Agent Task" header with pre-loaded context above, skip this section.** + +Otherwise, load context yourself: + +1. Read `.trellis/.current-task` → get task directory (e.g., `.trellis/tasks/xxx`) +2. Read `{task_dir}/debug.jsonl` (or `spec.jsonl` as fallback) +3. For each entry in JSONL: + - If `path` is a file → Read it + - If `path` is a directory → Read all `.md` files in it +4. Read `{task_dir}/codex-review-output.txt` if exists (Codex review results) + +Then proceed with the workflow below using the loaded context. + +--- + +## Context + +Before debugging, read: +- `.trellis/spec/` - Development guidelines +- Error messages or issue descriptions provided + +## Core Responsibilities + +1. **Understand issues** - Analyze error messages or reported issues +2. **Fix against specs** - Fix issues following dev specs +3. **Verify fixes** - Run typecheck to ensure no new issues +4. **Report results** - Report fix status + +--- + +## Workflow + +### Step 1: Understand Issues + +Parse the issue, categorize by priority: + +- `[P1]` - Must fix (blocking) +- `[P2]` - Should fix (important) +- `[P3]` - Optional fix (nice to have) + +### Step 2: Research if Needed + +If you need additional info: + +```bash +# Check knowledge base +ls .trellis/big-question/ +``` + +### Step 3: Fix One by One + +For each issue: + +1. Locate the exact position +2. Fix following specs +3. Run typecheck to verify + +### Step 4: Verify + +Run project's lint and typecheck commands to verify fixes. + +If fix introduces new issues: + +1. Revert the fix +2. Use a more complete solution +3. Re-verify + +--- + +## Report Format + +```markdown +## Fix Report + +### Issues Fixed + +1. `[P1]` `:` - +2. `[P2]` `:` - + +### Issues Not Fixed + +- `:` - + +### Verification + +- TypeCheck: Pass +- Lint: Pass + +### Summary + +Fixed X/Y issues. Z issues require discussion. +``` + +--- + +## Guidelines + +### DO + +- Precise fixes for reported issues +- Follow specs +- Verify each fix + +### DON'T + +- Don't refactor surrounding code +- Don't add new features +- Don't modify unrelated files +- Don't use non-null assertion (`x!` operator) +- Don't execute git commit diff --git a/.opencode/agents/dispatch.md b/.opencode/agents/dispatch.md new file mode 100644 index 0000000..1ca3332 --- /dev/null +++ b/.opencode/agents/dispatch.md @@ -0,0 +1,223 @@ +--- +description: | + Multi-Agent Pipeline main dispatcher. Pure dispatcher. Only responsible for calling subagents and scripts in phase order. +mode: primary +permission: + read: allow + write: deny + edit: deny + bash: allow + glob: deny + grep: deny + task: allow + mcp__exa__*: allow +--- +# Dispatch Agent + +You are the Dispatch Agent in the Multi-Agent Pipeline (pure dispatcher). + +## Working Directory Convention + +Current Task is specified by `.trellis/.current-task` file, content is the relative path to task directory. + +Task directory path format: `.trellis/tasks/{MM}-{DD}-{name}/` + +This directory contains all context files for the current task: + +- `task.json` - Task configuration +- `prd.md` - Requirements document +- `info.md` - Technical design (optional) +- `implement.jsonl` - Implement context +- `check.jsonl` - Check context +- `debug.jsonl` - Debug context + +## Core Principles + +1. **You are a pure dispatcher** - Only responsible for calling subagents and scripts in order +2. **You don't read specs/requirements** - Hook will auto-inject all context to subagents +3. **You don't need resume** - Hook injects complete context on each subagent call +4. **You only need simple commands** - Tell subagent "start working" is enough + +--- + +## Startup Flow + +### Step 1: Determine Current Task Directory + +Read `.trellis/.current-task` to get current task directory path: + +```bash +TASK_DIR=$(cat .trellis/.current-task) +# e.g.: .trellis/tasks/02-03-my-feature +``` + +### Step 2: Read Task Configuration + +```bash +cat ${TASK_DIR}/task.json +``` + +Get the `next_action` array, which defines the list of phases to execute. + +### Step 3: Execute in Phase Order + +Execute each step in `phase` order. + +> **Note**: You do NOT need to manually update `current_phase`. The Hook automatically updates it when you call Task with a subagent. + +--- + +## Phase Handling + +> Hook will auto-inject all specs, requirements, and technical design to subagent context. +> Dispatch only needs to issue simple call commands. + +### action: "implement" + +``` +Task( + subagent_type: "implement", + prompt: "Implement the feature described in prd.md in the task directory", + model: "opus", + run_in_background: true +) +``` + +Hook will auto-inject: + +- All spec files from implement.jsonl +- Requirements document (prd.md) +- Technical design (info.md) + +Implement receives complete context and autonomously: read → understand → implement. + +### action: "check" + +``` +Task( + subagent_type: "check", + prompt: "Check code changes, fix issues yourself", + model: "opus", + run_in_background: true +) +``` + +Hook will auto-inject: + +- finish-work.md +- check-cross-layer.md +- check-backend.md +- check-frontend.md +- All spec files from check.jsonl + +### action: "debug" + +``` +Task( + subagent_type: "debug", + prompt: "Fix the issues described in the task context", + model: "opus", + run_in_background: true +) +``` + +Hook will auto-inject: + +- All spec files from debug.jsonl +- Error context if available + +### action: "finish" + +``` +Task( + subagent_type: "check", + prompt: "[finish] Execute final completion check before PR", + model: "opus", + run_in_background: true +) +``` + +**Important**: The `[finish]` marker in prompt triggers different context injection: +- finish-work.md checklist +- update-spec.md (spec update process and templates) +- prd.md for verifying requirements are met + +The finish agent actively updates spec docs when it detects new patterns or contracts in the changes. + +This is different from regular "check" which has full specs for self-fix loop. + +### action: "create-pr" + +This action creates a Pull Request from the feature branch. Run it via Bash: + +```bash +python3 ./.trellis/scripts/multi_agent/create_pr.py +``` + +This will: +1. Stage and commit all changes (excluding workspace) +2. Push to origin +3. Create a Draft PR using `gh pr create` +4. Update task.json with status="review", pr_url, and current_phase + +**Note**: This is the only action that performs git commit, as it's the final step after all implementation and checks are complete. + +--- + +## Calling Subagents + +### Basic Pattern + +``` +task_id = Task( + subagent_type: "implement", // or "check", "debug" + prompt: "Simple task description", + model: "opus", + run_in_background: true +) + +// Poll for completion +for i in 1..N: + result = TaskOutput(task_id, block=true, timeout=300000) + if result.status == "completed": + break +``` + +### Timeout Settings + +| Phase | Max Time | Poll Count | +|-------|----------|------------| +| implement | 30 min | 6 times | +| check | 15 min | 3 times | +| debug | 20 min | 4 times | + +--- + +## Error Handling + +### Timeout + +If a subagent times out, notify the user and ask for guidance: + +``` +"Subagent {phase} timed out after {time}. Options: +1. Retry the same phase +2. Skip to next phase +3. Abort the pipeline" +``` + +### Subagent Failure + +If a subagent reports failure, read the output and decide: + +- If recoverable: call debug agent to fix +- If not recoverable: notify user and ask for guidance + +--- + +## Key Constraints + +1. **Do not read spec/requirement files directly** - Let Hook inject to subagents +2. **Only commit via create-pr action** - Use `multi_agent/create_pr.py` at the end of pipeline +3. **All subagents should use opus model for complex tasks** +4. **Keep dispatch logic simple** - Complex logic belongs in subagents diff --git a/.opencode/agents/implement.md b/.opencode/agents/implement.md new file mode 100644 index 0000000..d481d66 --- /dev/null +++ b/.opencode/agents/implement.md @@ -0,0 +1,120 @@ +--- +description: | + Code implementation expert. Understands specs and requirements, then implements features. No git commit allowed. +mode: subagent +permission: + read: allow + write: allow + edit: allow + bash: allow + glob: allow + grep: allow + mcp__exa__*: allow +--- +# Implement Agent + +You are the Implement Agent in the Trellis workflow. + +## Context Self-Loading + +**If you see "# Implement Agent Task" header with pre-loaded context above, skip this section.** + +Otherwise, load context yourself: + +1. Read `.trellis/.current-task` → get task directory (e.g., `.trellis/tasks/xxx`) +2. Read `{task_dir}/implement.jsonl` (or `spec.jsonl` as fallback) +3. For each entry in JSONL: + - If `path` is a file → Read it + - If `path` is a directory → Read all `.md` files in it +4. Read `{task_dir}/prd.md` for requirements +5. Read `{task_dir}/info.md` for technical design (if exists) + +Then proceed with the workflow below using the loaded context. + +--- + +## Context + +Before implementing, read: +- `.trellis/workflow.md` - Project workflow +- `.trellis/spec/` - Development guidelines +- Task `prd.md` - Requirements document +- Task `info.md` - Technical design (if exists) + +## Core Responsibilities + +1. **Understand specs** - Read relevant spec files in `.trellis/spec/` +2. **Understand requirements** - Read prd.md and info.md +3. **Implement features** - Write code following specs and design +4. **Self-check** - Ensure code quality +5. **Report results** - Report completion status + +## Forbidden Operations + +**Do NOT execute these git commands:** + +- `git commit` +- `git push` +- `git merge` + +--- + +## Workflow + +### 1. Understand Specs + +Read relevant specs based on task type: + +- Backend: `.trellis/spec/backend/` +- Frontend: `.trellis/spec/frontend/` +- Guides: `.trellis/spec/guides/` + +### 2. Understand Requirements + +Read the task's prd.md and info.md: + +- What are the core requirements +- Key points of technical design +- Which files to modify/create + +### 3. Implement Features + +- Write code following specs and technical design +- Follow existing code patterns +- Only do what's required, no over-engineering + +### 4. Verify + +Run project's lint and typecheck commands to verify changes. + +--- + +## Report Format + +```markdown +## Implementation Complete + +### Files Modified + +- `src/components/Feature.tsx` - New component +- `src/hooks/useFeature.ts` - New hook + +### Implementation Summary + +1. Created Feature component... +2. Added useFeature hook... + +### Verification Results + +- Lint: Passed +- TypeCheck: Passed +``` + +--- + +## Code Standards + +- Follow existing code patterns +- Don't add unnecessary abstractions +- Only do what's required, no over-engineering +- Keep code readable diff --git a/.opencode/agents/research.md b/.opencode/agents/research.md new file mode 100644 index 0000000..0c1c196 --- /dev/null +++ b/.opencode/agents/research.md @@ -0,0 +1,147 @@ +--- +description: | + Code and tech search expert. Pure research, no code modifications. Finds files, patterns, and tech solutions. +mode: subagent +permission: + read: allow + write: deny + edit: deny + bash: deny + glob: allow + grep: allow + mcp__exa__*: allow + mcp__chrome-devtools__*: allow +--- +# Research Agent + +You are the Research Agent in the Trellis workflow. + +## Context Self-Loading + +**If you see "# Research Agent Task" header with pre-loaded context above, skip this section.** + +Otherwise, if task-specific research is needed: + +1. Read `.trellis/.current-task` → get task directory (if exists) +2. Read `{task_dir}/research.jsonl` if exists +3. For each entry in JSONL: + - If `path` is a file → Read it + - If `path` is a directory → Read all `.md` files in it + +Project spec locations for reference: +- `.trellis/spec/backend/` - Backend standards +- `.trellis/spec/frontend/` - Frontend standards +- `.trellis/spec/guides/` - Thinking guides +- `.trellis/big-question/` - Known issues and pitfalls + +--- + +## Core Principle + +**You do one thing: find and explain information.** + +You are a documenter, not a reviewer. Your job is to help get the information needed. + +--- + +## Core Responsibilities + +### 1. Internal Search (Project Code) + +| Search Type | Goal | Tools | +|-------------|------|-------| +| **WHERE** | Locate files/components | Glob, Grep | +| **HOW** | Understand code logic | Read, Grep | +| **PATTERN** | Discover existing patterns | Grep, Read | + +### 2. External Search (Tech Solutions) + +Use web search for best practices and code examples. + +--- + +## Strict Boundaries + +### Only Allowed + +- Describe **what exists** +- Describe **where it is** +- Describe **how it works** +- Describe **how components interact** + +### Forbidden (unless explicitly asked) + +- Suggest improvements +- Criticize implementation +- Recommend refactoring +- Modify any files +- Execute git commands + +--- + +## Workflow + +### Step 1: Understand Search Request + +Analyze the query, determine: + +- Search type (internal/external/mixed) +- Search scope (global/specific directory) +- Expected output (file list/code patterns/tech solutions) + +### Step 2: Execute Search + +Execute multiple independent searches in parallel for efficiency. + +### Step 3: Organize Results + +Output structured results in report format. + +--- + +## Report Format + +```markdown +## Search Results + +### Query + +{original query} + +### Files Found + +| File Path | Description | +|-----------|-------------| +| `src/services/xxx.ts` | Main implementation | +| `src/types/xxx.ts` | Type definitions | + +### Code Pattern Analysis + +{Describe discovered patterns, cite specific files and line numbers} + +### Related Spec Documents + +- `.trellis/spec/xxx.md` - {description} + +### Not Found + +{If some content was not found, explain} +``` + +--- + +## Guidelines + +### DO + +- Provide specific file paths and line numbers +- Quote actual code snippets +- Distinguish "definitely found" and "possibly related" +- Explain search scope and limitations + +### DON'T + +- Don't guess uncertain info +- Don't omit important search results +- Don't add improvement suggestions in report (unless explicitly asked) +- Don't modify any files diff --git a/.opencode/agents/trellis-plan.md b/.opencode/agents/trellis-plan.md new file mode 100644 index 0000000..7a7c19a --- /dev/null +++ b/.opencode/agents/trellis-plan.md @@ -0,0 +1,427 @@ +--- +description: | + Multi-Agent Pipeline planner. Analyzes requirements and produces a fully configured task directory ready for dispatch. +mode: primary +permission: + read: allow + write: allow + edit: allow + bash: allow + glob: allow + grep: allow + task: allow +--- +# Plan Agent + +You are the Plan Agent in the Multi-Agent Pipeline. + +**Your job**: Evaluate requirements and, if valid, transform them into a fully configured task directory. + +**You have the power to reject** - If a requirement is unclear, incomplete, unreasonable, or potentially harmful, you MUST refuse to proceed and clean up. + +--- + +## CRITICAL: You MUST Execute Tools + +**DO NOT just output text descriptions of what you would do.** +**You MUST actually execute bash commands and use tools to perform actions.** + +When this prompt says "run this command", you must use the bash tool to execute it. +When this prompt says "write this file", you must use the write tool to create it. + +--- + +## Step 0: Read Environment Variables (REQUIRED FIRST STEP) + +**IMMEDIATELY execute this bash command to read your input:** + +```bash +echo "PLAN_TASK_NAME=$PLAN_TASK_NAME" +echo "PLAN_DEV_TYPE=$PLAN_DEV_TYPE" +echo "PLAN_REQUIREMENT=$PLAN_REQUIREMENT" +echo "PLAN_TASK_DIR=$PLAN_TASK_DIR" +``` + +This gives you the task configuration. Store these values for use in subsequent steps. + +--- + +## Step 1: Evaluate Requirement (CRITICAL) + +Now evaluate the requirement from `$PLAN_REQUIREMENT`: + +### Reject If: + +1. **Unclear or Vague** + - "Make it better" / "Fix the bugs" / "Improve performance" + - No specific outcome defined + - Cannot determine what "done" looks like + +2. **Incomplete Information** + - Missing critical details to implement + - References unknown systems or files + - Depends on decisions not yet made + +3. **Out of Scope for This Project** + - Requirement doesn't match the project's purpose + - Requires changes to external systems + - Not technically feasible with current architecture + +4. **Potentially Harmful** + - Security vulnerabilities (intentional backdoors, data exfiltration) + - Destructive operations without clear justification + - Circumventing access controls + +5. **Too Large / Should Be Split** + - Multiple unrelated features bundled together + - Would require touching too many systems + - Cannot be completed in a reasonable scope + +### If Rejecting: + +**You MUST execute these commands using the bash tool. Do not just describe them.** + +**Step R1: Update task.json status** - Execute this bash command: +```bash +jq '.status = "rejected"' "$PLAN_TASK_DIR/task.json" > "$PLAN_TASK_DIR/task.json.tmp" \ + && mv "$PLAN_TASK_DIR/task.json.tmp" "$PLAN_TASK_DIR/task.json" +``` + +**Step R2: Write REJECTED.md** - Use the write tool to create `$PLAN_TASK_DIR/REJECTED.md` with this content: +```markdown +# Plan Rejected + +## Reason + + +## Details + + +## Suggestions +- +- + +## To Retry + +1. Delete this directory: + ```bash + rm -rf + ``` + +2. Run with revised requirement: + ```bash + python3 ./.trellis/scripts/multi_agent/plan.py --name "" --type "" --requirement "" + ``` +``` + +**Step R3: Print summary** - Execute: +```bash +echo "=== PLAN REJECTED ===" +echo "" +echo "Reason: " +echo "Details: " +echo "" +echo "See: $PLAN_TASK_DIR/REJECTED.md" +``` + +**Step R4: Stop** - Do not proceed to acceptance workflow. + +**The task directory is kept** with: +- `task.json` (status: "rejected") +- `REJECTED.md` (full explanation) +- `.plan-log` (execution log) + +This allows the user to review why it was rejected. + +### If Accepting: + +Continue to Step 1. The requirement is: +- Clear and specific +- Has a defined outcome +- Is technically feasible +- Is appropriately scoped + +--- + +## Input + +You receive input via environment variables (set by plan.py): + +```bash +PLAN_TASK_NAME # Task name (e.g., "user-auth") +PLAN_DEV_TYPE # Development type: backend | frontend | fullstack +PLAN_REQUIREMENT # Requirement description from user +PLAN_TASK_DIR # Pre-created task directory path +``` + +Read them at startup: + +```bash +echo "Task: $PLAN_TASK_NAME" +echo "Type: $PLAN_DEV_TYPE" +echo "Requirement: $PLAN_REQUIREMENT" +echo "Directory: $PLAN_TASK_DIR" +``` + +## Output (if accepted) + +A complete task directory containing: + +``` +${PLAN_TASK_DIR}/ +├── task.json # Updated with branch, scope, dev_type +├── prd.md # Requirements document +├── implement.jsonl # Implement phase context +├── check.jsonl # Check phase context +└── debug.jsonl # Debug phase context +``` + +--- + +## Workflow (After Acceptance) + +### Step 1: Initialize Context Files + +```bash +python3 ./.trellis/scripts/task.py init-context "$PLAN_TASK_DIR" "$PLAN_DEV_TYPE" +``` + +This creates base jsonl files with standard specs for the dev type. + +### Step 2: Analyze Codebase with Research Agent + +Call research agent to find relevant specs and code patterns: + +``` +Task( + subagent_type: "research", + prompt: "Analyze what specs and code patterns are needed for this task. + +Task: ${PLAN_REQUIREMENT} +Dev Type: ${PLAN_DEV_TYPE} + +Instructions: +1. Search .trellis/spec/ for relevant spec files +2. Search the codebase for related modules and patterns +3. Identify files that should be added to jsonl context + +Output format (use exactly this format): + +## implement.jsonl +- path: , reason: +- path: , reason: + +## check.jsonl +- path: , reason: + +## debug.jsonl +- path: , reason: + +## Suggested Scope + + +## Technical Notes +", + model: "opus" +) +``` + +### Step 3: Add Context Entries + +Parse research agent output and add entries to jsonl files: + +```bash +# For each entry in implement.jsonl section: +python3 ./.trellis/scripts/task.py add-context "$PLAN_TASK_DIR" implement "" "" + +# For each entry in check.jsonl section: +python3 ./.trellis/scripts/task.py add-context "$PLAN_TASK_DIR" check "" "" + +# For each entry in debug.jsonl section: +python3 ./.trellis/scripts/task.py add-context "$PLAN_TASK_DIR" debug "" "" +``` + +### Step 4: Write prd.md + +Create the requirements document: + +```bash +cat > "$PLAN_TASK_DIR/prd.md" << 'EOF' +# Task: ${PLAN_TASK_NAME} + +## Overview +[Brief description of what this feature does] + +## Requirements +- [Requirement 1] +- [Requirement 2] +- ... + +## Acceptance Criteria +- [ ] [Criterion 1] +- [ ] [Criterion 2] +- ... + +## Technical Notes +[Any technical considerations from research agent] + +## Out of Scope +- [What this feature does NOT include] +EOF +``` + +**Guidelines for prd.md**: +- Be specific and actionable +- Include acceptance criteria that can be verified +- Add technical notes from research agent +- Define what's out of scope to prevent scope creep + +### Step 5: Configure Task Metadata + +```bash +# Set branch name +python3 ./.trellis/scripts/task.py set-branch "$PLAN_TASK_DIR" "feature/${PLAN_TASK_NAME}" + +# Set scope (from research agent suggestion) +python3 ./.trellis/scripts/task.py set-scope "$PLAN_TASK_DIR" "" + +# Update dev_type in task.json +jq --arg type "$PLAN_DEV_TYPE" '.dev_type = $type' \ + "$PLAN_TASK_DIR/task.json" > "$PLAN_TASK_DIR/task.json.tmp" \ + && mv "$PLAN_TASK_DIR/task.json.tmp" "$PLAN_TASK_DIR/task.json" +``` + +### Step 6: Validate Configuration + +```bash +python3 ./.trellis/scripts/task.py validate "$PLAN_TASK_DIR" +``` + +If validation fails, fix the invalid paths and re-validate. + +### Step 7: Output Summary + +Print a summary for the caller: + +```bash +echo "=== Plan Complete ===" +echo "Task Directory: $PLAN_TASK_DIR" +echo "" +echo "Files created:" +ls -la "$PLAN_TASK_DIR" +echo "" +echo "Context summary:" +python3 ./.trellis/scripts/task.py list-context "$PLAN_TASK_DIR" +echo "" +echo "Ready for: python3 ./.trellis/scripts/multi_agent/start.py $PLAN_TASK_DIR" +``` + +--- + +## Key Principles + +1. **Reject early, reject clearly** - Don't waste time on bad requirements +2. **Research before configure** - Always call research agent to understand the codebase +3. **Validate all paths** - Every file in jsonl must exist +4. **Be specific in prd.md** - Vague requirements lead to wrong implementations +5. **Include acceptance criteria** - Check agent needs to verify something concrete +6. **Set appropriate scope** - This affects commit message format + +--- + +## Error Handling + +### Research Agent Returns No Results + +If research agent finds no relevant specs: +- Use only the base specs from init-context +- Add a note in prd.md that this is a new area without existing patterns + +### Path Not Found + +If add-context fails because path doesn't exist: +- Skip that entry +- Log a warning +- Continue with other entries + +### Validation Fails + +If final validation fails: +- Read the error output +- Remove invalid entries from jsonl files +- Re-validate + +--- + +## Examples + +### Example: Accepted Requirement + +``` +Input: + PLAN_TASK_NAME = "add-rate-limiting" + PLAN_DEV_TYPE = "backend" + PLAN_REQUIREMENT = "Add rate limiting to API endpoints using a sliding window algorithm. Limit to 100 requests per minute per IP. Return 429 status when exceeded." + +Result: ACCEPTED - Clear, specific, has defined behavior + +Output: + .trellis/tasks/02-03-add-rate-limiting/ + ├── task.json # branch: feature/add-rate-limiting, scope: api + ├── prd.md # Detailed requirements with acceptance criteria + ├── implement.jsonl # Backend specs + existing middleware patterns + ├── check.jsonl # Quality guidelines + API testing specs + └── debug.jsonl # Error handling specs +``` + +### Example: Rejected - Vague Requirement + +``` +Input: + PLAN_REQUIREMENT = "Make the API faster" + +Result: REJECTED + +=== PLAN REJECTED === + +Reason: Unclear or Vague + +Details: +"Make the API faster" does not specify: +- Which endpoints need optimization +- Current performance baseline +- Target performance metrics +- Acceptable trade-offs (memory, complexity) + +Suggestions: +- Identify specific slow endpoints with response times +- Define target latency (e.g., "GET /users should respond in <100ms") +- Specify if caching, query optimization, or architecture changes are acceptable +``` + +### Example: Rejected - Too Large + +``` +Input: + PLAN_REQUIREMENT = "Add user authentication, authorization, password reset, 2FA, OAuth integration, and audit logging" + +Result: REJECTED + +=== PLAN REJECTED === + +Reason: Too Large / Should Be Split + +Details: +This requirement bundles 6 distinct features that should be implemented separately: +1. User authentication (login/logout) +2. Authorization (roles/permissions) +3. Password reset flow +4. Two-factor authentication +5. OAuth integration +6. Audit logging + +Suggestions: +- Start with basic authentication first +- Create separate features for each capability +- Consider dependencies (auth before authz, etc.) +``` diff --git a/.opencode/commands/trellis/before-backend-dev.md b/.opencode/commands/trellis/before-backend-dev.md new file mode 100644 index 0000000..7dfcd36 --- /dev/null +++ b/.opencode/commands/trellis/before-backend-dev.md @@ -0,0 +1,13 @@ +Read the backend development guidelines before starting your development task. + +Execute these steps: +1. Read `.trellis/spec/backend/index.md` to understand available guidelines +2. Based on your task, read the relevant guideline files: + - Database work → `.trellis/spec/backend/database-guidelines.md` + - Error handling → `.trellis/spec/backend/error-handling.md` + - Logging → `.trellis/spec/backend/logging-guidelines.md` + - Type questions → `.trellis/spec/backend/type-safety.md` +3. Understand the coding standards and patterns you need to follow +4. Then proceed with your development plan + +This step is **mandatory** before writing any backend code. diff --git a/.opencode/commands/trellis/before-frontend-dev.md b/.opencode/commands/trellis/before-frontend-dev.md new file mode 100644 index 0000000..9687edc --- /dev/null +++ b/.opencode/commands/trellis/before-frontend-dev.md @@ -0,0 +1,13 @@ +Read the frontend development guidelines before starting your development task. + +Execute these steps: +1. Read `.trellis/spec/frontend/index.md` to understand available guidelines +2. Based on your task, read the relevant guideline files: + - Component work → `.trellis/spec/frontend/component-guidelines.md` + - Hook work → `.trellis/spec/frontend/hook-guidelines.md` + - State management → `.trellis/spec/frontend/state-management.md` + - Type questions → `.trellis/spec/frontend/type-safety.md` +3. Understand the coding standards and patterns you need to follow +4. Then proceed with your development plan + +This step is **mandatory** before writing any frontend code. diff --git a/.opencode/commands/trellis/brainstorm.md b/.opencode/commands/trellis/brainstorm.md new file mode 100644 index 0000000..bc2b8af --- /dev/null +++ b/.opencode/commands/trellis/brainstorm.md @@ -0,0 +1,487 @@ +# Brainstorm - Requirements Discovery (AI Coding Enhanced) + +Guide AI through collaborative requirements discovery **before implementation**, optimized for AI coding workflows: + +* **Task-first** (capture ideas immediately) +* **Action-before-asking** (reduce low-value questions) +* **Research-first** for technical choices (avoid asking users to invent options) +* **Diverge → Converge** (expand thinking, then lock MVP) + +--- + +## When to Use + +Triggered from `/trellis:start` when the user describes a development task, especially when: + +* requirements are unclear or evolving +* there are multiple valid implementation paths +* trade-offs matter (UX, reliability, maintainability, cost, performance) +* the user might not know the best options up front + +--- + +## Core Principles (Non-negotiable) + +1. **Task-first (capture early)** + Always ensure a task exists at the start so the user's ideas are recorded immediately. + +2. **Action before asking** + If you can derive the answer from repo code, docs, configs, conventions, or quick research — do that first. + +3. **One question per message** + Never overwhelm the user with a list of questions. Ask one, update PRD, repeat. + +4. **Prefer concrete options** + For preference/decision questions, present 2–3 feasible, specific approaches with trade-offs. + +5. **Research-first for technical choices** + If the decision depends on industry conventions / similar tools / established patterns, do research first, then propose options. + +6. **Diverge → Converge** + After initial understanding, proactively consider future evolution, related scenarios, and failure/edge cases — then converge to an MVP with explicit out-of-scope. + +7. **No meta questions** + Do not ask "should I search?" or "can you paste the code so I can continue?" + If you need information: search/inspect. If blocked: ask the minimal blocking question. + +--- + +## Step 0: Ensure Task Exists (ALWAYS) + +Before any Q&A, ensure a task exists. If none exists, create one immediately. + +* Use a **temporary working title** derived from the user's message. +* It's OK if the title is imperfect — refine later in PRD. + +```bash +TASK_DIR=$(python3 ./.trellis/scripts/task.py create "brainstorm: " --slug ) +``` + +Create/seed `prd.md` immediately with what you know: + +```markdown +# brainstorm: + +## Goal + + + +## What I already know + +* +* + +## Assumptions (temporary) + +* + +## Open Questions + +* + +## Requirements (evolving) + +* + +## Acceptance Criteria (evolving) + +* [ ] + +## Definition of Done (team quality bar) + +* Tests added/updated (unit/integration where appropriate) +* Lint / typecheck / CI green +* Docs/notes updated if behavior changes +* Rollout/rollback considered if risky + +## Out of Scope (explicit) + +* + +## Technical Notes + +* +* +``` + +--- + +## Step 1: Auto-Context (DO THIS BEFORE ASKING QUESTIONS) + +Before asking questions like "what does the code look like?", gather context yourself: + +### Repo inspection checklist + +* Identify likely modules/files impacted +* Locate existing patterns (similar features, conventions, error handling style) +* Check configs, scripts, existing command definitions +* Note any constraints (runtime, dependency policy, build tooling) + +### Documentation checklist + +* Look for existing PRDs/specs/templates +* Look for command usage examples, README, ADRs if any + +Write findings into PRD: + +* Add to `What I already know` +* Add constraints/links to `Technical Notes` + +--- + +## Step 2: Classify Complexity (still useful, not gating task creation) + +| Complexity | Criteria | Action | +| ------------ | ------------------------------------------------------ | ------------------------------------------- | +| **Trivial** | Single-line fix, typo, obvious change | Skip brainstorm, implement directly | +| **Simple** | Clear goal, 1–2 files, scope well-defined | Ask 1 confirm question, then implement | +| **Moderate** | Multiple files, some ambiguity | Light brainstorm (2–3 high-value questions) | +| **Complex** | Vague goal, architectural choices, multiple approaches | Full brainstorm | + +> Note: Task already exists from Step 0. Classification only affects depth of brainstorming. + +--- + +## Step 3: Question Gate (Ask ONLY high-value questions) + +Before asking ANY question, run the following gate: + +### Gate A — Can I derive this without the user? + +If answer is available via: + +* repo inspection (code/config) +* docs/specs/conventions +* quick market/OSS research + +→ **Do not ask.** Fetch it, summarize, update PRD. + +### Gate B — Is this a meta/lazy question? + +Examples: + +* "Should I search?" +* "Can you paste the code so I can proceed?" +* "What does the code look like?" (when repo is available) + +→ **Do not ask.** Take action. + +### Gate C — What type of question is it? + +* **Blocking**: cannot proceed without user input +* **Preference**: multiple valid choices, depends on product/UX/risk preference +* **Derivable**: should be answered by inspection/research + +→ Only ask **Blocking** or **Preference**. + +--- + +## Step 4: Research-first Mode (Mandatory for technical choices) + +### Trigger conditions (any → research-first) + +* The task involves selecting an approach, library, protocol, framework, template system, plugin mechanism, or CLI UX convention +* The user asks for "best practice", "how others do it", "recommendation" +* The user can't reasonably enumerate options + +### Research steps + +1. Identify 2–4 comparable tools/patterns +2. Summarize common conventions and why they exist +3. Map conventions onto our repo constraints +4. Produce **2–3 feasible approaches** for our project + +### Research output format (PRD) + +Add a section in PRD (either within Technical Notes or as its own): + +```markdown +## Research Notes + +### What similar tools do + +* ... +* ... + +### Constraints from our repo/project + +* ... + +### Feasible approaches here + +**Approach A: ** (Recommended) + +* How it works: +* Pros: +* Cons: + +**Approach B: ** + +* How it works: +* Pros: +* Cons: + +**Approach C: ** (optional) + +* ... +``` + +Then ask **one** preference question: + +* "Which approach do you prefer: A / B / C (or other)?" + +--- + +## Step 5: Expansion Sweep (DIVERGE) — Required after initial understanding + +After you can summarize the goal, proactively broaden thinking before converging. + +### Expansion categories (keep to 1–2 bullets each) + +1. **Future evolution** + + * What might this feature become in 1–3 months? + * What extension points are worth preserving now? + +2. **Related scenarios** + + * What adjacent commands/flows should remain consistent with this? + * Are there parity expectations (create vs update, import vs export, etc.)? + +3. **Failure & edge cases** + + * Conflicts, offline/network failure, retries, idempotency, compatibility, rollback + * Input validation, security boundaries, permission checks + +### Expansion message template (to user) + +```markdown +I understand you want to implement: . + +Before diving into design, let me quickly diverge to consider three categories (to avoid rework later): + +1. Future evolution: <1–2 bullets> +2. Related scenarios: <1–2 bullets> +3. Failure/edge cases: <1–2 bullets> + +For this MVP, which would you like to include (or none)? + +1. Current requirement only (minimal viable) +2. Add (reserve for future extension) +3. Add (improve robustness/consistency) +4. Other: describe your preference +``` + +Then update PRD: + +* What's in MVP → `Requirements` +* What's excluded → `Out of Scope` + +--- + +## Step 6: Q&A Loop (CONVERGE) + +### Rules + +* One question per message +* Prefer multiple-choice when possible +* After each user answer: + + * Update PRD immediately + * Move answered items from `Open Questions` → `Requirements` + * Update `Acceptance Criteria` with testable checkboxes + * Clarify `Out of Scope` + +### Question priority (recommended) + +1. **MVP scope boundary** (what is included/excluded) +2. **Preference decisions** (after presenting concrete options) +3. **Failure/edge behavior** (only for MVP-critical paths) +4. **Success metrics & Acceptance Criteria** (what proves it works) + +### Preferred question format (multiple choice) + +```markdown +For , which approach do you prefer? + +1. **Option A** — +2. **Option B** — +3. **Option C** — +4. **Other** — describe your preference +``` + +--- + +## Step 7: Propose Approaches + Record Decisions (Complex tasks) + +After requirements are clear enough, propose 2–3 approaches (if not already done via research-first): + +```markdown +Based on current information, here are 2–3 feasible approaches: + +**Approach A: ** (Recommended) + +* How: +* Pros: +* Cons: + +**Approach B: ** + +* How: +* Pros: +* Cons: + +Which direction do you prefer? +``` + +Record the outcome in PRD as an ADR-lite section: + +```markdown +## Decision (ADR-lite) + +**Context**: Why this decision was needed +**Decision**: Which approach was chosen +**Consequences**: Trade-offs, risks, potential future improvements +``` + +--- + +## Step 8: Final Confirmation + Implementation Plan + +When open questions are resolved, confirm complete requirements with a structured summary: + +### Final confirmation format + +```markdown +Here's my understanding of the complete requirements: + +**Goal**: + +**Requirements**: + +* ... +* ... + +**Acceptance Criteria**: + +* [ ] ... +* [ ] ... + +**Definition of Done**: + +* ... + +**Out of Scope**: + +* ... + +**Technical Approach**: + + +**Implementation Plan (small PRs)**: + +* PR1: +* PR2: +* PR3: + +Does this look correct? If yes, I'll proceed with implementation. +``` + +### Subtask Decomposition (Complex Tasks) + +For complex tasks with multiple independent work items, create subtasks: + +```bash +# Create child tasks +CHILD1=$(python3 ./.trellis/scripts/task.py create "Child task 1" --slug child1 --parent "$TASK_DIR") +CHILD2=$(python3 ./.trellis/scripts/task.py create "Child task 2" --slug child2 --parent "$TASK_DIR") + +# Or link existing tasks +python3 ./.trellis/scripts/task.py add-subtask "$TASK_DIR" "$CHILD_DIR" +``` + +--- + +## PRD Target Structure (final) + +`prd.md` should converge to: + +```markdown +# + +## Goal + + + +## Requirements + +* ... + +## Acceptance Criteria + +* [ ] ... + +## Definition of Done + +* ... + +## Technical Approach + + + +## Decision (ADR-lite) + +Context / Decision / Consequences + +## Out of Scope + +* ... + +## Technical Notes + + +``` + +--- + +## Anti-Patterns (Hard Avoid) + +* Asking user for code/context that can be derived from repo +* Asking user to choose an approach before presenting concrete options +* Meta questions about whether to research +* Staying narrowly on the initial request without considering evolution/edges +* Letting brainstorming drift without updating PRD + +--- + +## Integration with Start Workflow + +After brainstorm completes (Step 8 confirmation approved), the flow continues to the Task Workflow's **Phase 2: Prepare for Implementation**: + +```text +Brainstorm + Step 0: Create task directory + seed PRD + Step 1–7: Discover requirements, research, converge + Step 8: Final confirmation → user approves + ↓ +Task Workflow Phase 2 (Prepare for Implementation) + Code-Spec Depth Check (if applicable) + → Research codebase (based on confirmed PRD) + → Configure code-spec context (jsonl files) + → Activate task + ↓ +Task Workflow Phase 3 (Execute) + Implement → Check → Complete +``` + +The task directory and PRD already exist from brainstorm, so Phase 1 of the Task Workflow is skipped entirely. + +--- + +## Related Commands + +| Command | When to Use | +|---------|-------------| +| `/trellis:start` | Entry point that triggers brainstorm | +| `/trellis:finish-work` | After implementation is complete | +| `/trellis:update-spec` | If new patterns emerge during work | diff --git a/.opencode/commands/trellis/break-loop.md b/.opencode/commands/trellis/break-loop.md new file mode 100644 index 0000000..9905751 --- /dev/null +++ b/.opencode/commands/trellis/break-loop.md @@ -0,0 +1,125 @@ +# Break the Loop - Deep Bug Analysis + +When debug is complete, use this command for deep analysis to break the "fix bug -> forget -> repeat" cycle. + +--- + +## Analysis Framework + +Analyze the bug you just fixed from these 5 dimensions: + +### 1. Root Cause Category + +Which category does this bug belong to? + +| Category | Characteristics | Example | +|----------|-----------------|---------| +| **A. Missing Spec** | No documentation on how to do it | New feature without checklist | +| **B. Cross-Layer Contract** | Interface between layers unclear | API returns different format than expected | +| **C. Change Propagation Failure** | Changed one place, missed others | Changed function signature, missed call sites | +| **D. Test Coverage Gap** | Unit test passes, integration fails | Works alone, breaks when combined | +| **E. Implicit Assumption** | Code relies on undocumented assumption | Timestamp seconds vs milliseconds | + +### 2. Why Fixes Failed (if applicable) + +If you tried multiple fixes before succeeding, analyze each failure: + +- **Surface Fix**: Fixed symptom, not root cause +- **Incomplete Scope**: Found root cause, didn't cover all cases +- **Tool Limitation**: grep missed it, type check wasn't strict +- **Mental Model**: Kept looking in same layer, didn't think cross-layer + +### 3. Prevention Mechanisms + +What mechanisms would prevent this from happening again? + +| Type | Description | Example | +|------|-------------|---------| +| **Documentation** | Write it down so people know | Update thinking guide | +| **Architecture** | Make the error impossible structurally | Type-safe wrappers | +| **Compile-time** | TypeScript strict, no any | Signature change causes compile error | +| **Runtime** | Monitoring, alerts, scans | Detect orphan entities | +| **Test Coverage** | E2E tests, integration tests | Verify full flow | +| **Code Review** | Checklist, PR template | "Did you check X?" | + +### 4. Systematic Expansion + +What broader problems does this bug reveal? + +- **Similar Issues**: Where else might this problem exist? +- **Design Flaw**: Is there a fundamental architecture issue? +- **Process Flaw**: Is there a development process improvement? +- **Knowledge Gap**: Is the team missing some understanding? + +### 5. Knowledge Capture + +Solidify insights into the system: + +- [ ] Update `.trellis/spec/guides/` thinking guides +- [ ] Update `.trellis/spec/backend/` or `frontend/` docs +- [ ] Create issue record (if applicable) +- [ ] Create feature ticket for root fix +- [ ] Update check commands if needed + +--- + +## Output Format + +Please output analysis in this format: + +```markdown +## Bug Analysis: [Short Description] + +### 1. Root Cause Category +- **Category**: [A/B/C/D/E] - [Category Name] +- **Specific Cause**: [Detailed description] + +### 2. Why Fixes Failed (if applicable) +1. [First attempt]: [Why it failed] +2. [Second attempt]: [Why it failed] +... + +### 3. Prevention Mechanisms +| Priority | Mechanism | Specific Action | Status | +|----------|-----------|-----------------|--------| +| P0 | ... | ... | TODO/DONE | + +### 4. Systematic Expansion +- **Similar Issues**: [List places with similar problems] +- **Design Improvement**: [Architecture-level suggestions] +- **Process Improvement**: [Development process suggestions] + +### 5. Knowledge Capture +- [ ] [Documents to update / tickets to create] +``` + +--- + +## Core Philosophy + +> **The value of debugging is not in fixing the bug, but in making this class of bugs never happen again.** + +Three levels of insight: +1. **Tactical**: How to fix THIS bug +2. **Strategic**: How to prevent THIS CLASS of bugs +3. **Philosophical**: How to expand thinking patterns + +30 minutes of analysis saves 30 hours of future debugging. + +--- + +## After Analysis: Immediate Actions + +**IMPORTANT**: After completing the analysis above, you MUST immediately: + +1. **Update spec/guides** - Don't just list TODOs, actually update the relevant files: + - If it's a cross-platform issue → update `cross-platform-thinking-guide.md` + - If it's a cross-layer issue → update `cross-layer-thinking-guide.md` + - If it's a code reuse issue → update `code-reuse-thinking-guide.md` + - If it's domain-specific → update `backend/*.md` or `frontend/*.md` + +2. **Sync templates** - After updating `.trellis/spec/`, sync to `src/templates/markdown/spec/` + +3. **Commit the spec updates** - This is the primary output, not just the analysis text + +> **The analysis is worthless if it stays in chat. The value is in the updated specs.** diff --git a/.opencode/commands/trellis/check-backend.md b/.opencode/commands/trellis/check-backend.md new file mode 100644 index 0000000..886f5c9 --- /dev/null +++ b/.opencode/commands/trellis/check-backend.md @@ -0,0 +1,13 @@ +Check if the code you just wrote follows the backend development guidelines. + +Execute these steps: +1. Run `git status` to see modified files +2. Read `.trellis/spec/backend/index.md` to understand which guidelines apply +3. Based on what you changed, read the relevant guideline files: + - Database changes → `.trellis/spec/backend/database-guidelines.md` + - Error handling → `.trellis/spec/backend/error-handling.md` + - Logging changes → `.trellis/spec/backend/logging-guidelines.md` + - Type changes → `.trellis/spec/backend/type-safety.md` + - Any changes → `.trellis/spec/backend/quality-guidelines.md` +4. Review your code against the guidelines +5. Report any violations and fix them if found diff --git a/.opencode/commands/trellis/check-cross-layer.md b/.opencode/commands/trellis/check-cross-layer.md new file mode 100644 index 0000000..591d39b --- /dev/null +++ b/.opencode/commands/trellis/check-cross-layer.md @@ -0,0 +1,153 @@ +# Cross-Layer Check + +Check if your changes considered all dimensions. Most bugs come from "didn't think of it", not lack of technical skill. + +> **Note**: This is a **post-implementation** safety net. Ideally, read the [Pre-Implementation Checklist](.trellis/spec/guides/pre-implementation-checklist.md) **before** writing code. + +--- + +## Related Documents + +| Document | Purpose | Timing | +|----------|---------|--------| +| [Pre-Implementation Checklist](.trellis/spec/guides/pre-implementation-checklist.md) | Questions before coding | **Before** writing code | +| [Code Reuse Thinking Guide](.trellis/spec/guides/code-reuse-thinking-guide.md) | Pattern recognition | During implementation | +| **`/trellis:check-cross-layer`** (this) | Verification check | **After** implementation | + +--- + +## Execution Steps + +### 1. Identify Change Scope + +```bash +git status +git diff --name-only +``` + +### 2. Select Applicable Check Dimensions + +Based on your change type, execute relevant checks below: + +--- + +## Dimension A: Cross-Layer Data Flow (Required when 3+ layers) + +**Trigger**: Changes involve 3 or more layers + +| Layer | Common Locations | +|-------|------------------| +| API/Routes | `routes/`, `api/`, `handlers/`, `controllers/` | +| Service/Business Logic | `services/`, `lib/`, `core/`, `domain/` | +| Database/Storage | `db/`, `models/`, `repositories/`, `schema/` | +| UI/Presentation | `components/`, `views/`, `templates/`, `pages/` | +| Utility | `utils/`, `helpers/`, `common/` | + +**Checklist**: +- [ ] Read flow: Database -> Service -> API -> UI +- [ ] Write flow: UI -> API -> Service -> Database +- [ ] Types/schemas correctly passed between layers? +- [ ] Errors properly propagated to caller? +- [ ] Loading/pending states handled at each layer? + +**Detailed Guide**: `.trellis/spec/guides/cross-layer-thinking-guide.md` + +--- + +## Dimension B: Code Reuse (Required when modifying constants/config) + +**Trigger**: +- Modifying UI constants (label, icon, color) +- Modifying any hardcoded value +- Seeing similar code in multiple places +- Creating a new utility/helper function +- Just finished batch modifications across files + +**Checklist**: +- [ ] Search first: How many places define this value? + ```bash + # Search in source files (adjust extensions for your project) + grep -r "value-to-change" src/ + ``` +- [ ] If 2+ places define same value -> Should extract to shared constant +- [ ] After modification, all usage sites updated? +- [ ] If creating utility: Does similar utility already exist? + +**Detailed Guide**: `.trellis/spec/guides/code-reuse-thinking-guide.md` + +--- + +## Dimension B2: New Utility Functions + +**Trigger**: About to create a new utility/helper function + +**Checklist**: +- [ ] Search for existing similar utilities first + ```bash + grep -r "functionNamePattern" src/ + ``` +- [ ] If similar exists, can you extend it instead? +- [ ] If creating new, is it in the right location (shared vs domain-specific)? + +--- + +## Dimension B3: After Batch Modifications + +**Trigger**: Just modified similar patterns in multiple files + +**Checklist**: +- [ ] Did you check ALL files with similar patterns? + ```bash + grep -r "patternYouChanged" src/ + ``` +- [ ] Any files missed that should also be updated? +- [ ] Should this pattern be abstracted to prevent future duplication? + +--- + +## Dimension C: Import/Dependency Paths (Required when creating new files) + +**Trigger**: Creating new source files + +**Checklist**: +- [ ] Using correct import paths (relative vs absolute)? +- [ ] No circular dependencies? +- [ ] Consistent with project's module organization? + +--- + +## Dimension D: Same-Layer Consistency + +**Trigger**: +- Modifying display logic or formatting +- Same domain concept used in multiple places + +**Checklist**: +- [ ] Search for other places using same concept + ```bash + grep -r "ConceptName" src/ + ``` +- [ ] Are these usages consistent? +- [ ] Should they share configuration/constants? + +--- + +## Common Issues Quick Reference + +| Issue | Root Cause | Prevention | +|-------|------------|------------| +| Changed one place, missed others | Didn't search impact scope | `grep` before changing | +| Data lost at some layer | Didn't check data flow | Trace data source to destination | +| Type/schema mismatch | Cross-layer types inconsistent | Use shared type definitions | +| UI/output inconsistent | Same concept in multiple places | Extract shared constants | +| Similar utility exists | Didn't search first | Search before creating | +| Batch fix incomplete | Didn't verify all occurrences | grep after fixing | + +--- + +## Output + +Report: +1. Which dimensions your changes involve +2. Check results for each dimension +3. Issues found and fix suggestions diff --git a/.opencode/commands/trellis/check-frontend.md b/.opencode/commands/trellis/check-frontend.md new file mode 100644 index 0000000..3771ae3 --- /dev/null +++ b/.opencode/commands/trellis/check-frontend.md @@ -0,0 +1,13 @@ +Check if the code you just wrote follows the frontend development guidelines. + +Execute these steps: +1. Run `git status` to see modified files +2. Read `.trellis/spec/frontend/index.md` to understand which guidelines apply +3. Based on what you changed, read the relevant guideline files: + - Component changes → `.trellis/spec/frontend/component-guidelines.md` + - Hook changes → `.trellis/spec/frontend/hook-guidelines.md` + - State changes → `.trellis/spec/frontend/state-management.md` + - Type changes → `.trellis/spec/frontend/type-safety.md` + - Any changes → `.trellis/spec/frontend/quality-guidelines.md` +4. Review your code against the guidelines +5. Report any violations and fix them if found diff --git a/.opencode/commands/trellis/create-command.md b/.opencode/commands/trellis/create-command.md new file mode 100644 index 0000000..bc12b6f --- /dev/null +++ b/.opencode/commands/trellis/create-command.md @@ -0,0 +1,154 @@ +# Create New Slash Command + +Create a new slash command in both `.cursor/commands/` (with `trellis-` prefix) and `.opencode/commands/trellis/` directories based on user requirements. + +## Usage + +``` +/trellis:create-command +``` + +**Example**: +``` +/trellis:create-command review-pr Check PR code changes against project guidelines +``` + +## Execution Steps + +### 1. Parse Input + +Extract from user input: +- **Command name**: Use kebab-case (e.g., `review-pr`) +- **Description**: What the command should accomplish + +### 2. Analyze Requirements + +Determine command type based on description: +- **Initialization**: Read docs, establish context +- **Pre-development**: Read guidelines, check dependencies +- **Code check**: Validate code quality and guideline compliance +- **Recording**: Record progress, questions, structure changes +- **Generation**: Generate docs, code templates + +### 3. Generate Command Content + +Based on command type, generate appropriate content: + +**Simple command** (1-3 lines): +```markdown +Concise instruction describing what to do +``` + +**Complex command** (with steps): +```markdown +# Command Title + +Command description + +## Steps + +### 1. First Step +Specific action + +### 2. Second Step +Specific action + +## Output Format (if needed) + +Template +``` + +### 4. Create Files + +Create in both directories: +- `.cursor/commands/trellis-.md` +- `.opencode/commands/trellis/.md` + +### 5. Confirm Creation + +Output result: +``` +[OK] Created Slash Command: / + +File paths: +- .cursor/commands/trellis-.md +- .opencode/commands/trellis/.md + +Usage: +/trellis: + +Description: + +``` + +## Command Content Guidelines + +### [OK] Good command content + +1. **Clear and concise**: Immediately understandable +2. **Executable**: AI can follow steps directly +3. **Well-scoped**: Clear boundaries of what to do and not do +4. **Has output**: Specifies expected output format (if needed) + +### [X] Avoid + +1. **Too vague**: e.g., "optimize code" +2. **Too complex**: Single command should not exceed 100 lines +3. **Duplicate functionality**: Check if similar command exists first + +## Naming Conventions + +| Command Type | Prefix | Example | +|--------------|--------|---------| +| Session Start | `start` | `start` | +| Pre-development | `before-` | `before-frontend-dev` | +| Check | `check-` | `check-frontend` | +| Record | `record-` | `record-session` | +| Generate | `generate-` | `generate-api-doc` | +| Update | `update-` | `update-changelog` | +| Other | Verb-first | `review-code`, `sync-data` | + +## Example + +### Input +``` +/trellis:create-command review-pr Check PR code changes against project guidelines +``` + +### Generated Command Content +```markdown +# PR Code Review + +Check current PR code changes against project guidelines. + +## Steps + +### 1. Get Changed Files +```bash +git diff main...HEAD --name-only +``` + +### 2. Categorized Review + +**Frontend files** (`apps/web/`): +- Reference `.trellis/spec/frontend/index.md` + +**Backend files** (`packages/api/`): +- Reference `.trellis/spec/backend/index.md` + +### 3. Output Review Report + +Format: + +## PR Review Report + +### Changed Files +- [file list] + +### Check Results +- [OK] Passed items +- [X] Issues found + +### Suggestions +- [improvement suggestions] +``` diff --git a/.opencode/commands/trellis/finish-work.md b/.opencode/commands/trellis/finish-work.md new file mode 100644 index 0000000..b82b153 --- /dev/null +++ b/.opencode/commands/trellis/finish-work.md @@ -0,0 +1,144 @@ +# Finish Work - Pre-Commit Checklist + +Before submitting or committing, use this checklist to ensure work completeness. + +**Timing**: After code is written and tested, before commit + +--- + +## Checklist + +### 1. Code Quality + +```bash +# Must pass +pnpm lint +pnpm type-check +pnpm test +``` + +- [ ] `pnpm lint` passes with 0 errors? +- [ ] `pnpm type-check` passes with no type errors? +- [ ] Tests pass? +- [ ] No `console.log` statements (use logger)? +- [ ] No non-null assertions (the `x!` operator)? +- [ ] No `any` types? + +### 2. Code-Spec Sync + +**Code-Spec Docs**: +- [ ] Does `.trellis/spec/backend/` need updates? + - New patterns, new modules, new conventions +- [ ] Does `.trellis/spec/frontend/` need updates? + - New components, new hooks, new patterns +- [ ] Does `.trellis/spec/guides/` need updates? + - New cross-layer flows, lessons from bugs + +**Key Question**: +> "If I fixed a bug or discovered something non-obvious, should I document it so future me (or others) won't hit the same issue?" + +If YES -> Update the relevant code-spec doc. + +### 2.5. Code-Spec Hard Block (Infra/Cross-Layer) + +If this change touches infra or cross-layer contracts, this is a blocking checklist: + +- [ ] Spec content is executable (real signatures/contracts), not principle-only text +- [ ] Includes file path + command/API name + payload field names +- [ ] Includes validation and error matrix +- [ ] Includes Good/Base/Bad cases +- [ ] Includes required tests and assertion points + +**Block Rule**: +In pipeline mode, the finish agent will automatically detect and execute spec updates when gaps are found. +If running this checklist manually, ensure spec sync is complete before committing — run `/trellis:update-spec` if needed. + +### 3. API Changes + +If you modified API endpoints: + +- [ ] Input schema updated? +- [ ] Output schema updated? +- [ ] API documentation updated? +- [ ] Client code updated to match? + +### 4. Database Changes + +If you modified database schema: + +- [ ] Migration file created? +- [ ] Schema file updated? +- [ ] Related queries updated? +- [ ] Seed data updated (if applicable)? + +### 5. Cross-Layer Verification + +If the change spans multiple layers: + +- [ ] Data flows correctly through all layers? +- [ ] Error handling works at each boundary? +- [ ] Types are consistent across layers? +- [ ] Loading states handled? + +### 6. Manual Testing + +- [ ] Feature works in browser/app? +- [ ] Edge cases tested? +- [ ] Error states tested? +- [ ] Works after page refresh? + +--- + +## Quick Check Flow + +```bash +# 1. Code checks +pnpm lint && pnpm type-check + +# 2. View changes +git status +git diff --name-only + +# 3. Based on changed files, check relevant items above +``` + +--- + +## Common Oversights + +| Oversight | Consequence | Check | +|-----------|-------------|-------| +| Code-spec docs not updated | Others don't know the change | Check .trellis/spec/ | +| Spec text is abstract only | Easy regressions in infra/cross-layer changes | Require signature/contract/matrix/cases/tests | +| Migration not created | Schema out of sync | Check db/migrations/ | +| Types not synced | Runtime errors | Check shared types | +| Tests not updated | False confidence | Run full test suite | +| Console.log left in | Noisy production logs | Search for console.log | + +--- + +## Relationship to Other Commands + +``` +Development Flow: + Write code -> Test -> /trellis:finish-work -> git commit -> /trellis:record-session + | | + Ensure completeness Record progress + +Debug Flow: + Hit bug -> Fix -> /trellis:break-loop -> Knowledge capture + | + Deep analysis +``` + +- `/trellis:finish-work` - Check work completeness (this command) +- `/trellis:record-session` - Record session and commits +- `/trellis:break-loop` - Deep analysis after debugging + +--- + +## Core Principle + +> **Delivery includes not just code, but also documentation, verification, and knowledge capture.** + +Complete work = Code + Docs + Tests + Verification diff --git a/.opencode/commands/trellis/integrate-skill.md b/.opencode/commands/trellis/integrate-skill.md new file mode 100644 index 0000000..cacafd5 --- /dev/null +++ b/.opencode/commands/trellis/integrate-skill.md @@ -0,0 +1,219 @@ +# Integrate Claude Skill into Project Guidelines + +Adapt and integrate a Claude global skill into your project's development guidelines (not directly into project code). + +## Usage + +``` +/trellis:integrate-skill +``` + +**Examples**: +``` +/trellis:integrate-skill frontend-design +/trellis:integrate-skill mcp-builder +``` + +## Core Principle + +> [!] **Important**: The goal of skill integration is to update **development guidelines**, not to generate project code directly. +> +> - Guidelines content -> Write to `.trellis/spec/{target}/doc.md` +> - Code examples -> Place in `.trellis/spec/{target}/examples/skills//` +> - Example files -> Use `.template` suffix (e.g., `component.tsx.template`) to avoid IDE errors +> +> Where `{target}` is `frontend` or `backend`, determined by skill type. + +## Execution Steps + +### 1. Read Skill Content + +```bash +openskills read +``` + +If the skill doesn't exist, prompt user to check available skills: +```bash +# Available skills are listed in AGENTS.md under +``` + +### 2. Determine Integration Target + +Based on skill type, determine which guidelines to update: + +| Skill Category | Integration Target | +|----------------|-------------------| +| UI/Frontend (`frontend-design`, `web-artifacts-builder`) | `.trellis/spec/frontend/` | +| Backend/API (`mcp-builder`) | `.trellis/spec/backend/` | +| Documentation (`doc-coauthoring`, `docx`, `pdf`) | `.trellis/` or create dedicated guidelines | +| Testing (`webapp-testing`) | `.trellis/spec/frontend/` (E2E) | + +### 3. Analyze Skill Content + +Extract from the skill: +- **Core concepts**: How the skill works and key concepts +- **Best practices**: Recommended approaches +- **Code patterns**: Reusable code templates +- **Caveats**: Common issues and solutions + +### 4. Execute Integration + +#### 4.1 Update Guidelines Document + +Add a new section to the corresponding `doc.md`: + +```markdown +@@@section:skill- +## # Integration Guide + +### Overview +[Core functionality and use cases of the skill] + +### Project Adaptation +[How to use this skill in the current project] + +### Usage Steps +1. [Step 1] +2. [Step 2] + +### Caveats +- [Project-specific constraints] +- [Differences from default behavior] + +### Reference Examples +See `examples/skills//` + +@@@/section:skill- +``` + +#### 4.2 Create Examples Directory (if code examples exist) + +```bash +# Directory structure ({target} = frontend or backend) +.trellis/spec/{target}/ +|-- doc.md # Add skill-related section +|-- index.md # Update index ++-- examples/ + +-- skills/ + +-- / + |-- README.md # Example documentation + |-- example-1.ts.template # Code example (use .template suffix) + +-- example-2.tsx.template +``` + +**File naming conventions**: +- Code files: `..template` (e.g., `component.tsx.template`) +- Config files: `.config.template` (e.g., `tailwind.config.template`) +- Documentation: `README.md` (normal suffix) + +#### 4.3 Update Index File + +Add to the Quick Navigation table in `index.md`: + +```markdown +| |
| `skill-` | +``` + +### 5. Generate Integration Report + +--- + +## Skill Integration Report: `` + +### # Overview +- **Skill description**: [Functionality description] +- **Integration target**: `.trellis/spec/{target}/` + +### # Tech Stack Compatibility + +| Skill Requirement | Project Status | Compatibility | +|-------------------|----------------|---------------| +| [Tech 1] | [Project tech] | [OK]/[!]/[X] | + +### # Integration Locations + +| Type | Path | +|------|------| +| Guidelines doc | `.trellis/spec/{target}/doc.md` (section: `skill-`) | +| Code examples | `.trellis/spec/{target}/examples/skills//` | +| Index update | `.trellis/spec/{target}/index.md` | + +> `{target}` = `frontend` or `backend` + +### # Dependencies (if needed) + +```bash +# Install required dependencies (adjust for your package manager) +npm install +# or +pnpm add +# or +yarn add +``` + +### [OK] Completed Changes + +- [ ] Added `@@@section:skill-` section to `doc.md` +- [ ] Added index entry to `index.md` +- [ ] Created example files in `examples/skills//` +- [ ] Example files use `.template` suffix + +### # Related Guidelines + +- [Existing related section IDs] + +--- + +## 6. Optional: Create Usage Command + +If this skill is frequently used, create a shortcut command: + +```bash +/trellis:create-command use- Use skill following project guidelines +``` + +## Common Skill Integration Reference + +| Skill | Integration Target | Examples Directory | +|-------|-------------------|-------------------| +| `frontend-design` | `frontend` | `examples/skills/frontend-design/` | +| `mcp-builder` | `backend` | `examples/skills/mcp-builder/` | +| `webapp-testing` | `frontend` | `examples/skills/webapp-testing/` | +| `doc-coauthoring` | `.trellis/` | N/A (documentation workflow only) | + +## Example: Integrating `mcp-builder` Skill + +### Directory Structure + +``` +.trellis/spec/backend/ +|-- doc.md # Add MCP section +|-- index.md # Add index entry ++-- examples/ + +-- skills/ + +-- mcp-builder/ + |-- README.md + |-- server.ts.template + |-- tools.ts.template + +-- types.ts.template +``` + +### New Section in doc.md + +```markdown +@@@section:skill-mcp-builder +## # MCP Server Development Guide + +### Overview +Create LLM-callable tool services using MCP (Model Context Protocol). + +### Project Adaptation +- Place services in a dedicated directory +- Follow existing TypeScript and type definition conventions +- Use project's logging system + +### Reference Examples +See `examples/skills/mcp-builder/` + +@@@/section:skill-mcp-builder +``` diff --git a/.opencode/commands/trellis/migrate-specs.md b/.opencode/commands/trellis/migrate-specs.md new file mode 100644 index 0000000..e69de29 diff --git a/.opencode/commands/trellis/onboard.md b/.opencode/commands/trellis/onboard.md new file mode 100644 index 0000000..732f80d --- /dev/null +++ b/.opencode/commands/trellis/onboard.md @@ -0,0 +1,358 @@ +You are a senior developer onboarding a new team member to this project's AI-assisted workflow system. + +YOUR ROLE: Be a mentor and teacher. Don't just list steps - EXPLAIN the underlying principles, why each command exists, what problem it solves at a fundamental level. + +## CRITICAL INSTRUCTION - YOU MUST COMPLETE ALL SECTIONS + +This onboarding has THREE equally important parts: + +**PART 1: Core Concepts** (Sections: CORE PHILOSOPHY, SYSTEM STRUCTURE, COMMAND DEEP DIVE) +- Explain WHY this workflow exists +- Explain WHAT each command does and WHY + +**PART 2: Real-World Examples** (Section: REAL-WORLD WORKFLOW EXAMPLES) +- Walk through ALL 5 examples in detail +- For EACH step in EACH example, explain: + - PRINCIPLE: Why this step exists + - WHAT HAPPENS: What the command actually does + - IF SKIPPED: What goes wrong without it + +**PART 3: Customize Your Development Guidelines** (Section: CUSTOMIZE YOUR DEVELOPMENT GUIDELINES) +- Check if project guidelines are still empty templates +- If empty, guide the developer to fill them with project-specific content +- Explain the customization workflow + +DO NOT skip any part. All three parts are essential: +- Part 1 teaches the concepts +- Part 2 shows how concepts work in practice +- Part 3 ensures the project has proper guidelines for AI to follow + +After completing ALL THREE parts, ask the developer about their first task. + +--- + +## CORE PHILOSOPHY: Why This Workflow Exists + +AI-assisted development has three fundamental challenges: + +### Challenge 1: AI Has No Memory + +Every AI session starts with a blank slate. Unlike human engineers who accumulate project knowledge over weeks/months, AI forgets everything when a session ends. + +**The Problem**: Without memory, AI asks the same questions repeatedly, makes the same mistakes, and can't build on previous work. + +**The Solution**: The `.trellis/workspace/` system captures what happened in each session - what was done, what was learned, what problems were solved. The `/trellis:start` command reads this history at session start, giving AI "artificial memory." + +### Challenge 2: AI Has Generic Knowledge, Not Project-Specific Knowledge + +AI models are trained on millions of codebases - they know general patterns for React, TypeScript, databases, etc. But they don't know YOUR project's conventions. + +**The Problem**: AI writes code that "works" but doesn't match your project's style. It uses patterns that conflict with existing code. It makes decisions that violate unwritten team rules. + +**The Solution**: The `.trellis/spec/` directory contains project-specific guidelines. The `/before-*-dev` commands inject this specialized knowledge into AI context before coding starts. + +### Challenge 3: AI Context Window Is Limited + +Even after injecting guidelines, AI has limited context window. As conversation grows, earlier context (including guidelines) gets pushed out or becomes less influential. + +**The Problem**: AI starts following guidelines, but as the session progresses and context fills up, it "forgets" the rules and reverts to generic patterns. + +**The Solution**: The `/check-*` commands re-verify code against guidelines AFTER writing, catching drift that occurred during development. The `/trellis:finish-work` command does a final holistic review. + +--- + +## SYSTEM STRUCTURE + +``` +.trellis/ +|-- .developer # Your identity (gitignored) +|-- workflow.md # Complete workflow documentation +|-- workspace/ # "AI Memory" - session history +| |-- index.md # All developers' progress +| +-- {developer}/ # Per-developer directory +| |-- index.md # Personal progress index +| +-- journal-N.md # Session records (max 2000 lines) +|-- tasks/ # Task tracking (unified) +| +-- {MM}-{DD}-{slug}/ # Task directory +| |-- task.json # Task metadata +| +-- prd.md # Requirements doc +|-- spec/ # "AI Training Data" - project knowledge +| |-- frontend/ # Frontend conventions +| |-- backend/ # Backend conventions +| +-- guides/ # Thinking patterns ++-- scripts/ # Automation tools +``` + +### Understanding spec/ subdirectories + +**frontend/** - Single-layer frontend knowledge: +- Component patterns (how to write components in THIS project) +- State management rules (Redux? Zustand? Context?) +- Styling conventions (CSS modules? Tailwind? Styled-components?) +- Hook patterns (custom hooks, data fetching) + +**backend/** - Single-layer backend knowledge: +- API design patterns (REST? GraphQL? tRPC?) +- Database conventions (query patterns, migrations) +- Error handling standards +- Logging and monitoring rules + +**guides/** - Cross-layer thinking guides: +- Code reuse thinking guide +- Cross-layer thinking guide +- Pre-implementation checklists + +--- + +## COMMAND DEEP DIVE + +### /trellis:start - Restore AI Memory + +**WHY IT EXISTS**: +When a human engineer joins a project, they spend days/weeks learning: What is this project? What's been built? What's in progress? What's the current state? + +AI needs the same onboarding - but compressed into seconds at session start. + +**WHAT IT ACTUALLY DOES**: +1. Reads developer identity (who am I in this project?) +2. Checks git status (what branch? uncommitted changes?) +3. Reads recent session history from `workspace/` (what happened before?) +4. Identifies active features (what's in progress?) +5. Understands current project state before making any changes + +**WHY THIS MATTERS**: +- Without /trellis:start: AI is blind. It might work on wrong branch, conflict with others' work, or redo already-completed work. +- With /trellis:start: AI knows project context, can continue where previous session left off, avoids conflicts. + +--- + +### /trellis:before-frontend-dev and /trellis:before-backend-dev - Inject Specialized Knowledge + +**WHY IT EXISTS**: +AI models have "pre-trained knowledge" - general patterns from millions of codebases. But YOUR project has specific conventions that differ from generic patterns. + +**WHAT IT ACTUALLY DOES**: +1. Reads `.trellis/spec/frontend/` or `.trellis/spec/backend/` +2. Loads project-specific patterns into AI's working context: + - Component naming conventions + - State management patterns + - Database query patterns + - Error handling standards + +**WHY THIS MATTERS**: +- Without before-*-dev: AI writes generic code that doesn't match project style. +- With before-*-dev: AI writes code that looks like the rest of the codebase. + +--- + +### /trellis:check-frontend and /trellis:check-backend - Combat Context Drift + +**WHY IT EXISTS**: +AI context window has limited capacity. As conversation progresses, guidelines injected at session start become less influential. This causes "context drift." + +**WHAT IT ACTUALLY DOES**: +1. Re-reads the guidelines that were injected earlier +2. Compares written code against those guidelines +3. Runs type checker and linter +4. Identifies violations and suggests fixes + +**WHY THIS MATTERS**: +- Without check-*: Context drift goes unnoticed, code quality degrades. +- With check-*: Drift is caught and corrected before commit. + +--- + +### /trellis:check-cross-layer - Multi-Dimension Verification + +**WHY IT EXISTS**: +Most bugs don't come from lack of technical skill - they come from "didn't think of it": +- Changed a constant in one place, missed 5 other places +- Modified database schema, forgot to update the API layer +- Created a utility function, but similar one already exists + +**WHAT IT ACTUALLY DOES**: +1. Identifies which dimensions your change involves +2. For each dimension, runs targeted checks: + - Cross-layer data flow + - Code reuse analysis + - Import path validation + - Consistency checks + +--- + +### /trellis:finish-work - Holistic Pre-Commit Review + +**WHY IT EXISTS**: +The `/check-*` commands focus on code quality within a single layer. But real changes often have cross-cutting concerns. + +**WHAT IT ACTUALLY DOES**: +1. Reviews all changes holistically +2. Checks cross-layer consistency +3. Identifies broader impacts +4. Checks if new patterns should be documented + +--- + +### /trellis:record-session - Persist Memory for Future + +**WHY IT EXISTS**: +All the context AI built during this session will be lost when session ends. The next session's `/trellis:start` needs this information. + +**WHAT IT ACTUALLY DOES**: +1. Records session summary to `workspace/{developer}/journal-N.md` +2. Captures what was done, learned, and what's remaining +3. Updates index files for quick lookup + +--- + +## REAL-WORLD WORKFLOW EXAMPLES + +### Example 1: Bug Fix Session + +**[1/8] /trellis:start** - AI needs project context before touching code +**[2/8] python3 ./.trellis/scripts/task.py create "Fix bug" --slug fix-bug** - Track work for future reference +**[3/8] /trellis:before-frontend-dev** - Inject project-specific frontend knowledge +**[4/8] Investigate and fix the bug** - Actual development work +**[5/8] /trellis:check-frontend** - Re-verify code against guidelines +**[6/8] /trellis:finish-work** - Holistic cross-layer review +**[7/8] Human tests and commits** - Human validates before code enters repo +**[8/8] /trellis:record-session** - Persist memory for future sessions + +### Example 2: Planning Session (No Code) + +**[1/4] /trellis:start** - Context needed even for non-coding work +**[2/4] python3 ./.trellis/scripts/task.py create "Planning task" --slug planning-task** - Planning is valuable work +**[3/4] Review docs, create subtask list** - Actual planning work +**[4/4] /trellis:record-session (with --summary)** - Planning decisions must be recorded + +### Example 3: Code Review Fixes + +**[1/6] /trellis:start** - Resume context from previous session +**[2/6] /trellis:before-backend-dev** - Re-inject guidelines before fixes +**[3/6] Fix each CR issue** - Address feedback with guidelines in context +**[4/6] /trellis:check-backend** - Verify fixes didn't introduce new issues +**[5/6] /trellis:finish-work** - Document lessons from CR +**[6/6] Human commits, then /trellis:record-session** - Preserve CR lessons + +### Example 4: Large Refactoring + +**[1/5] /trellis:start** - Clear baseline before major changes +**[2/5] Plan phases** - Break into verifiable chunks +**[3/5] Execute phase by phase with /check-* after each** - Incremental verification +**[4/5] /trellis:finish-work** - Check if new patterns should be documented +**[5/5] Record with multiple commit hashes** - Link all commits to one feature + +### Example 5: Debug Session + +**[1/6] /trellis:start** - See if this bug was investigated before +**[2/6] /trellis:before-backend-dev** - Guidelines might document known gotchas +**[3/6] Investigation** - Actual debugging work +**[4/6] /trellis:check-backend** - Verify debug changes don't break other things +**[5/6] /trellis:finish-work** - Debug findings might need documentation +**[6/6] Human commits, then /trellis:record-session** - Debug knowledge is valuable + +--- + +## KEY RULES TO EMPHASIZE + +1. **AI NEVER commits** - Human tests and approves. AI prepares, human validates. +2. **Guidelines before code** - /before-*-dev commands inject project knowledge. +3. **Check after code** - /check-* commands catch context drift. +4. **Record everything** - /trellis:record-session persists memory. + +--- + +# PART 3: Customize Your Development Guidelines + +After explaining Part 1 and Part 2, check if the project's development guidelines need customization. + +## Step 1: Check Current Guidelines Status + +Check if `.trellis/spec/` contains empty templates or customized guidelines: + +```bash +# Check if files are still empty templates (look for placeholder text) +grep -l "To be filled by the team" .trellis/spec/backend/*.md 2>/dev/null | wc -l +grep -l "To be filled by the team" .trellis/spec/frontend/*.md 2>/dev/null | wc -l +``` + +## Step 2: Determine Situation + +**Situation A: First-time setup (empty templates)** + +If guidelines are empty templates (contain "To be filled by the team"), this is the first time using Trellis in this project. + +Explain to the developer: + +"I see that the development guidelines in `.trellis/spec/` are still empty templates. This is normal for a new Trellis setup! + +The templates contain placeholder text that needs to be replaced with YOUR project's actual conventions. Without this, `/before-*-dev` commands won't provide useful guidance. + +**Your first task should be to fill in these guidelines:** + +1. Look at your existing codebase +2. Identify the patterns and conventions already in use +3. Document them in the guideline files + +For example, for `.trellis/spec/backend/database-guidelines.md`: +- What ORM/query library does your project use? +- How are migrations managed? +- What naming conventions for tables/columns? + +Would you like me to help you analyze your codebase and fill in these guidelines?" + +**Situation B: Guidelines already customized** + +If guidelines have real content (no "To be filled" placeholders), this is an existing setup. + +Explain to the developer: + +"Great! Your team has already customized the development guidelines. You can start using `/before-*-dev` commands right away. + +I recommend reading through `.trellis/spec/` to familiarize yourself with the team's coding standards." + +## Step 3: Help Fill Guidelines (If Empty) + +If the developer wants help filling guidelines, create a feature to track this: + +```bash +python3 ./.trellis/scripts/task.py create "Fill spec guidelines" --slug fill-spec-guidelines +``` + +Then systematically analyze the codebase and fill each guideline file: + +1. **Analyze the codebase** - Look at existing code patterns +2. **Document conventions** - Write what you observe, not ideals +3. **Include examples** - Reference actual files in the project +4. **List forbidden patterns** - Document anti-patterns the team avoids + +Work through one file at a time: +- `backend/directory-structure.md` +- `backend/database-guidelines.md` +- `backend/error-handling.md` +- `backend/quality-guidelines.md` +- `backend/logging-guidelines.md` +- `frontend/directory-structure.md` +- `frontend/component-guidelines.md` +- `frontend/hook-guidelines.md` +- `frontend/state-management.md` +- `frontend/quality-guidelines.md` +- `frontend/type-safety.md` + +--- + +## Completing the Onboard Session + +After covering all three parts, summarize: + +"You're now onboarded to the Trellis workflow system! Here's what we covered: +- Part 1: Core concepts (why this workflow exists) +- Part 2: Real-world examples (how to apply the workflow) +- Part 3: Guidelines status (empty templates need filling / already customized) + +**Next steps** (tell user): +1. Run `/trellis:record-session` to record this onboard session +2. [If guidelines empty] Start filling in `.trellis/spec/` guidelines +3. [If guidelines ready] Start your first development task + +What would you like to do first?" diff --git a/.opencode/commands/trellis/parallel.md b/.opencode/commands/trellis/parallel.md new file mode 100644 index 0000000..172f689 --- /dev/null +++ b/.opencode/commands/trellis/parallel.md @@ -0,0 +1,194 @@ +# Multi-Agent Pipeline Orchestrator + +You are the Multi-Agent Pipeline Orchestrator Agent, running in the main repository, responsible for collaborating with users to manage parallel development tasks. + +## Role Definition + +- **You are in the main repository**, not in a worktree +- **You don't write code directly** - code work is done by agents in worktrees +- **You are responsible for planning and dispatching**: discuss requirements, create plans, configure context, start worktree agents +- **Delegate complex analysis to research agent**: finding specs, analyzing code structure + +--- + +## Operation Types + +Operations in this document are categorized as: + +| Marker | Meaning | Executor | +|--------|---------|----------| +| `[AI]` | Bash scripts or Task calls executed by AI | You (AI) | +| `[USER]` | Slash commands executed by user | User | + +--- + +## Startup Flow + +### Step 1: Understand Trellis Workflow `[AI]` + +First, read the workflow guide to understand the development process: + +```bash +cat .trellis/workflow.md # Development process, conventions, and quick start guide +``` + +### Step 2: Get Current Status `[AI]` + +```bash +python3 ./.trellis/scripts/get_context.py +``` + +### Step 3: Read Project Guidelines `[AI]` + +```bash +cat .trellis/spec/frontend/index.md # Frontend guidelines index +cat .trellis/spec/backend/index.md # Backend guidelines index +cat .trellis/spec/guides/index.md # Thinking guides +``` + +### Step 4: Ask User for Requirements + +Ask the user: + +1. What feature to develop? +2. Which modules are involved? +3. Development type? (backend / frontend / fullstack) + +--- + +## Planning: Choose Your Approach + +Based on requirement complexity, choose one of these approaches: + +### Option A: Plan Agent (Recommended for complex features) `[AI]` + +Use when: +- Requirements need analysis and validation +- Multiple modules or cross-layer changes +- Unclear scope that needs research + +```bash +python3 ./.trellis/scripts/multi_agent/plan.py \ + --name "" \ + --type "" \ + --requirement "" \ + --platform opencode +``` + +Plan Agent will: +1. Evaluate requirement validity (may reject if unclear/too large) +2. Call research agent to analyze codebase +3. Create and configure task directory +4. Write prd.md with acceptance criteria +5. Output ready-to-use task directory + +After plan.py completes, start the worktree agent: + +```bash +python3 ./.trellis/scripts/multi_agent/start.py "$TASK_DIR" --platform opencode +``` + +### Option B: Manual Configuration (For simple/clear features) `[AI]` + +Use when: +- Requirements are already clear and specific +- You know exactly which files are involved +- Simple, well-scoped changes + +#### Step 1: Create Task Directory + +```bash +# title is task description, --slug for task directory name +TASK_DIR=$(python3 ./.trellis/scripts/task.py create "" --slug <task-name>) +``` + +#### Step 2: Configure Task + +```bash +# Initialize jsonl context files +python3 ./.trellis/scripts/task.py init-context "$TASK_DIR" <dev_type> + +# Set branch and scope +python3 ./.trellis/scripts/task.py set-branch "$TASK_DIR" feature/<name> +python3 ./.trellis/scripts/task.py set-scope "$TASK_DIR" <scope> +``` + +#### Step 3: Add Context (optional: use research agent) + +```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>" +``` + +#### Step 4: Create prd.md + +```bash +cat > "$TASK_DIR/prd.md" << 'EOF' +# Feature: <name> + +## Requirements +- ... + +## Acceptance Criteria +- ... +EOF +``` + +#### Step 5: Validate and Start + +```bash +python3 ./.trellis/scripts/task.py validate "$TASK_DIR" +python3 ./.trellis/scripts/multi_agent/start.py "$TASK_DIR" --platform opencode +``` + +--- + +## After Starting: Report Status + +Tell the user the agent has started and provide monitoring commands. + +--- + +## User Available Commands `[USER]` + +The following slash commands are for users (not AI): + +| Command | Description | +|---------|-------------| +| `/trellis:parallel` | Start Multi-Agent Pipeline (this command) | +| `/trellis:start` | Start normal development mode (single process) | +| `/trellis:record-session` | Record session progress | +| `/trellis:finish-work` | Pre-completion checklist | + +--- + +## Monitoring Commands (for user reference) + +Tell the user they can use these commands to monitor: + +```bash +python3 ./.trellis/scripts/multi_agent/status.py # Overview +python3 ./.trellis/scripts/multi_agent/status.py --log <name> # View log +python3 ./.trellis/scripts/multi_agent/status.py --watch <name> # Real-time monitoring +python3 ./.trellis/scripts/multi_agent/cleanup.py <branch> # Cleanup worktree +``` + +--- + +## Pipeline Phases + +The dispatch agent in worktree will automatically execute: + +1. implement → Implement feature +2. check → Check code quality +3. finish → Final verification +4. create-pr → Create PR + +--- + +## Core Rules + +- **Don't write code directly** - delegate to agents in worktree +- **Don't execute git commit** - agent does it via create-pr action +- **Delegate complex analysis to research** - finding specs, analyzing code structure +- **Subagents use globally configured model** - inherits from user's OpenCode config diff --git a/.opencode/commands/trellis/record-session.md b/.opencode/commands/trellis/record-session.md new file mode 100644 index 0000000..4a7e6ff --- /dev/null +++ b/.opencode/commands/trellis/record-session.md @@ -0,0 +1,61 @@ +[!] **Prerequisite**: This command should only be used AFTER the human has tested and committed the code. + +**Do NOT run `git commit` directly** — the scripts below handle their own commits for `.trellis/` metadata. You only need to read git history (`git log`, `git status`, `git diff`) and run the Python scripts. + +--- + +## Record Work Progress + +### Step 1: Get Context & Check Tasks + +```bash +python3 ./.trellis/scripts/get_context.py --mode record +``` + +[!] Archive tasks whose work is **actually done** — judge by work status, not the `status` field in task.json: +- Code committed? → Archive it (don't wait for PR) +- All acceptance criteria met? → Archive it +- Don't skip archiving just because `status` still says `planning` or `in_progress` + +```bash +python3 ./.trellis/scripts/task.py archive <task-name> +``` + +### Step 2: One-Click Add Session + +```bash +# Method 1: Simple parameters +python3 ./.trellis/scripts/add_session.py \ + --title "Session Title" \ + --commit "hash1,hash2" \ + --summary "Brief summary of what was done" + +# Method 2: Pass detailed content via stdin +cat << 'EOF' | python3 ./.trellis/scripts/add_session.py --title "Title" --commit "hash" +| Feature | Description | +|---------|-------------| +| New API | Added user authentication endpoint | +| Frontend | Updated login form | + +**Updated Files**: +- `packages/api/modules/auth/router.ts` +- `apps/web/modules/auth/components/login-form.tsx` +EOF +``` + +**Auto-completes**: +- [OK] Appends session to journal-N.md +- [OK] Auto-detects line count, creates new file if >2000 lines +- [OK] Updates index.md (Total Sessions +1, Last Active, line stats, history) +- [OK] Auto-commits .trellis/workspace and .trellis/tasks changes + +--- + +## Script Command Reference + +| Command | Purpose | +|---------|---------| +| `python3 ./.trellis/scripts/get_context.py --mode record` | Get context for record-session | +| `python3 ./.trellis/scripts/add_session.py --title "..." --commit "..."` | **One-click add session (recommended)** | +| `python3 ./.trellis/scripts/task.py archive <name>` | Archive completed task (auto-commits) | +| `python3 ./.trellis/scripts/task.py list` | List active tasks | diff --git a/.opencode/commands/trellis/start.md b/.opencode/commands/trellis/start.md new file mode 100644 index 0000000..4040de8 --- /dev/null +++ b/.opencode/commands/trellis/start.md @@ -0,0 +1,346 @@ +# Start Session + +Initialize your AI development session and begin working on tasks. + +--- + +## Operation Types + +| Marker | Meaning | Executor | +|--------|---------|----------| +| `[AI]` | Bash scripts or Task calls executed by AI | You (AI) | +| `[USER]` | Slash commands executed by user | User | + +--- + +## Initialization `[AI]` + +### Step 1: Understand Development Workflow + +First, read the workflow guide to understand the development process: + +```bash +cat .trellis/workflow.md +``` + +**Follow the instructions in workflow.md** - it contains: +- Core principles (Read Before Write, Follow Standards, etc.) +- File system structure +- Development process +- Best practices + +### Step 2: Get Current Context + +```bash +python3 ./.trellis/scripts/get_context.py +``` + +This shows: developer identity, git status, current task (if any), active tasks. + +### Step 3: Read Guidelines Index + +```bash +cat .trellis/spec/frontend/index.md # Frontend guidelines +cat .trellis/spec/backend/index.md # Backend guidelines +cat .trellis/spec/guides/index.md # Thinking guides +``` + +> **Important**: The index files are navigation — they list the actual guideline files (e.g., `error-handling.md`, `conventions.md`, `mock-strategies.md`). +> At this step, just read the indexes to understand what's available. +> When you start actual development, you MUST go back and read the specific guideline files relevant to your task, as listed in the index's Pre-Development Checklist. + +### Step 4: Report and Ask + +Report what you learned and ask: "What would you like to work on?" + +--- + +## Task Classification + +When user describes a task, classify it: + +| Type | Criteria | Workflow | +|------|----------|----------| +| **Question** | User asks about code, architecture, or how something works | Answer directly | +| **Trivial Fix** | Typo fix, comment update, single-line change, < 5 minutes | Direct Edit | +| **Simple Task** | Clear goal, 1-2 files, well-defined scope | Quick confirm → Task Workflow | +| **Complex Task** | Vague goal, multiple files, architectural decisions | **Brainstorm → Task Workflow** | + +### Decision Rule + +> **If in doubt, use Brainstorm + Task Workflow.** +> +> Task Workflow ensures code-spec context is injected to agents, resulting in higher quality code. +> The overhead is minimal, but the benefit is significant. + +--- + +## Question / Trivial Fix + +For questions or trivial fixes, work directly: + +1. Answer question or make the fix +2. If code was changed, remind user to run `/trellis:finish-work` + +--- + +## Complex Task - Brainstorm First + +For complex or vague tasks, **automatically start the brainstorm process** — do NOT skip directly to implementation. + +See `/trellis:brainstorm` for the full process. Summary: + +1. **Acknowledge and classify** - State your understanding +2. **Create task directory** - Track evolving requirements in `prd.md` +3. **Ask questions one at a time** - Update PRD after each answer +4. **Propose approaches** - For architectural decisions +5. **Confirm final requirements** - Get explicit approval +6. **Proceed to Task Workflow** - With clear requirements in PRD + +> **Subtask Decomposition**: If brainstorm reveals multiple independent work items, +> consider creating subtasks using `--parent` flag or `add-subtask` command. +> See `/trellis:brainstorm` Step 8 for details. + +--- + +## Task Workflow (Development Tasks) + +**Why this workflow?** +- Research Agent analyzes what code-spec files are needed +- Code-spec files are configured in jsonl files +- Implement Agent receives code-spec context via Hook injection +- Check Agent verifies against code-spec requirements +- Result: Code that follows project conventions automatically + +### Overview: Two Entry Points + +``` +From Brainstorm (Complex Task): + PRD confirmed → Research → Configure Context → Activate → Implement → Check → Complete + +From Simple Task: + Confirm → Create Task → Write PRD → Research → Configure Context → Activate → Implement → Check → Complete +``` + +**Key principle: Research happens AFTER requirements are clear (PRD exists).** + +--- + +### Phase 1: Establish Requirements + +#### Path A: From Brainstorm (skip to Phase 2) + +PRD and task directory already exist from brainstorm. Skip directly to Phase 2. + +#### Path B: From Simple Task + +**Step 1: Confirm Understanding** `[AI]` + +Quick confirm: +- What is the goal? +- What type of development? (frontend / backend / fullstack) +- Any specific requirements or constraints? + +**Step 2: Create Task Directory** `[AI]` + +```bash +TASK_DIR=$(python3 ./.trellis/scripts/task.py create "<title>" --slug <name>) +``` + +**Step 3: Write PRD** `[AI]` + +Create `prd.md` in the task directory with: + +```markdown +# <Task Title> + +## Goal +<What we're trying to achieve> + +## Requirements +- <Requirement 1> +- <Requirement 2> + +## Acceptance Criteria +- [ ] <Criterion 1> +- [ ] <Criterion 2> + +## Technical Notes +<Any technical decisions or constraints> +``` + +--- + +### Phase 2: Prepare for Implementation (shared) + +> Both paths converge here. PRD and task directory must exist before proceeding. + +**Step 4: Code-Spec Depth Check** `[AI]` + +If the task touches infra or cross-layer contracts, do not start implementation until code-spec depth is defined. + +Trigger this requirement when the change includes any of: +- New or changed command/API signatures +- Database schema or migration changes +- Infra integrations (storage, queue, cache, secrets, env contracts) +- Cross-layer payload transformations + +Must-have before proceeding: +- [ ] Target code-spec files to update are identified +- [ ] Concrete contract is defined (signature, fields, env keys) +- [ ] Validation and error matrix is defined +- [ ] At least one Good/Base/Bad case is defined + +**Step 5: Research the Codebase** `[AI]` + +Based on the confirmed PRD, call Research Agent to find relevant specs and patterns: + +``` +Task( + subagent_type: "research", + prompt: "Analyze the codebase for this task: + + Task: <goal from PRD> + Type: <frontend/backend/fullstack> + + Please find: + 1. Relevant code-spec files in .trellis/spec/ + 2. Existing code patterns to follow (find 2-3 examples) + 3. Files that will likely need modification + + Output: + ## Relevant Code-Specs + - <path>: <why it's relevant> + + ## Code Patterns Found + - <pattern>: <example file path> + + ## Files to Modify + - <path>: <what change>", + model: "opus" +) +``` + +**Step 6: Configure Context** `[AI]` + +Initialize default context: + +```bash +python3 ./.trellis/scripts/task.py init-context "$TASK_DIR" <type> +# type: backend | frontend | fullstack +``` + +Add code-spec files found by Research Agent: + +```bash +# For each relevant code-spec and code pattern: +python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" implement "<path>" "<reason>" +python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" check "<path>" "<reason>" +``` + +**Step 7: Activate Task** `[AI]` + +```bash +python3 ./.trellis/scripts/task.py start "$TASK_DIR" +``` + +This sets `.current-task` so hooks can inject context. + +--- + +### Phase 3: Execute (shared) + +**Step 8: Implement** `[AI]` + +Call Implement Agent (code-spec context is auto-injected by hook): + +``` +Task( + subagent_type: "implement", + prompt: "Implement the task described in prd.md. + + Follow all code-spec files that have been injected into your context. + Run lint and typecheck before finishing.", + model: "opus" +) +``` + +**Step 9: Check Quality** `[AI]` + +Call Check Agent (code-spec context is auto-injected by hook): + +``` +Task( + subagent_type: "check", + prompt: "Review all code changes against the code-spec requirements. + + Fix any issues you find directly. + Ensure lint and typecheck pass.", + model: "opus" +) +``` + +**Step 10: Complete** `[AI]` + +1. Verify lint and typecheck pass +2. Report what was implemented +3. Remind user to: + - Test the changes + - Commit when ready + - Run `/trellis:record-session` to record this session + +--- + +## Continuing Existing Task + +If `get_context.py` shows a current task: + +1. Read the task's `prd.md` to understand the goal +2. Check `task.json` for current status and phase +3. Ask user: "Continue working on <task-name>?" + +If yes, resume from the appropriate step (usually Step 7 or 8). + +--- + +## Commands Reference + +### User Commands `[USER]` + +| Command | When to Use | +|---------|-------------| +| `/trellis:start` | Begin a session (this command) | +| `/trellis:brainstorm` | Clarify vague requirements (called from start) | +| `/trellis:parallel` | Complex tasks needing isolated worktree | +| `/trellis:finish-work` | Before committing changes | +| `/trellis:record-session` | After completing a task | + +### AI Scripts `[AI]` + +| Script | Purpose | +|--------|---------| +| `python3 ./.trellis/scripts/get_context.py` | Get session context | +| `python3 ./.trellis/scripts/task.py create` | Create task directory | +| `python3 ./.trellis/scripts/task.py init-context` | Initialize jsonl files | +| `python3 ./.trellis/scripts/task.py add-context` | Add code-spec/context file to jsonl | +| `python3 ./.trellis/scripts/task.py start` | Set current task | +| `python3 ./.trellis/scripts/task.py finish` | Clear current task | +| `python3 ./.trellis/scripts/task.py archive` | Archive completed task | + +### Sub Agents `[AI]` + +| Agent | Purpose | Hook Injection | +|-------|---------|----------------| +| research | Analyze codebase | No (reads directly) | +| implement | Write code | Yes (implement.jsonl) | +| check | Review & fix | Yes (check.jsonl) | +| debug | Fix specific issues | Yes (debug.jsonl) | + +--- + +## Key Principle + +> **Code-spec context is injected, not remembered.** +> +> The Task Workflow ensures agents receive relevant code-spec context automatically. +> This is more reliable than hoping the AI "remembers" conventions. diff --git a/.opencode/commands/trellis/update-spec.md b/.opencode/commands/trellis/update-spec.md new file mode 100644 index 0000000..3f0b2e7 --- /dev/null +++ b/.opencode/commands/trellis/update-spec.md @@ -0,0 +1,354 @@ +# Update Code-Spec - Capture Executable Contracts + +When you learn something valuable (from debugging, implementing, or discussion), use this command to update the relevant code-spec documents. + +**Timing**: After completing a task, fixing a bug, or discovering a new pattern + +--- + +## Code-Spec First Rule (CRITICAL) + +In this project, "spec" for implementation work means **code-spec**: +- Executable contracts (not principle-only text) +- Concrete signatures, payload fields, env keys, and boundary behavior +- Testable validation/error behavior + +If the change touches infra or cross-layer contracts, code-spec depth is mandatory. + +### Mandatory Triggers + +Apply code-spec depth when the change includes any of: +- New/changed command or API signature +- Cross-layer request/response contract change +- Database schema/migration change +- Infra integration (storage, queue, cache, secrets, env wiring) + +### Mandatory Output (7 Sections) + +For triggered tasks, include all sections below: +1. Scope / Trigger +2. Signatures (command/API/DB) +3. Contracts (request/response/env) +4. Validation & Error Matrix +5. Good/Base/Bad Cases +6. Tests Required (with assertion points) +7. Wrong vs Correct (at least one pair) + +--- + +## When to Update Code-Specs + +| Trigger | Example | Target Spec | +|---------|---------|-------------| +| **Implemented a feature** | Added template download with giget | Relevant `backend/` or `frontend/` file | +| **Made a design decision** | Used type field + mapping table for extensibility | Relevant code-spec + "Design Decisions" section | +| **Fixed a bug** | Found a subtle issue with error handling | `backend/error-handling.md` | +| **Discovered a pattern** | Found a better way to structure code | Relevant `backend/` or `frontend/` file | +| **Hit a gotcha** | Learned that X must be done before Y | Relevant code-spec + "Common Mistakes" section | +| **Established a convention** | Team agreed on naming pattern | `quality-guidelines.md` | +| **New thinking trigger** | "Don't forget to check X before doing Y" | `guides/*.md` (as a checklist item, not detailed rules) | + +**Key Insight**: Code-spec updates are NOT just for problems. Every feature implementation contains design decisions and contracts that future AI/developers need to execute safely. + +--- + +## Spec Structure Overview + +``` +.trellis/spec/ +├── backend/ # Backend coding standards +│ ├── index.md # Overview and links +│ └── *.md # Topic-specific guidelines +├── frontend/ # Frontend coding standards +│ ├── index.md # Overview and links +│ └── *.md # Topic-specific guidelines +└── guides/ # Thinking checklists (NOT coding specs!) + ├── index.md # Guide index + └── *.md # Topic-specific guides +``` + +### CRITICAL: Code-Spec vs Guide - Know the Difference + +| Type | Location | Purpose | Content Style | +|------|----------|---------|---------------| +| **Code-Spec** | `backend/*.md`, `frontend/*.md` | Tell AI "how to implement safely" | Signatures, contracts, matrices, cases, test points | +| **Guide** | `guides/*.md` | Help AI "what to think about" | Checklists, questions, pointers to specs | + +**Decision Rule**: Ask yourself: + +- "This is **how to write** the code" → Put in `backend/` or `frontend/` +- "This is **what to consider** before writing" → Put in `guides/` + +**Example**: + +| Learning | Wrong Location | Correct Location | +|----------|----------------|------------------| +| "Use `reconfigure()` not `TextIOWrapper` for Windows stdout" | ❌ `guides/cross-platform-thinking-guide.md` | ✅ `backend/script-conventions.md` | +| "Remember to check encoding when writing cross-platform code" | ❌ `backend/script-conventions.md` | ✅ `guides/cross-platform-thinking-guide.md` | + +**Guides should be short checklists that point to specs**, not duplicate the detailed rules. + +--- + +## Update Process + +### Step 1: Identify What You Learned + +Answer these questions: + +1. **What did you learn?** (Be specific) +2. **Why is it important?** (What problem does it prevent?) +3. **Where does it belong?** (Which spec file?) + +### Step 2: Classify the Update Type + +| Type | Description | Action | +|------|-------------|--------| +| **Design Decision** | Why we chose approach X over Y | Add to "Design Decisions" section | +| **Project Convention** | How we do X in this project | Add to relevant section with examples | +| **New Pattern** | A reusable approach discovered | Add to "Patterns" section | +| **Forbidden Pattern** | Something that causes problems | Add to "Anti-patterns" or "Don't" section | +| **Common Mistake** | Easy-to-make error | Add to "Common Mistakes" section | +| **Convention** | Agreed-upon standard | Add to relevant section | +| **Gotcha** | Non-obvious behavior | Add warning callout | + +### Step 3: Read the Target Code-Spec + +Before editing, read the current code-spec to: +- Understand existing structure +- Avoid duplicating content +- Find the right section for your update + +```bash +cat .trellis/spec/<category>/<file>.md +``` + +### Step 4: Make the Update + +Follow these principles: + +1. **Be Specific**: Include concrete examples, not just abstract rules +2. **Explain Why**: State the problem this prevents +3. **Show Contracts**: Add signatures, payload fields, and error behavior +4. **Show Code**: Add code snippets for key patterns +5. **Keep it Short**: One concept per section + +### Step 5: Update the Index (if needed) + +If you added a new section or the code-spec status changed, update the category's `index.md`. + +--- + +## Update Templates + +### Mandatory Template for Infra/Cross-Layer Work + +```markdown +## Scenario: <name> + +### 1. Scope / Trigger +- Trigger: <why this requires code-spec depth> + +### 2. Signatures +- Backend command/API/DB signature(s) + +### 3. Contracts +- Request fields (name, type, constraints) +- Response fields (name, type, constraints) +- Environment keys (required/optional) + +### 4. Validation & Error Matrix +- <condition> -> <error> + +### 5. Good/Base/Bad Cases +- Good: ... +- Base: ... +- Bad: ... + +### 6. Tests Required +- Unit/Integration/E2E with assertion points + +### 7. Wrong vs Correct +#### Wrong +... +#### Correct +... +``` + +### Adding a Design Decision + +```markdown +### Design Decision: [Decision Name] + +**Context**: What problem were we solving? + +**Options Considered**: +1. Option A - brief description +2. Option B - brief description + +**Decision**: We chose Option X because... + +**Example**: +\`\`\`typescript +// How it's implemented +code example +\`\`\` + +**Extensibility**: How to extend this in the future... +``` + +### Adding a Project Convention + +```markdown +### Convention: [Convention Name] + +**What**: Brief description of the convention. + +**Why**: Why we do it this way in this project. + +**Example**: +\`\`\`typescript +// How to follow this convention +code example +\`\`\` + +**Related**: Links to related conventions or specs. +``` + +### Adding a New Pattern + +```markdown +### Pattern Name + +**Problem**: What problem does this solve? + +**Solution**: Brief description of the approach. + +**Example**: +\`\`\` +// Good +code example + +// Bad +code example +\`\`\` + +**Why**: Explanation of why this works better. +``` + +### Adding a Forbidden Pattern + +```markdown +### Don't: Pattern Name + +**Problem**: +\`\`\` +// Don't do this +bad code example +\`\`\` + +**Why it's bad**: Explanation of the issue. + +**Instead**: +\`\`\` +// Do this instead +good code example +\`\`\` +``` + +### Adding a Common Mistake + +```markdown +### Common Mistake: Description + +**Symptom**: What goes wrong + +**Cause**: Why this happens + +**Fix**: How to correct it + +**Prevention**: How to avoid it in the future +``` + +### Adding a Gotcha + +```markdown +> **Warning**: Brief description of the non-obvious behavior. +> +> Details about when this happens and how to handle it. +``` + +--- + +## Interactive Mode + +If you're unsure what to update, answer these prompts: + +1. **What did you just finish?** + - [ ] Fixed a bug + - [ ] Implemented a feature + - [ ] Refactored code + - [ ] Had a discussion about approach + +2. **What did you learn or decide?** + - Design decision (why X over Y) + - Project convention (how we do X) + - Non-obvious behavior (gotcha) + - Better approach (pattern) + +3. **Would future AI/developers need to know this?** + - To understand how the code works → Yes, update spec + - To maintain or extend the feature → Yes, update spec + - To avoid repeating mistakes → Yes, update spec + - Purely one-off implementation detail → Maybe skip + +4. **Which area does it relate to?** + - [ ] Backend code + - [ ] Frontend code + - [ ] Cross-layer data flow + - [ ] Code organization/reuse + - [ ] Quality/testing + +--- + +## Quality Checklist + +Before finishing your code-spec update: + +- [ ] Is the content specific and actionable? +- [ ] Did you include a code example? +- [ ] Did you explain WHY, not just WHAT? +- [ ] Did you include executable signatures/contracts? +- [ ] Did you include validation and error matrix? +- [ ] Did you include Good/Base/Bad cases? +- [ ] Did you include required tests with assertion points? +- [ ] Is it in the right code-spec file? +- [ ] Does it duplicate existing content? +- [ ] Would a new team member understand it? + +--- + +## Relationship to Other Commands + +``` +Development Flow: + Learn something → /trellis:update-spec → Knowledge captured + ↑ ↓ + /trellis:break-loop ←──────────────────── Future sessions benefit + (deep bug analysis) +``` + +- `/trellis:break-loop` - Analyzes bugs deeply, often reveals spec updates needed +- `/trellis:update-spec` - Actually makes the updates (this command) +- `/trellis:finish-work` - Reminds you to check if specs need updates + +--- + +## Core Philosophy + +> **Code-specs are living documents. Every debugging session, every "aha moment" is an opportunity to make the implementation contract clearer.** + +The goal is **institutional memory**: +- What one person learns, everyone benefits from +- What AI learns in one session, persists to future sessions +- Mistakes become documented guardrails diff --git a/.opencode/lib/trellis-context.js b/.opencode/lib/trellis-context.js new file mode 100644 index 0000000..972802d --- /dev/null +++ b/.opencode/lib/trellis-context.js @@ -0,0 +1,436 @@ +/** + * Trellis Context Manager + * + * Unified context management for OpenCode plugins. + * Handles detection of oh-my-opencode, .claude/hooks/, and other edge cases. + * + * Usage: + * import { TrellisContext } from "./trellis-context.js" + * const ctx = new TrellisContext(directory) + * if (ctx.shouldSkipHook("session-start")) return + */ + +import { existsSync, readFileSync, appendFileSync, readdirSync } from "fs" +import { join } from "path" +import { homedir, platform } from "os" +import { execSync } from "child_process" + +// Python command: Windows uses 'python', macOS/Linux use 'python3' +const PYTHON_CMD = platform() === "win32" ? "python" : "python3" + +// Debug logging +const DEBUG_LOG = "/tmp/trellis-plugin-debug.log" + +function debugLog(prefix, ...args) { + const timestamp = new Date().toISOString() + const msg = `[${timestamp}] [${prefix}] ${args.map(a => typeof a === "object" ? JSON.stringify(a) : a).join(" ")}\n` + try { + appendFileSync(DEBUG_LOG, msg) + } catch { + // ignore + } +} + +/** + * Trellis Context Manager + * + * Centralized logic for: + * - Detecting oh-my-opencode installation + * - Checking .claude/hooks/ presence + * - Determining which plugin should handle each hook + */ +export class TrellisContext { + constructor(directory) { + this.directory = directory + this._omoInstalled = null + this._omoHooksEnabled = null + this._claudeHooksPresent = {} + + debugLog("context", "TrellisContext initialized", { directory }) + } + + // ============================================================ + // oh-my-opencode Detection + // ============================================================ + + /** + * Check if oh-my-opencode is installed + * + * Detection order: + * 1. Check if oh-my-opencode.json exists (most reliable) + * 2. Fallback: check opencode.json plugin list + */ + isOmoInstalled() { + if (this._omoInstalled !== null) { + return this._omoInstalled + } + + try { + const configDir = join(homedir(), ".config", "opencode") + + // Method 1: Check oh-my-opencode.json existence (omo-specific config) + const omoConfigPath = join(configDir, "oh-my-opencode.json") + if (existsSync(omoConfigPath)) { + this._omoInstalled = true + debugLog("context", "omo installed: oh-my-opencode.json exists") + return true + } + + // Method 2: Fallback to plugin list check + const configPath = join(configDir, "opencode.json") + if (!existsSync(configPath)) { + this._omoInstalled = false + debugLog("context", "omo not installed: no config files") + return false + } + + const content = readFileSync(configPath, "utf-8") + const config = JSON.parse(content) + const plugins = config.plugin || [] + + this._omoInstalled = plugins.some(p => + typeof p === "string" && p.toLowerCase().includes("oh-my-opencode") + ) + + debugLog("context", "omo installed (plugin list):", this._omoInstalled) + return this._omoInstalled + } catch (e) { + debugLog("context", "omo detection error:", e.message) + this._omoInstalled = false + return false + } + } + + /** + * Check if omo's claude_code.hooks is enabled + * Reads oh-my-opencode.json or defaults to true + */ + isOmoHooksEnabled() { + if (this._omoHooksEnabled !== null) { + return this._omoHooksEnabled + } + + if (!this.isOmoInstalled()) { + this._omoHooksEnabled = false + return false + } + + try { + // Check global config + const globalConfig = join(homedir(), ".config", "opencode", "oh-my-opencode.json") + if (existsSync(globalConfig)) { + const content = readFileSync(globalConfig, "utf-8") + const config = JSON.parse(content) + if (config.claude_code?.hooks === false) { + this._omoHooksEnabled = false + debugLog("context", "omo hooks disabled in global config") + return false + } + } + + // Check project config + const projectConfig = join(this.directory, "oh-my-opencode.json") + if (existsSync(projectConfig)) { + const content = readFileSync(projectConfig, "utf-8") + const config = JSON.parse(content) + if (config.claude_code?.hooks === false) { + this._omoHooksEnabled = false + debugLog("context", "omo hooks disabled in project config") + return false + } + } + + // Default: enabled + this._omoHooksEnabled = true + debugLog("context", "omo hooks enabled (default)") + return true + } catch (e) { + debugLog("context", "omo hooks detection error:", e.message) + this._omoHooksEnabled = true // Default to enabled + return true + } + } + + // ============================================================ + // .claude/hooks/ Detection + // ============================================================ + + /** + * Check if a specific .claude/hooks/ file exists + */ + hasClaudeHook(hookName) { + if (hookName in this._claudeHooksPresent) { + return this._claudeHooksPresent[hookName] + } + + const hookPath = join(this.directory, ".claude", "hooks", `${hookName}.py`) + const exists = existsSync(hookPath) + + this._claudeHooksPresent[hookName] = exists + debugLog("context", `claude hook ${hookName}:`, exists) + return exists + } + + // ============================================================ + // Trellis Project Detection + // ============================================================ + + /** + * Check if this is a Trellis-managed project + */ + isTrellisProject() { + return existsSync(join(this.directory, ".trellis")) + } + + /** + * Get current task directory from .trellis/.current-task + */ + getCurrentTask() { + try { + const currentTaskPath = join(this.directory, ".trellis", ".current-task") + if (!existsSync(currentTaskPath)) { + return null + } + return readFileSync(currentTaskPath, "utf-8").trim() + } catch { + return null + } + } + + // ============================================================ + // Hook Decision Logic + // ============================================================ + + /** + * Determine if our plugin should skip this hook + * (because omo will handle it via .claude/hooks/) + * + * @param {string} hookName - Hook name without extension (e.g., "session-start") + * @returns {boolean} - true if we should skip, false if we should handle + */ + shouldSkipHook(hookName) { + // Not a Trellis project? Skip. + if (!this.isTrellisProject()) { + debugLog("context", `shouldSkipHook(${hookName}): skip - not Trellis project`) + return true + } + + // omo not installed? We handle it. + if (!this.isOmoInstalled()) { + debugLog("context", `shouldSkipHook(${hookName}): handle - omo not installed`) + return false + } + + // omo installed but hooks disabled? We handle it. + if (!this.isOmoHooksEnabled()) { + debugLog("context", `shouldSkipHook(${hookName}): handle - omo hooks disabled`) + return false + } + + // omo installed + hooks enabled + .claude/hooks/ exists? Skip (omo handles). + if (this.hasClaudeHook(hookName)) { + debugLog("context", `shouldSkipHook(${hookName}): skip - omo will handle via .claude/hooks/`) + return true + } + + // omo installed but no .claude/hooks/ file? We handle it. + debugLog("context", `shouldSkipHook(${hookName}): handle - no .claude/hooks/ file`) + return false + } + + // ============================================================ + // File Reading Utilities + // ============================================================ + + /** + * Read a file, return null on error + */ + readFile(filePath) { + try { + if (existsSync(filePath)) { + return readFileSync(filePath, "utf-8") + } + } catch { + // Ignore read errors + } + return null + } + + /** + * Read a file relative to project directory + */ + readProjectFile(relativePath) { + return this.readFile(join(this.directory, relativePath)) + } + + /** + * Run a Python script and return output + */ + runScript(scriptPath, cwd = null) { + try { + const result = execSync(`${PYTHON_CMD} "${scriptPath}"`, { + cwd: cwd || this.directory, + timeout: 10000, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"] + }) + return result || "" + } catch { + return "" + } + } + + // ============================================================ + // JSONL Reading + // ============================================================ + + /** + * Read all .md files in a directory + * @param {string} dirPath - Directory path relative to project root + * @param {number} maxFiles - Max files to read (prevent huge directories) + * @returns {Array<{path: string, content: string}>} + */ + readDirectoryMdFiles(dirPath, maxFiles = 20) { + const results = [] + const fullPath = join(this.directory, dirPath) + + if (!existsSync(fullPath)) { + return results + } + + try { + const files = readdirSync(fullPath) + .filter(f => f.endsWith(".md")) + .sort() + .slice(0, maxFiles) + + for (const filename of files) { + const filePath = join(dirPath, filename) + const content = this.readProjectFile(filePath) + if (content) { + results.push({ path: filePath, content }) + } + } + } catch { + // Ignore directory read errors + } + + return results + } + + /** + * Read a JSONL file and load referenced files/directories + * Supports: + * {"file": "path/to/file.md", "reason": "..."} + * {"file": "path/to/dir/", "type": "directory", "reason": "..."} + */ + readJsonlWithFiles(jsonlPath) { + const results = [] + const content = this.readFile(jsonlPath) + if (!content) return results + + for (const line of content.split("\n")) { + if (!line.trim()) continue + try { + const item = JSON.parse(line) + const file = item.file || item.path + const entryType = item.type || "file" + + if (!file) continue + + if (entryType === "directory") { + // Read all .md files in directory + const dirEntries = this.readDirectoryMdFiles(file) + results.push(...dirEntries) + } else { + // Read single file + const fullPath = join(this.directory, file) + const fileContent = this.readFile(fullPath) + if (fileContent) { + results.push({ path: file, content: fileContent }) + } + } + } catch { + // Ignore parse errors for individual lines + } + } + return results + } + + /** + * Build context string from file entries + */ + buildContextFromEntries(entries) { + return entries.map(e => `=== ${e.path} ===\n${e.content}`).join("\n\n") + } +} + +// ============================================================ +// Context Collector (for synthetic message injection) +// ============================================================ + +/** + * Simple context collector for cross-hook communication + * Similar to oh-my-opencode's contextCollector + */ +class ContextCollector { + constructor() { + this.pending = new Map() + this.processed = new Set() + } + + /** + * Store context for a session + */ + store(sessionID, content) { + this.pending.set(sessionID, { + content, + timestamp: Date.now() + }) + debugLog("collector", "stored context for session:", sessionID, "length:", content.length) + } + + /** + * Check if session has pending context + */ + hasPending(sessionID) { + return this.pending.has(sessionID) + } + + /** + * Get and consume pending context + */ + consume(sessionID) { + const pending = this.pending.get(sessionID) + this.pending.delete(sessionID) + return pending + } + + /** + * Mark session as processed (for first-message-only injection) + */ + markProcessed(sessionID) { + this.processed.add(sessionID) + } + + /** + * Check if session was already processed + */ + isProcessed(sessionID) { + return this.processed.has(sessionID) + } + + /** + * Clear session state + */ + clear(sessionID) { + this.pending.delete(sessionID) + this.processed.delete(sessionID) + } +} + +// Singleton instance +export const contextCollector = new ContextCollector() + +// Export debug log for plugins +export { debugLog } diff --git a/.opencode/plugin/inject-subagent-context.js b/.opencode/plugin/inject-subagent-context.js new file mode 100644 index 0000000..3ccbed7 --- /dev/null +++ b/.opencode/plugin/inject-subagent-context.js @@ -0,0 +1,538 @@ +/** + * Trellis Context Injection Plugin + * + * Injects context when Task tool is called with supported subagent types. + * Uses OpenCode's tool.execute.before hook. + * + * Compatibility: + * - If oh-my-opencode handles via .claude/hooks/, this plugin skips + * - Otherwise, this plugin handles injection + */ + +import { existsSync, writeFileSync } from "fs" +import { join } from "path" +import { TrellisContext, debugLog } from "../lib/trellis-context.js" + +// Supported subagent types +const AGENTS_ALL = ["implement", "check", "debug", "research"] +const AGENTS_REQUIRE_TASK = ["implement", "check", "debug"] +// Agents that don't update phase (can be called at any time) +const AGENTS_NO_PHASE_UPDATE = ["debug", "research"] + +/** + * Update current_phase in task.json based on subagent_type + */ +function updateCurrentPhase(ctx, taskDir, subagentType) { + if (AGENTS_NO_PHASE_UPDATE.includes(subagentType)) { + return + } + + const taskJsonPath = join(ctx.directory, taskDir, "task.json") + const content = ctx.readFile(taskJsonPath) + if (!content) return + + try { + const taskData = JSON.parse(content) + const currentPhase = taskData.current_phase || 0 + const nextActions = taskData.next_action || [] + + // Map action names to subagent types + const actionToAgent = { + "implement": "implement", + "check": "check", + "finish": "check" // finish uses check agent + } + + // Find the next phase that matches this subagent_type + let newPhase = null + for (const action of nextActions) { + const phaseNum = action.phase || 0 + const actionName = action.action || "" + const expectedAgent = actionToAgent[actionName] + + // Only consider phases after current_phase + if (phaseNum > currentPhase && expectedAgent === subagentType) { + newPhase = phaseNum + break + } + } + + if (newPhase !== null) { + taskData.current_phase = newPhase + writeFileSync(taskJsonPath, JSON.stringify(taskData, null, 2)) + debugLog("inject", "Updated current_phase to:", newPhase) + } + } catch (e) { + debugLog("inject", "Error updating phase:", e.message) + } +} + +/** + * Get context for implement agent + */ +function getImplementContext(ctx, taskDir) { + const parts = [] + + // 1. Read implement.jsonl (or fallback to spec.jsonl) + let jsonlPath = join(ctx.directory, taskDir, "implement.jsonl") + let entries = ctx.readJsonlWithFiles(jsonlPath) + + if (entries.length === 0) { + // Fallback to spec.jsonl + jsonlPath = join(ctx.directory, taskDir, "spec.jsonl") + entries = ctx.readJsonlWithFiles(jsonlPath) + } + + if (entries.length > 0) { + parts.push(ctx.buildContextFromEntries(entries)) + } + + // 2. Requirements document + const prd = ctx.readProjectFile(join(taskDir, "prd.md")) + if (prd) { + parts.push(`=== ${taskDir}/prd.md (Requirements) ===\n${prd}`) + } + + // 3. Technical design + const info = ctx.readProjectFile(join(taskDir, "info.md")) + if (info) { + parts.push(`=== ${taskDir}/info.md (Technical Design) ===\n${info}`) + } + + return parts.join("\n\n") +} + +/** + * Get context for check agent + */ +function getCheckContext(ctx, taskDir) { + const parts = [] + + // 1. Read check.jsonl + const jsonlPath = join(ctx.directory, taskDir, "check.jsonl") + const entries = ctx.readJsonlWithFiles(jsonlPath) + + if (entries.length > 0) { + parts.push(ctx.buildContextFromEntries(entries)) + } else { + // Fallback: hardcoded check files + spec.jsonl + const checkFiles = [ + [".opencode/commands/trellis/finish-work.md", "Finish work checklist"], + [".opencode/commands/trellis/check-cross-layer.md", "Cross-layer check spec"], + [".opencode/commands/trellis/check-backend.md", "Backend check spec"], + [".opencode/commands/trellis/check-frontend.md", "Frontend check spec"], + ] + for (const [f, description] of checkFiles) { + const content = ctx.readProjectFile(f) + if (content) { + parts.push(`=== ${f} (${description}) ===\n${content}`) + } + } + + // Add spec.jsonl + const specJsonlPath = join(ctx.directory, taskDir, "spec.jsonl") + const specEntries = ctx.readJsonlWithFiles(specJsonlPath) + for (const entry of specEntries) { + parts.push(`=== ${entry.path} (Dev spec) ===\n${entry.content}`) + } + } + + // 2. Requirements document + const prd = ctx.readProjectFile(join(taskDir, "prd.md")) + if (prd) { + parts.push(`=== ${taskDir}/prd.md (Requirements - for understanding intent) ===\n${prd}`) + } + + return parts.join("\n\n") +} + +/** + * Get context for finish phase (final check before PR) + */ +function getFinishContext(ctx, taskDir) { + const parts = [] + + // 1. Try finish.jsonl first + const jsonlPath = join(ctx.directory, taskDir, "finish.jsonl") + const entries = ctx.readJsonlWithFiles(jsonlPath) + + if (entries.length > 0) { + parts.push(ctx.buildContextFromEntries(entries)) + } else { + // Fallback: only finish-work.md (lightweight) + const finishWork = ctx.readProjectFile(".opencode/commands/trellis/finish-work.md") + if (finishWork) { + parts.push(`=== .opencode/commands/trellis/finish-work.md (Finish checklist) ===\n${finishWork}`) + } + } + + // 2. Spec update process (for active spec sync) + const updateSpec = ctx.readProjectFile(".opencode/commands/trellis/update-spec.md") + if (updateSpec) { + parts.push(`=== .opencode/commands/trellis/update-spec.md (Spec update process) ===\n${updateSpec}`) + } + + // 3. Requirements document (for verifying requirements are met) + const prd = ctx.readProjectFile(join(taskDir, "prd.md")) + if (prd) { + parts.push(`=== ${taskDir}/prd.md (Requirements - verify all met) ===\n${prd}`) + } + + return parts.join("\n\n") +} + +/** + * Get context for debug agent + */ +function getDebugContext(ctx, taskDir) { + const parts = [] + + // 1. Read debug.jsonl (or fallback to spec.jsonl + check files) + const jsonlPath = join(ctx.directory, taskDir, "debug.jsonl") + const entries = ctx.readJsonlWithFiles(jsonlPath) + + if (entries.length > 0) { + parts.push(ctx.buildContextFromEntries(entries)) + } else { + // Fallback: use spec.jsonl + hardcoded check files + const specJsonlPath = join(ctx.directory, taskDir, "spec.jsonl") + const specEntries = ctx.readJsonlWithFiles(specJsonlPath) + for (const entry of specEntries) { + parts.push(`=== ${entry.path} (Dev spec) ===\n${entry.content}`) + } + + const checkFiles = [ + [".opencode/commands/trellis/check-backend.md", "Backend check spec"], + [".opencode/commands/trellis/check-frontend.md", "Frontend check spec"], + [".opencode/commands/trellis/check-cross-layer.md", "Cross-layer check spec"], + ] + for (const [f, description] of checkFiles) { + const content = ctx.readProjectFile(f) + if (content) { + parts.push(`=== ${f} (${description}) ===\n${content}`) + } + } + } + + // 2. Codex review output (if exists) + const codex = ctx.readProjectFile(join(taskDir, "codex-review-output.txt")) + if (codex) { + parts.push(`=== ${taskDir}/codex-review-output.txt (Codex Review Results) ===\n${codex}`) + } + + return parts.join("\n\n") +} + +/** + * Get context for research agent + */ +function getResearchContext(ctx, taskDir) { + const parts = [] + + parts.push(`## Project Spec Directory Structure + +\`\`\` +.trellis/spec/ +├── shared/ # Cross-project common specs +├── frontend/ # Frontend standards +├── backend/ # Backend standards +└── guides/ # Thinking guides + +.trellis/big-question/ # Known issues and pitfalls +\`\`\` + +## Search Tips + +- Spec files: \`.trellis/spec/**/*.md\` +- Known issues: \`.trellis/big-question/\` +- Code search: Use Glob and Grep tools +- Tech solutions: Use mcp__exa__web_search_exa or mcp__exa__get_code_context_exa`) + + if (taskDir) { + const jsonlPath = join(ctx.directory, taskDir, "research.jsonl") + const entries = ctx.readJsonlWithFiles(jsonlPath) + if (entries.length > 0) { + parts.push("\n## Additional Search Context\n") + parts.push(ctx.buildContextFromEntries(entries)) + } + } + + return parts.join("\n\n") +} + +/** + * Build enhanced prompt with context + */ +function buildPrompt(agentType, originalPrompt, context, isFinish = false) { + const templates = { + implement: `# Implement Agent Task + +You are the Implement Agent in the Multi-Agent Pipeline. + +## Your Context + +${context} + +--- + +## Your Task + +${originalPrompt} + +--- + +## Workflow + +1. **Understand specs** - All dev specs are injected above +2. **Understand requirements** - Read requirements and technical design +3. **Implement feature** - Follow specs and design +4. **Self-check** - Ensure code quality + +## Important Constraints + +- Do NOT execute git commit +- Follow all dev specs injected above +- Report list of modified/created files when done`, + + check: isFinish ? `# Finish Agent Task + +You are performing the final check before creating a PR. + +## Your Context + +${context} + +--- + +## Your Task + +${originalPrompt} + +--- + +## Workflow + +1. **Review changes** - Run \`git diff --name-only\` to see all changed files +2. **Verify requirements** - Check each requirement in prd.md is implemented +3. **Spec sync** - Analyze whether changes introduce new patterns, contracts, or conventions + - If new pattern/convention found: read target spec file → update it → update index.md if needed + - If infra/cross-layer change: follow the 7-section mandatory template from update-spec.md + - If pure code fix with no new patterns: skip this step +4. **Run final checks** - Execute lint and typecheck +5. **Confirm ready** - Ensure code is ready for PR + +## Important Constraints + +- You MAY update spec files when gaps are detected (use update-spec.md as guide) +- MUST read the target spec file BEFORE editing (avoid duplicating existing content) +- Do NOT update specs for trivial changes (typos, formatting, obvious fixes) +- If critical CODE issues found, report them clearly (fix specs, not code) +- Verify all acceptance criteria in prd.md are met` : + `# Check Agent Task + +You are the Check Agent in the Multi-Agent Pipeline. + +## Your Context + +${context} + +--- + +## Your Task + +${originalPrompt} + +--- + +## Workflow + +1. **Get changes** - Run \`git diff --name-only\` and \`git diff\` +2. **Check against specs** - Check item by item +3. **Self-fix** - Fix issues directly, don't just report +4. **Run verification** - Run lint and typecheck + +## Important Constraints + +- Fix issues yourself, don't just report +- Must execute complete checklist`, + + debug: `# Debug Agent Task + +You are the Debug Agent in the Multi-Agent Pipeline. + +## Your Context + +${context} + +--- + +## Your Task + +${originalPrompt} + +--- + +## Workflow + +1. **Understand issues** - Analyze issues pointed out +2. **Locate code** - Find positions that need fixing +3. **Fix against specs** - Fix following dev specs +4. **Verify fixes** - Run typecheck + +## Important Constraints + +- Do NOT execute git commit +- Run typecheck after each fix`, + + research: `# Research Agent Task + +You are the Research Agent in the Multi-Agent Pipeline. + +## Core Principle + +**You do one thing: find and explain information.** + +## Project Info + +${context} + +--- + +## Your Task + +${originalPrompt} + +--- + +## Workflow + +1. **Understand query** - Determine search type and scope +2. **Plan search** - List search steps +3. **Execute search** - Run multiple searches in parallel +4. **Organize results** - Output structured report + +## Strict Boundaries + +**Only allowed**: Describe what exists, where it is, how it works + +**Forbidden**: Suggest improvements, criticize implementation, modify files` + } + + return templates[agentType] || originalPrompt +} + +export default async ({ directory }) => { + const ctx = new TrellisContext(directory) + debugLog("inject", "Plugin loaded, directory:", directory) + + return { + // ========================================================================== + // ⚠️ KNOWN LIMITATION: OpenCode project-level plugins cannot intercept subagents + // + // This hook will NOT be triggered because: + // 1. Project-level plugins (.opencode/plugin/) don't support tool.execute.before + // 2. Only global plugins (npm packages) have full hook permissions + // 3. This is a known OpenCode architecture limitation (see Issue #5894) + // + // SOLUTION: Trellis + OpenCode users must install oh-my-opencode (omo) + // - omo is a global plugin with full hook permissions + // - omo reads .claude/settings.json and executes Python hooks + // - .claude/hooks/inject-subagent-context.py handles the actual injection + // + // References: + // - https://github.com/sst/opencode/issues/5894 (plugin hooks don't intercept subagent) + // - https://github.com/sst/opencode/issues/2588 (subagent inherit context) + // ========================================================================== + "tool.execute.before": async (input, output) => { + try { + debugLog("inject", "tool.execute.before called, tool:", input?.tool) + + // Only handle Task tool + const toolName = input?.tool?.toLowerCase() + if (toolName !== "task") { + return + } + + const args = output?.args || {} + const subagentType = args.subagent_type + const originalPrompt = args.prompt || "" + + debugLog("inject", "Task tool called, subagent_type:", subagentType) + + // Only handle supported agent types + if (!AGENTS_ALL.includes(subagentType)) { + debugLog("inject", "Skipping - unsupported subagent_type") + return + } + + // Check if we should skip (omo will handle) + if (ctx.shouldSkipHook("inject-subagent-context")) { + debugLog("inject", "Skipping - omo will handle via .claude/hooks/") + return + } + + // Read current task + const taskDir = ctx.getCurrentTask() + + // Agents requiring task directory + if (AGENTS_REQUIRE_TASK.includes(subagentType)) { + if (!taskDir) { + debugLog("inject", "Skipping - no current task") + return + } + const taskDirFull = join(directory, taskDir) + if (!existsSync(taskDirFull)) { + debugLog("inject", "Skipping - task directory not found") + return + } + + // Update current_phase in task.json + updateCurrentPhase(ctx, taskDir, subagentType) + } + + // Check for [finish] marker + const isFinish = originalPrompt.toLowerCase().includes("[finish]") + + // Get context based on agent type + let context = "" + switch (subagentType) { + case "implement": + context = getImplementContext(ctx, taskDir) + break + case "check": + // Use finish context for [finish] phase (lighter, focused on final verification) + // Use check context for regular check (full specs for self-fix loop) + context = isFinish + ? getFinishContext(ctx, taskDir) + : getCheckContext(ctx, taskDir) + break + case "debug": + context = getDebugContext(ctx, taskDir) + break + case "research": + context = getResearchContext(ctx, taskDir) + break + } + + if (!context) { + debugLog("inject", "No context to inject") + return + } + + // Build enhanced prompt + const newPrompt = buildPrompt(subagentType, originalPrompt, context, isFinish) + + // Update the tool input + output.args = { + ...args, + prompt: newPrompt + } + + debugLog("inject", "Injected context for", subagentType, "prompt length:", newPrompt.length) + + } catch (error) { + debugLog("inject", "Error in tool.execute.before:", error.message, error.stack) + } + } + } +} diff --git a/.opencode/plugin/session-start.js b/.opencode/plugin/session-start.js new file mode 100644 index 0000000..d455d66 --- /dev/null +++ b/.opencode/plugin/session-start.js @@ -0,0 +1,325 @@ +/* global process */ +/** + * Trellis Session Start Plugin + * + * Injects context when user sends the first message in a session. + * Uses OpenCode's chat.message + experimental.chat.messages.transform hooks. + * + * Compatibility: + * - If oh-my-opencode handles via .claude/hooks/, this plugin skips + * - Otherwise, this plugin handles injection + */ + +import { existsSync, readFileSync, readdirSync, statSync } from "fs" +import { join } from "path" +import { TrellisContext, contextCollector, debugLog } from "../lib/trellis-context.js" + + +/** + * Check current task status and return structured status string. + * JavaScript equivalent of _get_task_status in Claude's session-start.py. + */ +function getTaskStatus(directory) { + const trellisDir = join(directory, ".trellis") + const currentTaskFile = join(trellisDir, ".current-task") + + if (!existsSync(currentTaskFile)) { + return "Status: NO ACTIVE TASK\nNext: Describe what you want to work on" + } + + let taskRef + try { + taskRef = readFileSync(currentTaskFile, "utf-8").trim() + } catch { + return "Status: NO ACTIVE TASK\nNext: Describe what you want to work on" + } + + if (!taskRef) { + return "Status: NO ACTIVE TASK\nNext: Describe what you want to work on" + } + + // Resolve task directory + let taskDir + if (taskRef.startsWith("/")) { + taskDir = taskRef + } else if (taskRef.startsWith(".trellis/")) { + taskDir = join(directory, taskRef) + } else { + taskDir = join(trellisDir, "tasks", taskRef) + } + + if (!existsSync(taskDir)) { + return `Status: STALE POINTER\nTask: ${taskRef}\nNext: Task directory not found. Run: python3 ./.trellis/scripts/task.py finish` + } + + // Read task.json + let taskData = {} + const taskJsonPath = join(taskDir, "task.json") + if (existsSync(taskJsonPath)) { + try { + taskData = JSON.parse(readFileSync(taskJsonPath, "utf-8")) + } catch { + // Ignore parse errors + } + } + + const taskTitle = taskData.title || taskRef + const taskStatus = taskData.status || "unknown" + + if (taskStatus === "completed") { + const dirName = taskDir.split("/").pop() + return `Status: COMPLETED\nTask: ${taskTitle}\nNext: Archive with \`python3 ./.trellis/scripts/task.py archive ${dirName}\` or start a new task` + } + + // Check if context is configured (jsonl files exist and non-empty) + let hasContext = false + for (const jsonlName of ["implement.jsonl", "check.jsonl", "spec.jsonl"]) { + const jsonlPath = join(taskDir, jsonlName) + if (existsSync(jsonlPath)) { + try { + const st = statSync(jsonlPath) + if (st.size > 0) { + hasContext = true + break + } + } catch { + // Ignore stat errors + } + } + } + + const hasPrd = existsSync(join(taskDir, "prd.md")) + + if (!hasPrd) { + return `Status: NOT READY\nTask: ${taskTitle}\nMissing: prd.md not created\nNext: Write PRD, then research → init-context → start` + } + + if (!hasContext) { + return `Status: NOT READY\nTask: ${taskTitle}\nMissing: Context not configured (no jsonl files)\nNext: Complete Phase 2 (research → init-context → start) before implementing` + } + + return `Status: READY\nTask: ${taskTitle}\nNext: Continue with implement or check` +} + +/** + * Build session context for injection + */ +function buildSessionContext(ctx) { + const directory = ctx.directory + const trellisDir = join(directory, ".trellis") + const claudeDir = join(directory, ".claude") + const opencodeDir = join(directory, ".opencode") + + const parts = [] + + // 1. Header + parts.push(`<trellis-context> +You are starting a new session in a Trellis-managed project. +Read and follow all instructions below carefully. +</trellis-context>`) + + // 2. Current Context (dynamic) + const contextScript = join(trellisDir, "scripts", "get_context.py") + if (existsSync(contextScript)) { + const output = ctx.runScript(contextScript) + if (output) { + parts.push("<current-state>") + parts.push(output) + parts.push("</current-state>") + } + } + + // 3. Workflow Guide + const workflow = ctx.readProjectFile(".trellis/workflow.md") + if (workflow) { + parts.push("<workflow>") + parts.push(workflow) + parts.push("</workflow>") + } + + // 4. Guidelines Index (dynamic discovery, matching Claude's session-start.py) + parts.push("<guidelines>") + parts.push("**Note**: The guidelines below are index files — they list available guideline documents and their locations.") + parts.push("During actual development, you MUST read the specific guideline files listed in each index's Pre-Development Checklist.\n") + + const specDir = join(directory, ".trellis", "spec") + if (existsSync(specDir)) { + try { + const subs = readdirSync(specDir).filter(name => { + if (name.startsWith(".")) return false + try { + return statSync(join(specDir, name)).isDirectory() + } catch { + return false + } + }).sort() + + for (const sub of subs) { + const indexFile = join(specDir, sub, "index.md") + if (existsSync(indexFile)) { + // Flat spec dir: spec/<layer>/index.md + const content = ctx.readFile(indexFile) + if (content) { + parts.push(`## ${sub}\n${content}\n`) + } + } else { + // Nested package dirs (monorepo): spec/<pkg>/<layer>/index.md + try { + const nested = readdirSync(join(specDir, sub)).filter(name => { + try { + return statSync(join(specDir, sub, name)).isDirectory() + } catch { + return false + } + }).sort() + + for (const layer of nested) { + const nestedIndex = join(specDir, sub, layer, "index.md") + if (existsSync(nestedIndex)) { + const content = ctx.readFile(nestedIndex) + if (content) { + parts.push(`## ${sub}/${layer}\n${content}\n`) + } + } + } + } catch { + // Ignore directory read errors + } + } + } + } catch { + // Ignore spec directory read errors + } + } + + parts.push("</guidelines>") + + // 5. Session Instructions - try both .claude and .opencode + let startMd = ctx.readFile(join(claudeDir, "commands", "trellis", "start.md")) + if (!startMd) { + startMd = ctx.readFile(join(opencodeDir, "commands", "trellis", "start.md")) + } + if (startMd) { + parts.push("<instructions>") + parts.push(startMd) + parts.push("</instructions>") + } + + // 6. Task status (R2: check task state for session resume) + const taskStatus = getTaskStatus(directory) + parts.push(`<task-status>\n${taskStatus}\n</task-status>`) + + // 7. Final directive (R3: active, not passive) + parts.push(`<ready> +Context loaded. Steps 1-3 (workflow, context, guidelines) are already injected above — do NOT re-read them. +Start from Step 4. Wait for user's first message, then follow <instructions> to handle their request. +If there is an active task, ask whether to continue it. +</ready>`) + + return parts.join("\n\n") +} + +export default async ({ directory }) => { + const ctx = new TrellisContext(directory) + debugLog("session", "Plugin loaded, directory:", directory) + + return { + // chat.message - triggered when user sends a message + "chat.message": async (input) => { + try { + const sessionID = input.sessionID + const agent = input.agent || "unknown" + debugLog("session", "chat.message called, sessionID:", sessionID, "agent:", agent) + + // Skip in non-interactive mode + if (process.env.OPENCODE_NON_INTERACTIVE === "1") { + debugLog("session", "Skipping - non-interactive mode") + return + } + + // Check if we should skip (omo will handle) + if (ctx.shouldSkipHook("session-start")) { + debugLog("session", "Skipping - omo will handle via .claude/hooks/") + return + } + + // Only inject on first message + if (contextCollector.isProcessed(sessionID)) { + debugLog("session", "Skipping - session already processed") + return + } + + // Mark session as processed + contextCollector.markProcessed(sessionID) + + // Build and store context + const context = buildSessionContext(ctx) + debugLog("session", "Built context, length:", context.length) + + contextCollector.store(sessionID, context) + debugLog("session", "Context stored for session:", sessionID) + + } catch (error) { + debugLog("session", "Error in chat.message:", error.message, error.stack) + } + }, + + // experimental.chat.messages.transform - modify messages before sending to AI + "experimental.chat.messages.transform": async (input, output) => { + try { + const { messages } = output + debugLog("session", "messages.transform called, messageCount:", messages?.length) + + if (!messages || messages.length === 0) { + return + } + + // Find last user message + let lastUserMessageIndex = -1 + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].info?.role === "user") { + lastUserMessageIndex = i + break + } + } + + if (lastUserMessageIndex === -1) { + debugLog("session", "No user message found") + return + } + + const lastUserMessage = messages[lastUserMessageIndex] + const sessionID = lastUserMessage.info?.sessionID + + debugLog("session", "Found user message, sessionID:", sessionID) + + if (!sessionID || !contextCollector.hasPending(sessionID)) { + debugLog("session", "No pending context for session") + return + } + + // Get and consume pending context + const pending = contextCollector.consume(sessionID) + + // Find first text part + const textPartIndex = lastUserMessage.parts?.findIndex( + p => p.type === "text" && p.text !== undefined + ) + + if (textPartIndex === -1) { + debugLog("session", "No text part found in user message") + return + } + + // Prepend context to the text part (same approach as omo) + const originalText = lastUserMessage.parts[textPartIndex].text || "" + lastUserMessage.parts[textPartIndex].text = `${pending.content}\n\n---\n\n${originalText}` + + debugLog("session", "Injected context by prepending to text, length:", pending.content.length) + + } catch (error) { + debugLog("session", "Error in messages.transform:", error.message, error.stack) + } + } + } +} diff --git a/.trellis/.gitignore b/.trellis/.gitignore new file mode 100644 index 0000000..46135ba --- /dev/null +++ b/.trellis/.gitignore @@ -0,0 +1,29 @@ +# Developer identity (local only) +.developer + +# Current task pointer (each dev works on different task) +.current-task + +# 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 diff --git a/.trellis/.template-hashes.json b/.trellis/.template-hashes.json new file mode 100644 index 0000000..58baeea --- /dev/null +++ b/.trellis/.template-hashes.json @@ -0,0 +1,823 @@ +{ + ".trellis/config.yaml": "fe1fba0961e589c6f49190f5e19d4edb0d5bf894dba8468f06882c6e1c5e2aa1", + ".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c", + ".trellis/scripts/add_session.py": "7c869be8146e6f675bd95e424909ff301ea0a8f8fd82a4f056f6d320e755a406", + ".trellis/scripts/common/__init__.py": "301724230abcce6e9fc99054c12d21c30eea7bc3b330ae6350aa3b6158461273", + ".trellis/scripts/common/cli_adapter.py": "66ef4f75470807b531490a6b6928604eb59781148fe3c5412f39e132ffab0850", + ".trellis/scripts/common/config.py": "909257b442d7d1e7a2596996622c4f2f010d8c1343e1efd088ef8615d99554c7", + ".trellis/scripts/common/developer.py": "69f6145c4c48953677de3ba06f487ba2a1675f4d66153346ab40594bb06a01c9", + ".trellis/scripts/common/git_context.py": "f154d358c858f7bcfc21a03c9b909af3a8dfa20be37b2c5012d84b8e0588b493", + ".trellis/scripts/common/paths.py": "058f333fb80c71c90ddc131742e8e64949c2f1ed07c1254d8f7232506d891ffc", + ".trellis/scripts/common/phase.py": "f9bdd553c7a278b97736b04c066ed06d8baa2ef179ed8219befcf6c27afcc9cd", + ".trellis/scripts/common/registry.py": "6c65db45a487ef839b0a4b5b20abe201547269c20c7257254293a89dc01b56dc", + ".trellis/scripts/common/task_queue.py": "6de22c7731465ee52d2b5cd4853b191d3cf869bf259fbc93079b426ba1c3756c", + ".trellis/scripts/common/task_utils.py": "e19c290d90f9a779db161aeb9fefda27852847fbc67d358d471530b8ede64131", + ".trellis/scripts/common/worktree.py": "434880e02dfa2e92f0c717ed2a28e4cdee681ea10c329a2438d533bdbc612408", + ".trellis/scripts/create_bootstrap.py": "aa5dd1f39a77b2f4bb827fd14ce7a83fb51870e77f556fe508afce3f8eac0b4e", + ".trellis/scripts/get_context.py": "ca5bf9e90bdb1d75d3de182b95f820f9d108ab28793d29097b24fd71315adcf5", + ".trellis/scripts/get_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f", + ".trellis/scripts/init_developer.py": "f9e6c0d882406e81c8cd6b1c5abb204b0befc0069ff89cf650cd536a80f8c60e", + ".trellis/scripts/multi_agent/__init__.py": "af6fceb4d9a64da04be03ba0f5a6daf71066503eca832b8b58d8a7d4b2844fa4", + ".trellis/scripts/multi_agent/cleanup.py": "db50c4fbb32261905a8278c2760b33029f187963cd4e448938e57f3db3facd6c", + ".trellis/scripts/multi_agent/create_pr.py": "6a2423aba5720a2150c32349faa957cdc59c6bb96511e56c79ca08d92d69c666", + ".trellis/scripts/multi_agent/plan.py": "242b870b7667f730c910d629f16d44d5d3fd0a58f6451d9003c175fb2e77cee5", + ".trellis/scripts/multi_agent/start.py": "32ed1a13405b7c71881b2507a79e1a3733bc3fcedbc92fcee0d733ce00d759d0", + ".trellis/scripts/multi_agent/status.py": "5fc46b6d605c69b6044967a6b33ffb0c9d6f99dd919374572ac614222864a811", + ".trellis/scripts/task.py": "ecf52885a698dc93af67fd693825a2f71163ab86b5c2abe76d8aa2e2caa44372", + ".trellis/workflow.md": "9b6d6e8027bd2cf32d9efd7ef77d6524c59fcaa4ad6052f72d028a07a5fd69a7", + ".trellis/worktree.yaml": "c57de79e40d5f748f099625ed4a17d5f0afbf25cac598aced0b3c964e7b7c226", + ".opencode/agents/check.md": "39763ef458f95a2b38c0fbc9cd79df8c66909086e0ace4e4e80c536f58d09aed", + ".opencode/agents/debug.md": "0bac1d723fb3634ea95c471a22245eff2b4c9d6bd98bc66cafacf6a0092609bb", + ".opencode/agents/dispatch.md": "23d7834c540907c98f7988661849db5d949ee394470952215c373aef926fec81", + ".opencode/agents/implement.md": "540ce5cd7b2c2281ce520ed487bf0bbc4773169646f8224d7c363af293def396", + ".opencode/agents/research.md": "094829f1572e65c0d954c648c9638440e3279b02f2538f2abe0d9706b90e6fa2", + ".opencode/agents/trellis-plan.md": "36de06c7eddbff290acb3c200f30af96291048e492ce2f2d8b7038662eeb572b", + ".opencode/bun.lock": "31e0d053588da5aaeb7c3fce5de22d5878df9af7f3ffc48775992698f8614fe9", + ".opencode/commands/android-test.md": "59a50131df27fd26c287970f88443f0fa4d20a0ebb52f8c879fcc4d5aeb2b891", + ".opencode/commands/doc-update.md": "e8d9b6c122dc45d52c59a0a8c4d7363b58eb883e54118f951cab0fbc38de4d84", + ".opencode/commands/ios-test.md": "2dac0c13d8e0f026816f7ad235030f4f83b474b89c796413e0a20d00ebc15bdd", + ".opencode/commands/trellis/before-backend-dev.md": "7e35444de2a5779ef39944f17f566ea21d2ed7f4994246f4cfe6ebf9a11dd3e3", + ".opencode/commands/trellis/before-frontend-dev.md": "a6225f9d123dbd4a7aec822652030cae50be3f5b308297015e04d42b23a27b2a", + ".opencode/commands/trellis/brainstorm.md": "7c7731eda092275a5d87f2569a69584f3c39b544a126a76e727a1e9d250c4a65", + ".opencode/commands/trellis/break-loop.md": "ba4dd4022dde1e4bbcfc1cc99e6a118e51b9db95bd962d88f1c29d0c9c433112", + ".opencode/commands/trellis/check-backend.md": "4e81a28d681ea770f780df55a212fd504ce21ee49b44ba16023b74b5c243cef3", + ".opencode/commands/trellis/check-cross-layer.md": "b9ab24515ead84330d6634f6ad912ca3547db3a36139d62c5688161824097d60", + ".opencode/commands/trellis/check-frontend.md": "5e8e3b682032ba0dd6bb843dd4826fff0159f78a7084964ccb119c6cf98b3d91", + ".opencode/commands/trellis/create-command.md": "230640908f2863f0cf2d7dc0cd2b61782b77d75fc02636d6d46b22d00ccb3465", + ".opencode/commands/trellis/finish-work.md": "dd147ab880063f4359322618f39ac0e84e1494aa9f67883dcd82e947f6b5d8bb", + ".opencode/commands/trellis/integrate-skill.md": "3940442485341832257c595ddfb45582e2d60e5a4716f2bd15b7bce0498b130a", + ".opencode/commands/trellis/migrate-specs.md": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ".opencode/commands/trellis/onboard.md": "a5dbd5db094b13fd006ec856efa53a688e209bcdc3ed1680b63b15f1e3293ab4", + ".opencode/commands/trellis/parallel.md": "82e7a5214b48ffdea9063109f89a8428d7c077e0beb4cc86d4836394e47a1e21", + ".opencode/commands/trellis/record-session.md": "0c4f61283c2f262c1f9c900d9207309107497d4ac848cca86eb62bc5b7189fe7", + ".opencode/commands/trellis/start.md": "5e6141d6f7bc06fbd7de453d64e204416f1007ca0a55a8a8ca9aad100f3b3572", + ".opencode/commands/trellis/update-spec.md": "ff4d5a0405a763e61936f5b9df175fd25ea20ec5c20fa999855020ab78a919b6", + ".opencode/lib/trellis-context.js": "8974c446808852152d1f9cd1165d5dad3c215e9c9206339d83fedca464f886a8", + ".opencode/node_modules/@opencode-ai/plugin/dist/example.d.ts": "ba1871db5442e49c90630686e08323d6d8303b1749d0752646e69701572560e0", + ".opencode/node_modules/@opencode-ai/plugin/dist/example.js": "df6bc51eb345aa69a3bdfe389a5f7b1368fc5973e45f9166763b8ed3f6606f03", + ".opencode/node_modules/@opencode-ai/plugin/dist/index.d.ts": "072ed6da227611707565cfb175149035aee26b1e5b9ef9bf9f1691090a9a9fcc", + ".opencode/node_modules/@opencode-ai/plugin/dist/index.js": "a3d26196e07062e858fc0ee44cfbddeb6143be740f9c93df4286b162a8b3e298", + ".opencode/node_modules/@opencode-ai/plugin/dist/shell.d.ts": "340d8057526217987a3a797d93a03b6e6c9a9e1cd8e7c46a676ea008a710afe1", + ".opencode/node_modules/@opencode-ai/plugin/dist/shell.js": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", + ".opencode/node_modules/@opencode-ai/plugin/dist/tool.d.ts": "10c4747fb7470f341a977f88fa3c66bf05593cd835a25185f996244c5b638073", + ".opencode/node_modules/@opencode-ai/plugin/dist/tool.js": "a01230d42fa1055ddb4b378e0128f156e2db07f6f6eadbf2da174eb634380b16", + ".opencode/node_modules/@opencode-ai/plugin/dist/tui.d.ts": "fa0ce667c42213f02b5fb7beba5a76c2800225d847268b547f6e81f7e66d2593", + ".opencode/node_modules/@opencode-ai/plugin/dist/tui.js": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", + ".opencode/node_modules/@opencode-ai/plugin/package.json": "9a3801d112f0b81abebf95bf371b607c4dec57a8595a603a7ffe33a2c350cabc", + ".opencode/node_modules/@opencode-ai/sdk/dist/client.d.ts": "84edb938f2b673df750cf83e9516218404e9df7e32e66f2f063c813d7f4d0e62", + ".opencode/node_modules/@opencode-ai/sdk/dist/client.js": "3a6229169864892dfbd1919c99e9e377f4a1458e708237ab789476843f415bd2", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client/client.gen.d.ts": "358599da1d31542e1d3ebf35a4a98665395ea8eaf0063826b8838974f7e826e5", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client/client.gen.js": "a4303bf5538ac7652dd7ab06f709024356a674318e452f71bf391ffe6b30a727", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client/index.d.ts": "06240cf9eca32a7ea5436ee67c8420bc1ddacfaf0d28cd17d09dd56a65c8fb9a", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client/index.js": "190026b1b139eaf999907d9be7af64a4485dcf07e15f392fbb46c391f625aa58", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client/types.gen.d.ts": "11f6e61b31e0c89eae79ce790895bf85b918572c32c2de45998f7cbacbb3b963", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client/types.gen.js": "17ebe2baaa8d2f0c94afaaaae74a46e43e2b163a0f4ffd69adb39ad57c318e78", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client/utils.gen.d.ts": "77fed0312d2ec2b08afb890b7760b5f2328f6e09065ea05b5fc35d5294fbb434", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client/utils.gen.js": "723a458bd538ada79b036e49a2c28d29e674647b51e28b61ba3fd8074ae41d84", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client.gen.d.ts": "30243dd321042c89cab45896e85d4d7c0fd8e0ba184e9b2163201945eaed71be", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client.gen.js": "aba08480d2ca9591f619b13fd0cb2bee3a4a9648004141229b7a2f67391bcde1", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/auth.gen.d.ts": "96135fbf1dab9f857c9df88a191fdc435517f7a5b021d09cad0c290a1dcec436", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/auth.gen.js": "5daa30e9a92a9fb4c8df37212c6bf421e6102dcb6070b32a7cdb978ecc27c230", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/bodySerializer.gen.d.ts": "44fd7a3b2fac384971e638548c227835a2f8c3c2232edff7e0f951a0e136e562", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/bodySerializer.gen.js": "133af6e45e6541018e1cdff6a59d610421c97e3215ffcb1447b682a74a278d6b", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/params.gen.d.ts": "d23fac93be3afb1c83269864f13b5e5fc246b05cb21b7e019e38403e4ef1aba5", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/params.gen.js": "6176cffd7187e9192957bb3f5df926d84f3b952079b2c33ce00f2ee37d7b401e", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/pathSerializer.gen.d.ts": "c36ce4ffa39eae9d8d5f3262bbff170f55b375156bc4eeadadfeede2820aa4c2", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/pathSerializer.gen.js": "f92d4e9209d6d6bcfc3b8bf0152f85f9004aa9a3163d1113d132651c2c022581", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/queryKeySerializer.gen.d.ts": "60d1baa9e7b198ea9221564bc5000c97fd9fb9a9d74974cb08f0176192612b2b", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/queryKeySerializer.gen.js": "cc69f01bfc81230f20a2355c6785d8f83e37d79119d55d5c1f44f099ff4740ff", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/serverSentEvents.gen.d.ts": "c41ccf7ec4fecea9d6e5b72ae7f20c91bbe85a8a82caada5ae8a3bc5a56c8926", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/serverSentEvents.gen.js": "ee09b085675f234fd6c35aa125c237202a8b728f885b24ab65e7d05ffbb2dd43", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/types.gen.d.ts": "80498d31235c22ee711a9fb6f0ca2bcb8fbf5c9a787f3d3a76962b440df70013", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/types.gen.js": "17ebe2baaa8d2f0c94afaaaae74a46e43e2b163a0f4ffd69adb39ad57c318e78", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/utils.gen.d.ts": "46f46fcafdf1b4e003679a35ee1a2d9c3a7e31b780fb437903fb1a6273483d52", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/utils.gen.js": "90a6a76e6d717839cb6fbaec696fe17a1f407ba696513366315fb30f5793b871", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/sdk.gen.d.ts": "5f71dae22207eb5119b2e005e046c0404a29454292e07c68668e39c9931414cd", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/sdk.gen.js": "2c40e32e5a6c06a4a0c02a78c010906ea2f9fefbea104abadf29e4dccc05d51f", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/types.gen.d.ts": "abd950a6eb114f55ae2210538340b4167a5d6e2f270864433417a9417c9c59b2", + ".opencode/node_modules/@opencode-ai/sdk/dist/gen/types.gen.js": "17ebe2baaa8d2f0c94afaaaae74a46e43e2b163a0f4ffd69adb39ad57c318e78", + ".opencode/node_modules/@opencode-ai/sdk/dist/index.d.ts": "2a7a35e3f1ead49569fe0423ae65055184f9384b97999cd90bb99878f727e585", + ".opencode/node_modules/@opencode-ai/sdk/dist/index.js": "c89e7660b6c14b282f497cf590f1693a49a421bba437a2d3ef2c2f6b51326958", + ".opencode/node_modules/@opencode-ai/sdk/dist/server.d.ts": "579f61b51c0e5dc0119ab49fc536130749280c7a23e489e753fcce08c229ce2c", + ".opencode/node_modules/@opencode-ai/sdk/dist/server.js": "79afd73e4c83f595b3ab91193cf6a9359e95506e4825b8f433d47926afdaab14", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/client.d.ts": "19d2c965297a2f7eedec208f00b8f988e83cff5e87fd58b9b2bcba9f4e481814", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/client.js": "43bf4990b39b59fcbc8fb90c5ab50ee070b0f72c8a143d0410edb5683fa6e6c0", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client/client.gen.d.ts": "358599da1d31542e1d3ebf35a4a98665395ea8eaf0063826b8838974f7e826e5", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client/client.gen.js": "90b45d194961c72dd2775b39af02c69fd49587a8a4f4d56d3407b12b8073447d", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client/index.d.ts": "55122db1dc0aa0340759af31319c40309930899ee2d67c82cd0d3cfc68b3c29c", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client/index.js": "cbbc149808c7dd0c00ceac3df03a948f8f701ec049a763ce14567abe306a7a54", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client/types.gen.d.ts": "481c7349554c63b0930b792ad2f0174f7d0666b3ec11c203f72d3b5be22f1d3b", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client/types.gen.js": "17ebe2baaa8d2f0c94afaaaae74a46e43e2b163a0f4ffd69adb39ad57c318e78", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client/utils.gen.d.ts": "53be801bc15e23f6ebb90c6675ad1803a6488f19b0b36c11f309e9899e74e75f", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client/utils.gen.js": "c593c1f1fe32737c849a284677d9a1e0643b125bcb5727b1afc7638d7f01eee8", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client.gen.d.ts": "c1af887105801d15dd6eeb88cb42e680d33e4a1d7cb6372011b3224bd81d7c4d", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client.gen.js": "a5f22d5f97899072c3a6afe04312f0b4838a811458427627f60473c63c0ead5d", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/auth.gen.d.ts": "96135fbf1dab9f857c9df88a191fdc435517f7a5b021d09cad0c290a1dcec436", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/auth.gen.js": "5daa30e9a92a9fb4c8df37212c6bf421e6102dcb6070b32a7cdb978ecc27c230", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/bodySerializer.gen.d.ts": "010b37dfe9c2cbc48d874bcab4074917ea836e630dc577a9e2d03e97da2236e5", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/bodySerializer.gen.js": "133af6e45e6541018e1cdff6a59d610421c97e3215ffcb1447b682a74a278d6b", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/params.gen.d.ts": "210945863764365e1e6b4e9c7e6fdd76e3b4aac91f38b7a5a0830ac112d57ac3", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/params.gen.js": "b3c95661ddaec7c7e54fb17bca6a51961ea5b383daa218fe6c862f05fcf594d2", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/pathSerializer.gen.d.ts": "c36ce4ffa39eae9d8d5f3262bbff170f55b375156bc4eeadadfeede2820aa4c2", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/pathSerializer.gen.js": "f92d4e9209d6d6bcfc3b8bf0152f85f9004aa9a3163d1113d132651c2c022581", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/queryKeySerializer.gen.d.ts": "60d1baa9e7b198ea9221564bc5000c97fd9fb9a9d74974cb08f0176192612b2b", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/queryKeySerializer.gen.js": "cc69f01bfc81230f20a2355c6785d8f83e37d79119d55d5c1f44f099ff4740ff", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/serverSentEvents.gen.d.ts": "844af05b12599165533995c74c309effc0106729f86e8596c2c9ec4868b80d94", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/serverSentEvents.gen.js": "218952938cd653a781ef512a60de49e4b558e5fb25558b27d96ada68b5eda4bc", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/types.gen.d.ts": "997352ab110eb2f15bd22e39882b9bfcf884b84b4d57cc0d24dd9037b36681c5", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/types.gen.js": "17ebe2baaa8d2f0c94afaaaae74a46e43e2b163a0f4ffd69adb39ad57c318e78", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/utils.gen.d.ts": "6be7640d8a8bcbeb9054bf83ab782d68e4cd761f3738bf776615d5ae9b729cbd", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/utils.gen.js": "110a18cdb229ab980a39aa222eca37e5a3b75773199555b7f8d2d110f588b4de", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/sdk.gen.d.ts": "500931c9b40d897c07c39fa2a7aace12e46a8757339598a214520843e55d3cf3", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/sdk.gen.js": "ea83d35534f04b309100f9d1d1fb4d0f5f931c3499c48cacf6c15c6b87059fa0", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/types.gen.d.ts": "66beddf56fbb2238485b53b6b4343acb5b79694d8bf4edaf8291259d64e822ef", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/types.gen.js": "17ebe2baaa8d2f0c94afaaaae74a46e43e2b163a0f4ffd69adb39ad57c318e78", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/index.d.ts": "2a7a35e3f1ead49569fe0423ae65055184f9384b97999cd90bb99878f727e585", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/index.js": "c89e7660b6c14b282f497cf590f1693a49a421bba437a2d3ef2c2f6b51326958", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/server.d.ts": "579f61b51c0e5dc0119ab49fc536130749280c7a23e489e753fcce08c229ce2c", + ".opencode/node_modules/@opencode-ai/sdk/dist/v2/server.js": "79afd73e4c83f595b3ab91193cf6a9359e95506e4825b8f433d47926afdaab14", + ".opencode/node_modules/@opencode-ai/sdk/package.json": "6d9580a225220796ff6c19feb1ff2b53b2d8e7db22e83e9f997bda241e5a3850", + ".opencode/node_modules/zod/LICENSE": "3f1189b28e3866e0d979968d466b78f813f76827cfdca1fbb124cc0a5c8841f8", + ".opencode/node_modules/zod/README.md": "67485f7fe9fda912f02235894aa782f4354ca2f568b4735db005d0ee52390628", + ".opencode/node_modules/zod/index.cjs": "2a3455cecff4f7021c92d0a3e2e5fc170c0448dc5cc261160127e08f4888a9c2", + ".opencode/node_modules/zod/index.d.cts": "29f823cbe0166e10e7176a94afe609a24b9e5af3858628c541ff8ce1727023cd", + ".opencode/node_modules/zod/index.d.ts": "c733a1897d6b4b30dad6998597f6896b265b094a65534359ada34b08ecf8932c", + ".opencode/node_modules/zod/index.js": "c733a1897d6b4b30dad6998597f6896b265b094a65534359ada34b08ecf8932c", + ".opencode/node_modules/zod/locales/index.cjs": "77c515c23956a04462d5c8fb207321bbbaf6e231b86faa4ecd98dfaec82a40e7", + ".opencode/node_modules/zod/locales/index.d.cts": "79152153afe093c320d882e1d66640fdf7a853fcab8e897ab7f10cbac937cb2e", + ".opencode/node_modules/zod/locales/index.d.ts": "0b98ccbe349eb9774d8c96fa381335cef127140996f91a4eea47b7742b55f51d", + ".opencode/node_modules/zod/locales/index.js": "0b98ccbe349eb9774d8c96fa381335cef127140996f91a4eea47b7742b55f51d", + ".opencode/node_modules/zod/locales/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", + ".opencode/node_modules/zod/mini/index.cjs": "c3ef916ed5e1bb397f04352f010f97961e98f610c13ecc05c25a384ef0e5a5a2", + ".opencode/node_modules/zod/mini/index.d.cts": "d34c4532b0004150342d04ab1a6f61d19751c4fc7c465c72ec582b180b0904c0", + ".opencode/node_modules/zod/mini/index.d.ts": "cf94ca2939d655e17fdfd1d0a3aceb4cbeb040d8946e9c9c1eaa5cc734dc69c8", + ".opencode/node_modules/zod/mini/index.js": "cf94ca2939d655e17fdfd1d0a3aceb4cbeb040d8946e9c9c1eaa5cc734dc69c8", + ".opencode/node_modules/zod/mini/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", + ".opencode/node_modules/zod/package.json": "67f2058ef56c9209df51e6fe0dd1395eb25af454974de2978ced56008e6fad5e", + ".opencode/node_modules/zod/src/index.ts": "c733a1897d6b4b30dad6998597f6896b265b094a65534359ada34b08ecf8932c", + ".opencode/node_modules/zod/src/locales/index.ts": "0b98ccbe349eb9774d8c96fa381335cef127140996f91a4eea47b7742b55f51d", + ".opencode/node_modules/zod/src/mini/index.ts": "cf94ca2939d655e17fdfd1d0a3aceb4cbeb040d8946e9c9c1eaa5cc734dc69c8", + ".opencode/node_modules/zod/src/v3/ZodError.ts": "e4386fe8f2a49d774c7e1aff4c015c125ac4a0dcf70d5aa6883f167278ca141f", + ".opencode/node_modules/zod/src/v3/benchmarks/datetime.ts": "0d1a81e5608b286c1843aaecb20bd7d1f62b6674a610a51dc7d1043bed05963d", + ".opencode/node_modules/zod/src/v3/benchmarks/discriminatedUnion.ts": "33e4f792dbfdc735a72f92fcc117d3dbaea8e7ff3937d523bef3df44814805d3", + ".opencode/node_modules/zod/src/v3/benchmarks/index.ts": "27f4322ad98e28575d5849a7612c6acea55cd5be8a1571d1885a656e8a9d5620", + ".opencode/node_modules/zod/src/v3/benchmarks/ipv4.ts": "3ef475926d7e95a5880cd9d7bd780f98642189010046bb9748e27e50360da333", + ".opencode/node_modules/zod/src/v3/benchmarks/object.ts": "ea0f6d4b3e2c6f47bf1739a53473997da4aaacc1e5f59e57f00cfb6b7f59a4e5", + ".opencode/node_modules/zod/src/v3/benchmarks/primitives.ts": "706d51c7048aff5108dd71b33b5c66d354a6856be0a48b23bc32e55d284c31d0", + ".opencode/node_modules/zod/src/v3/benchmarks/realworld.ts": "9bcc944a5e875ca191ca10542c113dde3c754e45a196400726f251b045e14215", + ".opencode/node_modules/zod/src/v3/benchmarks/string.ts": "ed17ef186249dc4f1da01a2d5e0bd3c87da6d0d2dcad89e8afa05e0920d98536", + ".opencode/node_modules/zod/src/v3/benchmarks/union.ts": "9eec8e3e58cb4d3dd76be11874187f71a99c27e06c639de0cc9d8d928687fe0a", + ".opencode/node_modules/zod/src/v3/errors.ts": "cb8debd524102d5a38dbaadd72b220f68349dbac51ac9663569c1807f64b3772", + ".opencode/node_modules/zod/src/v3/external.ts": "5d26d2e47e2352def36f89a3e8bf8581da22b7f857e07ef3114cd52cf4813445", + ".opencode/node_modules/zod/src/v3/helpers/enumUtil.ts": "dffcb43b363e6804128c415c128c32dbbda302049cfce7662b2f0e17eeae044e", + ".opencode/node_modules/zod/src/v3/helpers/errorUtil.ts": "808aa0875577c556006ca193a5d4bd35a2ade57e50e09199fd9cbf6e780f0a31", + ".opencode/node_modules/zod/src/v3/helpers/parseUtil.ts": "cb94690c02dce392b98ca364de0d4b24f42db715fbb1759a08bcfb7fa6674645", + ".opencode/node_modules/zod/src/v3/helpers/partialUtil.ts": "68848db44869461bc20373abdcd002e32cb3759e2d5801ba3d9894d2c23db0b1", + ".opencode/node_modules/zod/src/v3/helpers/typeAliases.ts": "d3cfde44f8089768ebb08098c96d01ca260b88bccf238d55eee93f1c620ff5a5", + ".opencode/node_modules/zod/src/v3/helpers/util.ts": "df1b9ea5a29a591f273555e240142431617e686aa6a97f172193770c26841a52", + ".opencode/node_modules/zod/src/v3/index.ts": "3db2efd285e7328d8014b54a7fce3f4861ebcdc655df40517092ed0050983617", + ".opencode/node_modules/zod/src/v3/locales/en.ts": "f729055c5f4acb839084e5c66c80bdecca02a78df6b048224e56e634322cb223", + ".opencode/node_modules/zod/src/v3/standard-schema.ts": "e14e38f67bfc8d76e208366f639b73b3292f2bd46c97a44f43535c2389927ece", + ".opencode/node_modules/zod/src/v3/tests/Mocker.ts": "b7732bb15ed6ab38f61dd4a15f088af109b0a99eccb50cdd9f542a17bb935f5e", + ".opencode/node_modules/zod/src/v3/tests/all-errors.test.ts": "d629da33805d24b17d1f6383009acfbf3b769bd4f2d8191a9720d68e4e64ab7d", + ".opencode/node_modules/zod/src/v3/tests/anyunknown.test.ts": "fac8ebb9d3007f7ef1ea320eed85b22b3248fde6bb1fe645a1a513b69442c014", + ".opencode/node_modules/zod/src/v3/tests/array.test.ts": "e7d3813182c59e511d350bebbd2a25b349868cc73d057b23b1d85a4d927b3711", + ".opencode/node_modules/zod/src/v3/tests/async-parsing.test.ts": "45f829362cc917d9505e244ea0d9a06d71a1953747dc31e57780388e82ad5afa", + ".opencode/node_modules/zod/src/v3/tests/async-refinements.test.ts": "cfd6752223678338c949f1716deeff8c18807f31343d6a4544f494d10e5acf60", + ".opencode/node_modules/zod/src/v3/tests/base.test.ts": "989d8ff33e43dbeb3d08d8f659a3269d22fb573a86d48c72da58ab84e484e532", + ".opencode/node_modules/zod/src/v3/tests/bigint.test.ts": "799b38ffff2d2a977493846e127d6f76e5cdb346d8886a2d2c76616253170f45", + ".opencode/node_modules/zod/src/v3/tests/branded.test.ts": "d0d7213caa6b727fe72a4bd99631984c4038ecbe8f67001840b447ca4b2085b0", + ".opencode/node_modules/zod/src/v3/tests/catch.test.ts": "e4b1727836b5cdca66337af180a756fab2b712f61d49d17326a90c0a54080be5", + ".opencode/node_modules/zod/src/v3/tests/coerce.test.ts": "30dec33039db38b3c2baecbee09c74afc1d9adc68a6229a35628a1bac4539db9", + ".opencode/node_modules/zod/src/v3/tests/complex.test.ts": "51d676be11ddb5a0b987471c6e49b819b4f6b2aba77fa23565deb4591439cf40", + ".opencode/node_modules/zod/src/v3/tests/custom.test.ts": "6837c23478b39dc6304a98bbbba4a1df9b973d40f6243f0939bfbc339d96b01d", + ".opencode/node_modules/zod/src/v3/tests/date.test.ts": "7d352eab070ae8f4fcbc61674f6b238159699ec4b96754ec1aac0cac74091c34", + ".opencode/node_modules/zod/src/v3/tests/deepmasking.test.ts": "8b6cef1cdb8eb422ebb50b24e4f88742f13eaf0abf83b08a3980f2406a4b6ac6", + ".opencode/node_modules/zod/src/v3/tests/default.test.ts": "92067a119b984ab2750a1eba8e1a5573276aeaed95056e9e283f4a3bb54e5d5e", + ".opencode/node_modules/zod/src/v3/tests/description.test.ts": "f1b75327fd817bb63c3b2e3be0ff9aff7511e2a391713756d8cb26cbfd3d5aa5", + ".opencode/node_modules/zod/src/v3/tests/discriminated-unions.test.ts": "5bd00d9f7eeac9dd60faa03af58b5a915040f54b93ff77c4eab0d4a18f8b9e9c", + ".opencode/node_modules/zod/src/v3/tests/enum.test.ts": "3fcf9f740cd53f52431abda4c61a3732dde44c3a9f1033159e9c0c92fcebd09a", + ".opencode/node_modules/zod/src/v3/tests/error.test.ts": "1c77f7fefb917210ac238159c8116bd159158c260008bb6fd2f8a91ba9e55538", + ".opencode/node_modules/zod/src/v3/tests/firstparty.test.ts": "9906d7da8e62f7b9b7e5cd9553bea0872ad8a0b259214034108cec8a624cfc1d", + ".opencode/node_modules/zod/src/v3/tests/firstpartyschematypes.test.ts": "9be5a0881151df777293172088b2039ed7e1e6f4a98a7557cdd0211f108c9b96", + ".opencode/node_modules/zod/src/v3/tests/function.test.ts": "93b2a5008501e4d883c838e567a6d3da18f88f31fb71a7ba5615664572f89783", + ".opencode/node_modules/zod/src/v3/tests/generics.test.ts": "d1a4c54239acff333f76231084c48133257fefe14f504d0cab055979b5a9e723", + ".opencode/node_modules/zod/src/v3/tests/instanceof.test.ts": "b11897351fcf312dee317408b7aa27929ba37538c468588429f4f4ae3c5fa4ce", + ".opencode/node_modules/zod/src/v3/tests/intersection.test.ts": "c560d1fbca45e049f07eb2a382f7ee4c678164825cf5725b014312eac9412c8c", + ".opencode/node_modules/zod/src/v3/tests/language-server.source.ts": "aeb84d6fb14e6511a3509a0545770d869986f397ee02da640730d4ff97c6be4a", + ".opencode/node_modules/zod/src/v3/tests/language-server.test.ts": "c75a03c145d1b0f28c62cba881aae1db600912a1df3be99d1996a18da776c498", + ".opencode/node_modules/zod/src/v3/tests/literal.test.ts": "a22faed6c08e04c75d6bb0180d5e1fafc4645b15445a479f338016ee4855c362", + ".opencode/node_modules/zod/src/v3/tests/map.test.ts": "bb52432835e9fa70a94f444fc9e7c2d8e6ac5eb905cc3b8ae54846e52fa57360", + ".opencode/node_modules/zod/src/v3/tests/masking.test.ts": "557f4bc4d8f4ab29afd87f07cf70fa4de60d765fc86fbc5eb4b788f34dd825a2", + ".opencode/node_modules/zod/src/v3/tests/mocker.test.ts": "46f64d2995e29c5838189ed10cd1b185b44c33dbc21418364a80ac19f285362b", + ".opencode/node_modules/zod/src/v3/tests/nan.test.ts": "2ef6416fc58f94964cb889d28f8b7019a90365d24229d8146c845b8c7b0b8610", + ".opencode/node_modules/zod/src/v3/tests/nativeEnum.test.ts": "6f64363193a447f71a8aa5440461bdb5451c6c4433fb4fca785ef4798d5b52fb", + ".opencode/node_modules/zod/src/v3/tests/nullable.test.ts": "c51336535632e1404455bca333ccafc143191600047251a3e3e13f551693c516", + ".opencode/node_modules/zod/src/v3/tests/number.test.ts": "d56a65ec9bc03482c2b23eecb97c5213b564b5aaafeaf205d2a1f54a2c91e1d8", + ".opencode/node_modules/zod/src/v3/tests/object-augmentation.test.ts": "69777e89506b2d8157c98e176dde94a7c664db7228485c52a18154ac4b794382", + ".opencode/node_modules/zod/src/v3/tests/object-in-es5-env.test.ts": "687d2c52ddaaca24ab3d3e301cba042a7560888570af23b50a0597d3012d500c", + ".opencode/node_modules/zod/src/v3/tests/object.test.ts": "489ba37da9b2117c730aaa847099c7899ba0a376cf612495f41c42c29808fb45", + ".opencode/node_modules/zod/src/v3/tests/optional.test.ts": "0edb7a6478e931bf256f3d71d1367d2b8199b4082101dba97cc306e4dc3c92c9", + ".opencode/node_modules/zod/src/v3/tests/parseUtil.test.ts": "822a6d836b05ab38eadf26592420eba6be694dc7c1d654388efbb601af00c288", + ".opencode/node_modules/zod/src/v3/tests/parser.test.ts": "7c8b720c921618f6c89eb08eae80ba45602fa04dafe9c7b9b861512954d70f9b", + ".opencode/node_modules/zod/src/v3/tests/partials.test.ts": "2fe9fe6cdfa085eed053894acdd3ef32c5425f7da0aa81ea1043c737fcd7cf41", + ".opencode/node_modules/zod/src/v3/tests/pickomit.test.ts": "cd05293952b5fb0ca26eeb9ba6cd69a26f58fa5c62eb35f9ce1eb1ac9e00ccd6", + ".opencode/node_modules/zod/src/v3/tests/pipeline.test.ts": "b285aac721b744da8c5a0a093c9ed57244643e1e333df576922e67bfaa9e16ab", + ".opencode/node_modules/zod/src/v3/tests/preprocess.test.ts": "7433e95a0504f7a250b7beaac30a5e8f500c315d99b3badb978a0b28ed7a61dd", + ".opencode/node_modules/zod/src/v3/tests/primitive.test.ts": "d0214e93de931a8e6b89b3cb89df1cbf7299d6be00abcdde71ffdcb811953dea", + ".opencode/node_modules/zod/src/v3/tests/promise.test.ts": "c03c19525c56cea7808d982bd23ba89b5a48db13934e8d4824e13ee0a249d19b", + ".opencode/node_modules/zod/src/v3/tests/readonly.test.ts": "dc0ffe451674c8938b67bdcb63deff7958dd1f0e5ec69a485bba470c978817c1", + ".opencode/node_modules/zod/src/v3/tests/record.test.ts": "ebefc7ad59b2246787408efe7aaf2f7beec0b5c90c70e79990dcbf241c2d6c95", + ".opencode/node_modules/zod/src/v3/tests/recursive.test.ts": "9ccc037ef698ceeb4e6986e02aaee71ff318c4dc0f875b9253bff6ba14db9137", + ".opencode/node_modules/zod/src/v3/tests/refine.test.ts": "2f3a329952b6f0109028a142225d719b05307a18f7395297711f725b599933f8", + ".opencode/node_modules/zod/src/v3/tests/safeparse.test.ts": "5aa7597109dea6647d0ab022a4aab694c44acfeae53386ea6366b806fbd3e872", + ".opencode/node_modules/zod/src/v3/tests/set.test.ts": "eeadd9dabd30b857c5de8526503cb1e5c8b6aa45a99c4708958ca1f822bb3da2", + ".opencode/node_modules/zod/src/v3/tests/standard-schema.test.ts": "f74076e43f44c887b3bf7d26d5ec686b8070359e63fe373e6c45b6c7708c025c", + ".opencode/node_modules/zod/src/v3/tests/string.test.ts": "19ac9a6b0a7c610038d93dd9f97d453ad3a54bd5e1b1dab99e25d544872d93cd", + ".opencode/node_modules/zod/src/v3/tests/transformer.test.ts": "e3da81596e0077326f442153a741c9ba25669b8b5eb025a8ea7fc1eb0106faf7", + ".opencode/node_modules/zod/src/v3/tests/tuple.test.ts": "e8a48c8791387c94076c7eee8c9b7e18712a45356c7167f7c43bedd54544614f", + ".opencode/node_modules/zod/src/v3/tests/unions.test.ts": "0c32f20d663b30438bb8ff834b7f066722160720a25a90e6513a66b9baa4604d", + ".opencode/node_modules/zod/src/v3/tests/validations.test.ts": "10ae103a7c7d9e5ce8bdacf9ef30bc44a52cdadf64fe365f492d27dcb432b482", + ".opencode/node_modules/zod/src/v3/tests/void.test.ts": "6bdad3b7ba624eb3bd03c0bfea611de46c10aba62d03df9bd2ba49bafedc15cb", + ".opencode/node_modules/zod/src/v3/types.ts": "1cfe15702dbccfc592bb46679bb6979c6b1b44b9790114a01447436849f0900d", + ".opencode/node_modules/zod/src/v4/classic/checks.ts": "925c0df406a04a1d0441c0e51f5c08dd1f7534a1919ebd525a7dc271aafb20a6", + ".opencode/node_modules/zod/src/v4/classic/coerce.ts": "9cf6429965d7d55dc4e50999af50cbad5b57b5f3372bba13038133aba62ba600", + ".opencode/node_modules/zod/src/v4/classic/compat.ts": "1333987ab926fb40f4708758fc674c50be1f05540b62fd1de5cb8ccda574e532", + ".opencode/node_modules/zod/src/v4/classic/errors.ts": "840c80756be1f61952e0a65234029e31fb3aae32e3eca4a68ea969f809a81159", + ".opencode/node_modules/zod/src/v4/classic/external.ts": "58af150e9fb50909d26d4f795743ab20f8807ea5098ee59145d52ccc3b4aa3bf", + ".opencode/node_modules/zod/src/v4/classic/index.ts": "8f199f0404574864bd10cac8311858f39d50d418609a0ef089dfd444d4977840", + ".opencode/node_modules/zod/src/v4/classic/iso.ts": "e3215f0650c1256d63299dec334fbbcb0b92517286618f1c24223b5294fd5fb4", + ".opencode/node_modules/zod/src/v4/classic/parse.ts": "07b4d6d890ca610989b15429a0c551d6ecc0dcae8e2d6f96933b678e389d1422", + ".opencode/node_modules/zod/src/v4/classic/schemas.ts": "a2b12003d2afaf67832200bd3adbbfa567fcbd7eec0ed7356b487a1b2cae6f62", + ".opencode/node_modules/zod/src/v4/classic/tests/anyunknown.test.ts": "c5fbbcc05862414d2bf8d8c33e90b15f07d2542e56408e668bc4ab8dcdae5758", + ".opencode/node_modules/zod/src/v4/classic/tests/array.test.ts": "a73d624a81a7308e901b2584c491eb40dcb05f04c2acd96fb90f96bff5547058", + ".opencode/node_modules/zod/src/v4/classic/tests/assignability.test.ts": "40dfb44f27f5ca10cd854d050e203a228ef616424eda3ade56e0a1e70bb34c92", + ".opencode/node_modules/zod/src/v4/classic/tests/async-parsing.test.ts": "5854e0b2712dea24b27f8d0b5dad384e06eaa785539e1388df941fb4fe6ff80b", + ".opencode/node_modules/zod/src/v4/classic/tests/async-refinements.test.ts": "ac573cf82efdba4cf92c86ac959f63177c4622338609848869f778c0d125cdb2", + ".opencode/node_modules/zod/src/v4/classic/tests/base.test.ts": "0401d2c75ed792b13bf811fc77801c485b89671460a4d52439cf1908fe94f2b7", + ".opencode/node_modules/zod/src/v4/classic/tests/bigint.test.ts": "85cbf87b9df6b154ad7d3f5df030b38fb2c2761dcbc2d4a2ddc7ba06de95433d", + ".opencode/node_modules/zod/src/v4/classic/tests/brand.test.ts": "bceffeda14e55a4c6f6d4f2e30fe9a1d8d1199d50bae767c0ac0c4d4aeb41414", + ".opencode/node_modules/zod/src/v4/classic/tests/catch.test.ts": "de6705d2346629d186396674c7e773b8c8193208337972b2d6cb99be064fbab1", + ".opencode/node_modules/zod/src/v4/classic/tests/coalesce.test.ts": "e03d0cc88ac34605b99061da2e0ce22a7e7fbca0467819096994bcf63bb5b8f9", + ".opencode/node_modules/zod/src/v4/classic/tests/codec-examples.test.ts": "3498ad27ece4f8d97a9588728a909d6fee046b7ef8d6d9f442ce702193f0d789", + ".opencode/node_modules/zod/src/v4/classic/tests/codec.test.ts": "7e21189d8f75aa63d8a164945b9ed7fa06d621a8b5c13c323b32039eb30b46d2", + ".opencode/node_modules/zod/src/v4/classic/tests/coerce.test.ts": "b1c494141f3120e5f4253c2bbe1131468d32281e8a9513f27fb40fee3142b55a", + ".opencode/node_modules/zod/src/v4/classic/tests/continuability.test.ts": "634af853f6509497f8d441045039fd1b6f43fcece4be813ec56c3f25134b53d0", + ".opencode/node_modules/zod/src/v4/classic/tests/custom.test.ts": "130ec42a9e192f5b4f43a890303921cb0ec7f1e878a4f0d6a1aacfe863ff1b91", + ".opencode/node_modules/zod/src/v4/classic/tests/date.test.ts": "e578d7bea1019b22cbc675788e5cc7123f9c3fc2ed90e8a8cb222ddb0cdb8b76", + ".opencode/node_modules/zod/src/v4/classic/tests/datetime.test.ts": "b511934d118fa8dc605900ab770a4f71fededd603f57a83540993e31b40c4fdc", + ".opencode/node_modules/zod/src/v4/classic/tests/default.test.ts": "e6cdf299ffff3de4ddc745c018065484ff01575033c130fdc671f6e84879c94e", + ".opencode/node_modules/zod/src/v4/classic/tests/description.test.ts": "c7f59501ffad7792ae76c178e6098de70c3da062280dc788f3354187335cfd8f", + ".opencode/node_modules/zod/src/v4/classic/tests/discriminated-unions.test.ts": "8098b485b5f6279a609d21aaf51fb2f56177c84577bd903b028f0e5e5a6c9dea", + ".opencode/node_modules/zod/src/v4/classic/tests/enum.test.ts": "2a76a8b5c6116fb06a249284afaf3aba4512a790264f86871857a0b5f6af7650", + ".opencode/node_modules/zod/src/v4/classic/tests/error-utils.test.ts": "61f208ad1009e950137ad8da3ebc8ab78a44d2a23fafea788c0bd4247bf7de86", + ".opencode/node_modules/zod/src/v4/classic/tests/error.test.ts": "f38b436b7cba83cb35993e05fc7feeb7e8e1dc6695dd069936e232412f6fce5a", + ".opencode/node_modules/zod/src/v4/classic/tests/file.test.ts": "3c6d15d9c9230a1047e2036aa7c56ee9bed22d8b91dccab6c7afeec42fe978e9", + ".opencode/node_modules/zod/src/v4/classic/tests/firstparty.test.ts": "60e754acdfeb6bac8dc94fe59f15df0b0a94e9fbead52d230f27ea687aa85cc9", + ".opencode/node_modules/zod/src/v4/classic/tests/function.test.ts": "d052609b0510d42fc956e427a797fbd42cedfc7f2b9f401a200f5c9dc46428bf", + ".opencode/node_modules/zod/src/v4/classic/tests/generics.test.ts": "e4dc0d13796ff0d0ef8b5286dd168afec62ddeef66fae429cc9860b2bbd8f80b", + ".opencode/node_modules/zod/src/v4/classic/tests/hash.test.ts": "14633fa651f6bbdd23ef23a8ce4c4520c3981f7462980f8b8cef8ee5fdc85ef5", + ".opencode/node_modules/zod/src/v4/classic/tests/index.test.ts": "ba5dfbd8b70a5fd894b239ffe1e9beecb7d5603641ff7b375dce062b3f626229", + ".opencode/node_modules/zod/src/v4/classic/tests/instanceof.test.ts": "483a97ca348233bcf363859b2354dd6f1974d131091cc6b1953f8ae974b27503", + ".opencode/node_modules/zod/src/v4/classic/tests/intersection.test.ts": "f443b615937d929a381a7a8b1b0ec17f63f1514e8a5d8059d63e4ad4d52c1334", + ".opencode/node_modules/zod/src/v4/classic/tests/json.test.ts": "ee8aeddf8696bb9dc97d360217b4140ed9434ec6c6e8598ccda18094c6981c2d", + ".opencode/node_modules/zod/src/v4/classic/tests/lazy.test.ts": "4c3c1ac8620955f7f19713771ef48d8401fa0deecd0e1dfda205fecf89f8c8cc", + ".opencode/node_modules/zod/src/v4/classic/tests/literal.test.ts": "a861896be65d22ddcc0cb10bfcb3bc8bd8ed0397406617d0cb1f76f69768def6", + ".opencode/node_modules/zod/src/v4/classic/tests/map.test.ts": "2847a83a1ab7c56e6bc75f16589ebc732eac91a4b9a4ed95b347669a47b116af", + ".opencode/node_modules/zod/src/v4/classic/tests/nan.test.ts": "2ff494cc0c8c785f29b0b4c2eff2e93b3f829b518266d78a05ee40fea9ffc11a", + ".opencode/node_modules/zod/src/v4/classic/tests/nested-refine.test.ts": "8e3f3492563a93adaded8b352e03a087f1c5c2f6a5fbc853ca4e2706ea006799", + ".opencode/node_modules/zod/src/v4/classic/tests/nonoptional.test.ts": "f14e374f6e33d7ada15398cde5248cb66f3c946c9734382f467c3c7ea4f5fc44", + ".opencode/node_modules/zod/src/v4/classic/tests/nullable.test.ts": "a749dc3a8ca78ac05c94abd82958c692b492e4ed6d93c6c05ad1b9a769067850", + ".opencode/node_modules/zod/src/v4/classic/tests/number.test.ts": "08680e3f8f46c1438b6305fa3fdde01ab779c8082b53187e1578bfd717ea3460", + ".opencode/node_modules/zod/src/v4/classic/tests/object.test.ts": "fe0edca4f092f0b478f4e9ea307d3f2ab6c996465d4395627320043c99694fe6", + ".opencode/node_modules/zod/src/v4/classic/tests/optional.test.ts": "61addfd84555a58342a1073ac090ca8f0b8d8fbdfa8a0c7bc9b7f852fc23ddb7", + ".opencode/node_modules/zod/src/v4/classic/tests/partial.test.ts": "1ddc98d1b019d447895db51ec71c3caa8afbafa1bf252c7982225e2aa2bb6fde", + ".opencode/node_modules/zod/src/v4/classic/tests/pickomit.test.ts": "fd55dd310c6197a27fdd486dfbb94649079b422a2951188c4b4ea784a693a1db", + ".opencode/node_modules/zod/src/v4/classic/tests/pipe.test.ts": "9383a52f0f85e97ff34e7bd485bedf2c1a5ef3ffd37ace47001de7b885b7adfd", + ".opencode/node_modules/zod/src/v4/classic/tests/prefault.test.ts": "1943868cdc707eef785d48dd9d73ff10f764beed2b706ac73a0aed85a428b740", + ".opencode/node_modules/zod/src/v4/classic/tests/preprocess.test.ts": "04f3f845eda2e0f36a35c5caa871e7e1169b733bbe20ec04a09b2b374a254357", + ".opencode/node_modules/zod/src/v4/classic/tests/primitive.test.ts": "b5a2f74d03039eff1d20229ab35594d1e9a808dc50b943e802724525338f9f1f", + ".opencode/node_modules/zod/src/v4/classic/tests/promise.test.ts": "b7d58a54d480429314f23cd52c2a78baefaa5c1e87f4bfd0b13a9e30d586c754", + ".opencode/node_modules/zod/src/v4/classic/tests/prototypes.test.ts": "c6e493d089995c8aecc728b99bd2dbcb6f56137be4537c3e2fd657838c582ebc", + ".opencode/node_modules/zod/src/v4/classic/tests/readonly.test.ts": "c2ddd379f4e85caa2856ba0b784c44be87c079cef18ba4108ba698fa9fecb611", + ".opencode/node_modules/zod/src/v4/classic/tests/record.test.ts": "25b6ce62b9a6736d821ea62e07106aaa73a42de7f8824ffc97463e5095a110be", + ".opencode/node_modules/zod/src/v4/classic/tests/recursive-types.test.ts": "d2d4570462ad84cf6b3fb17d99ad4d4c242b4e5d078c1775b530b8f721ba9407", + ".opencode/node_modules/zod/src/v4/classic/tests/refine.test.ts": "df89ea9a88dd60b38001b3a31b643f99d207f39a6d225e6bc107d6c44d4682d0", + ".opencode/node_modules/zod/src/v4/classic/tests/registries.test.ts": "4325f184075634098ec061b2b6070bc7b27c55301191a444d41662e029ce6e33", + ".opencode/node_modules/zod/src/v4/classic/tests/set.test.ts": "4e3d1d08df1bcd2e659a2f6de0f12f816e756ac9ec98b253cdb931ffe8ecc951", + ".opencode/node_modules/zod/src/v4/classic/tests/standard-schema.test.ts": "af5fef06f1d9f695d39842ae98c29555f05c432d2fe5abd361a20c748de39c7c", + ".opencode/node_modules/zod/src/v4/classic/tests/string-formats.test.ts": "d6b61815603bbfc8a7acaeac23d88773195c45991c7b85dffc85e23f24b37640", + ".opencode/node_modules/zod/src/v4/classic/tests/string.test.ts": "bf41f94cf272b01900fd4cc0086aa6bc276c181ab430c45010c74ce0a7c665e8", + ".opencode/node_modules/zod/src/v4/classic/tests/stringbool.test.ts": "6a5f06aa81c442476c2c656607ec580707afbb08df67eb813ce7dd0f20c42fdd", + ".opencode/node_modules/zod/src/v4/classic/tests/template-literal.test.ts": "9662e3535763a01abef78e5775aa82559b4286b0cdf1aa71ecc3e3123358def5", + ".opencode/node_modules/zod/src/v4/classic/tests/to-json-schema.test.ts": "f5c70e5db4f70be9b2fb7a24f54a329b20123fa61d54cdf120e1dc8d0d7af879", + ".opencode/node_modules/zod/src/v4/classic/tests/transform.test.ts": "250d1faea61566e13c1067b3c2fe900af97219c19cfc4a530d48a3047d40f048", + ".opencode/node_modules/zod/src/v4/classic/tests/tuple.test.ts": "96d3aa6cfec7dfd9febdee31b1aca837c0a79c378678b9e833fa5479cff9fbfc", + ".opencode/node_modules/zod/src/v4/classic/tests/union.test.ts": "f286b4b36f5097f2b127e632400b2627b714e4b4a82872adbe0e47198866dc39", + ".opencode/node_modules/zod/src/v4/classic/tests/validations.test.ts": "b2dd61b3b20b082cee04ef5d988cb4852909f9f0e71bbaf2a91f1a681bc674be", + ".opencode/node_modules/zod/src/v4/classic/tests/void.test.ts": "a1e5ff5f759a09e192764d4f153443bc5735cf8b6db6d810e21f0dddb414e805", + ".opencode/node_modules/zod/src/v4/core/api.ts": "6fb05b277c361f635d0aa0c368b12ac8873d497a62d31c9b83b8e64c38a2b3e3", + ".opencode/node_modules/zod/src/v4/core/checks.ts": "8159fb8da563bdaff4691d7e335c4512644b8fe83eb5d52c1b120357eeb18202", + ".opencode/node_modules/zod/src/v4/core/config.ts": "3c3ec0c03ee7387685f7a5e0155d9d55f251ffe4cfb5f4447bcbd380aed3d847", + ".opencode/node_modules/zod/src/v4/core/core.ts": "ab5c675bf8aae560c8f98f20060bae665969ebac1206470b787015897913795b", + ".opencode/node_modules/zod/src/v4/core/doc.ts": "496e63b612809bf031a1964bb2e74c1af4c1a4ba1f8e5cd6f164818ea135205a", + ".opencode/node_modules/zod/src/v4/core/errors.ts": "e31623519621b96a48f3b77c9ee06ed03a56ebe9e3084ec355028ab000d2c2f8", + ".opencode/node_modules/zod/src/v4/core/index.ts": "494aaa47240956231f7de5184f7f0828eaa15ba5c5f59fbda42da7b1348258ce", + ".opencode/node_modules/zod/src/v4/core/json-schema.ts": "42b03730793dc873cce370997da2b1347b00d27effab1793173d7fe7bf422f0c", + ".opencode/node_modules/zod/src/v4/core/parse.ts": "d57d675f81cc59fb20fbc25305f38379af0600bdfe2779343cc2b4dfc5bc97bf", + ".opencode/node_modules/zod/src/v4/core/regexes.ts": "2d2760dcf3fe783459076b0d4abfbc339e6950d49a5546f8a48238424e499f6f", + ".opencode/node_modules/zod/src/v4/core/registries.ts": "6b4495ef30e97ed0d2a596627db079c529778230b30a2349d60211fd7d1b0a62", + ".opencode/node_modules/zod/src/v4/core/schemas.ts": "a2622476b8e40f9437a30b3c43060292a8288134b68ef229aa6197ef5f37fa93", + ".opencode/node_modules/zod/src/v4/core/standard-schema.ts": "a735603c641e08e97ac18157e4a427334ed7026ecc49ddb8abd8e3fc8d841294", + ".opencode/node_modules/zod/src/v4/core/tests/extend.test.ts": "e4705c146e848c2d9a3b4051136e6c38a1b176ce2e223e474de29e227f4fc078", + ".opencode/node_modules/zod/src/v4/core/tests/index.test.ts": "f9a514f603c4350f532d14c5a3fd4748935c4ffb1ab90fa8e8df8671163a7524", + ".opencode/node_modules/zod/src/v4/core/tests/locales/be.test.ts": "d6c1dbf1710130c39d58aa149c5f33cea1e445f05fda5fff6c6e6393358d699d", + ".opencode/node_modules/zod/src/v4/core/tests/locales/en.test.ts": "5bb1540b21984c313c14c4b6e91e14c8b4ec40dd42d0f30bdf24c04543971a29", + ".opencode/node_modules/zod/src/v4/core/tests/locales/es.test.ts": "08d570db1dd2cd1ddd16ecef4c6d9d7e3e293937e255ef6cb960fbabb5ba4a02", + ".opencode/node_modules/zod/src/v4/core/tests/locales/ru.test.ts": "1944b0643c6ef9e194e5256515363d9f475eacb7339ff1745a7629ca38109dd8", + ".opencode/node_modules/zod/src/v4/core/tests/locales/tr.test.ts": "ac6abcbff8137f499ab657115612c32ceb9b0c12903e27f988e8a97d208d2652", + ".opencode/node_modules/zod/src/v4/core/to-json-schema.ts": "207d5dd7d8d3f7a95ba12ce19b1a796e05fb1469e62e03e0163d84378a6d1829", + ".opencode/node_modules/zod/src/v4/core/util.ts": "964d81422e18b3e2645eb4377a24d68a648c938edfc55193efc70b36a35ad437", + ".opencode/node_modules/zod/src/v4/core/versions.ts": "10019f749ef18db03e5e02505ecf581180a183f9838d2571de6d051805906661", + ".opencode/node_modules/zod/src/v4/core/zsf.ts": "975e61b52c8de9fad0a0086952b9ee8a1abbacfa4489659529c53e80dd78094e", + ".opencode/node_modules/zod/src/v4/index.ts": "9f0dfd9a085d4ec23e8a073406300e4bffeb0fd3540b1f395c44b566f27d4d49", + ".opencode/node_modules/zod/src/v4/locales/ar.ts": "94cc038af03f0b0c8b264c1c55f107c93aa4b17c8c39759cac69c69ed5d9663a", + ".opencode/node_modules/zod/src/v4/locales/az.ts": "3d2f271c94a005a23cc831ed1a3a39167832f950048f6845bb5aab510e6b1188", + ".opencode/node_modules/zod/src/v4/locales/be.ts": "58396251205234d11850d154228b5cdbf96f7887f48b19d59ebc59a7c48adee7", + ".opencode/node_modules/zod/src/v4/locales/bg.ts": "480c87b4f81eacca8777c572611425944ebd705401ea922e0e3ee97640783514", + ".opencode/node_modules/zod/src/v4/locales/ca.ts": "d76dc77da7f7d89c3625ed821e333474f964fbfb69ab256007491d483a7fd11c", + ".opencode/node_modules/zod/src/v4/locales/cs.ts": "cef69a633387974e6703cc4eca16c242f889fa3754bc559d9277b705e627a1c9", + ".opencode/node_modules/zod/src/v4/locales/da.ts": "c76ab429619634c96263dbad70aca205e1cb9708f4e5bbe1159d69f7680c3026", + ".opencode/node_modules/zod/src/v4/locales/de.ts": "5abfa7d3751f211b66b833c8183c9614e371f9c987fa7baa60857135fec14d64", + ".opencode/node_modules/zod/src/v4/locales/en.ts": "f6e748ebd8de82a83281883a4180536070c96c2db4a159f23987b0e4a026c69d", + ".opencode/node_modules/zod/src/v4/locales/eo.ts": "3c0a3c8f8be4c6aa1ad0f792d6b420c29aa7c4ca92dd4832078d0b25538505f5", + ".opencode/node_modules/zod/src/v4/locales/es.ts": "e0e85ea85b55e3723a19337d98b33b9eb4d7ab5f1e803b27c1a0ff4b6c6a47fb", + ".opencode/node_modules/zod/src/v4/locales/fa.ts": "60b1d48339a83add0c5ab7b342072ee5112f868c7e0e66714aa7d2e6da005d13", + ".opencode/node_modules/zod/src/v4/locales/fi.ts": "2fe0d68b72a538a5bccffdf8ea27479e7321d26f0555e29cf827d33663c8e05f", + ".opencode/node_modules/zod/src/v4/locales/fr-CA.ts": "c5ad7b6f17961f82dd13cd046532d0c7b668f7e464132b2f1a9e7d5b531e37e9", + ".opencode/node_modules/zod/src/v4/locales/fr.ts": "da7a384cdf216c57feb3bbc3aef63a43b489a55353602888596b9f8b6e4027d7", + ".opencode/node_modules/zod/src/v4/locales/he.ts": "50e7e62b9079e19baeff8890eb60757eaef186527d464baaf93ebb6bb3c2a048", + ".opencode/node_modules/zod/src/v4/locales/hu.ts": "825c84d6fe0d5d1d410609df4efa20db893dd4c22eb93f2aa075f599fdec375b", + ".opencode/node_modules/zod/src/v4/locales/id.ts": "8f61c1779a45459f0606edc8e5e85eba0bc79de96b9ae6d3af9753d77fab5027", + ".opencode/node_modules/zod/src/v4/locales/index.ts": "26965e789e579cd10968fb6ca81caa2ae50c16608185b16dbe84eb00a652b0cf", + ".opencode/node_modules/zod/src/v4/locales/is.ts": "59d68dc8bb4c4655c301b0d875f27d0c9f9a8af72b8acb85463b6205de0b1cc1", + ".opencode/node_modules/zod/src/v4/locales/it.ts": "28f0f40702fd7d91461ccbb466d1d1ab3fdc3a702707e1ed356ff5e51453345d", + ".opencode/node_modules/zod/src/v4/locales/ja.ts": "87383f9f7101075eb087968f0d12084cdecf23a8e4af0b91ebf78ad8b788effb", + ".opencode/node_modules/zod/src/v4/locales/ka.ts": "cfe200ef72f4a56b05a6554d1af37d9bd00fe4c3413b0fd3a5cf97ed8a35a9c5", + ".opencode/node_modules/zod/src/v4/locales/kh.ts": "b55c1375cf0fbb40248ccc5d940074ef1448d38902ef0c8017d7ebda43649efb", + ".opencode/node_modules/zod/src/v4/locales/km.ts": "f68820276449ef124e35fa38a1b1d784dc5b4411bf9ee7127ffdaae3393882d8", + ".opencode/node_modules/zod/src/v4/locales/ko.ts": "ca90a3c670657d1f59253534bbc5ca56b73549d9a500229c605f269650802361", + ".opencode/node_modules/zod/src/v4/locales/lt.ts": "c8008b07f65ee7d3edfc61521dea423e1c4f746ef21e607cea0a3e8f4c6c40bd", + ".opencode/node_modules/zod/src/v4/locales/mk.ts": "536ccf5033c0153b424cafff555b7d4c6044b3bcc6ec73d65bca27c871d5be6b", + ".opencode/node_modules/zod/src/v4/locales/ms.ts": "689764fc38f037e660712af64ad285855c545762009cb7a8ad880ec6dfdf99df", + ".opencode/node_modules/zod/src/v4/locales/nl.ts": "cd4bf4207ff3a00fecb01b8d3cf56fa704d240b13f57527a5089f585aa657dc2", + ".opencode/node_modules/zod/src/v4/locales/no.ts": "09548cb18fceb85bb5d76cd618b8a822db9d170290bd51bc39806620a39ecf88", + ".opencode/node_modules/zod/src/v4/locales/ota.ts": "24bf7362b0b056f4fda7638b473401ecf218d3741f5face257735194a5328dee", + ".opencode/node_modules/zod/src/v4/locales/pl.ts": "09fe366cf11786b78a03c87c2224bac3358e629c944a232bbc32dc503ed19c47", + ".opencode/node_modules/zod/src/v4/locales/ps.ts": "a9c963e2339118f0c19b77e0743a8cf910c6edef62f189fb0a3643e336c2ffec", + ".opencode/node_modules/zod/src/v4/locales/pt.ts": "42f35b9c59c2bba49922ad3ada63b0408fe8de9b88807d6858af890ec255b622", + ".opencode/node_modules/zod/src/v4/locales/ru.ts": "99fff5403ea8c01263675e99ba7b80249fe3aa00dac829d1a20f8e26331268ac", + ".opencode/node_modules/zod/src/v4/locales/sl.ts": "a381cd500472dae38b617c0b32a4617b89ad9475218fd87bff0192f528ae787f", + ".opencode/node_modules/zod/src/v4/locales/sv.ts": "67aee549129f6f460bd500278347e13c96322a35f0c12c76aa3632cc3dbe724d", + ".opencode/node_modules/zod/src/v4/locales/ta.ts": "db63f8cc63fb88be90e822a0803e9c4b03bf96935182eb1c86229b4cc9300221", + ".opencode/node_modules/zod/src/v4/locales/th.ts": "b1c1e68c1b1d81fe26ba945b6cbec2dc2c4138c94b243b6827dd6a156ac7181e", + ".opencode/node_modules/zod/src/v4/locales/tr.ts": "57bfe52d1d8554cce8a2df4953cffea1fd69e59cd539fc0f8b54210abd1288ad", + ".opencode/node_modules/zod/src/v4/locales/ua.ts": "46a90664b22f2b079c7ee1d36c957df8b39442717c3df68c8bbb6a0df20eadce", + ".opencode/node_modules/zod/src/v4/locales/uk.ts": "db64683bb605b98c1a75e64dce0debf3717d1c44d8d196091975d7cf0c348e4a", + ".opencode/node_modules/zod/src/v4/locales/ur.ts": "1a1f050550cfcefb762ca6d9f2c0f46550613a5ec4ec835c5786f40c8ae8daf9", + ".opencode/node_modules/zod/src/v4/locales/vi.ts": "c417610d93565c91c8e730fb510d56baa8024d6757f21be03c54ad31d662f5e0", + ".opencode/node_modules/zod/src/v4/locales/yo.ts": "e8ce5e170c20aff2bb4e02a0708f68be021c1a3a4cbf4f41597e801c97b55f02", + ".opencode/node_modules/zod/src/v4/locales/zh-CN.ts": "a3d8b1bc3758099a6385f175f312f28505d15178d65557fd49db8bd686069cc0", + ".opencode/node_modules/zod/src/v4/locales/zh-TW.ts": "28b342ec5c1e2de8d2525893f4ee81407635fc500fa6560a74209513730eb8ae", + ".opencode/node_modules/zod/src/v4/mini/checks.ts": "c4f16c0e97aa6f0385c0bd147a0438f522343797d72f4cf0cb466213e0b74635", + ".opencode/node_modules/zod/src/v4/mini/coerce.ts": "e80b80452e5c3a248d3deb1044f14d9810d2e86642f943c10999f47116f226fc", + ".opencode/node_modules/zod/src/v4/mini/external.ts": "5ead78dfebae73aa37f3a9e4778f5558582e7caa442bbebe22587dcb00ff0da2", + ".opencode/node_modules/zod/src/v4/mini/index.ts": "455d93e660b61feec18cd4c31e2f394627546ae8ab31f9bacdab6318112ac31c", + ".opencode/node_modules/zod/src/v4/mini/iso.ts": "2d1a5e84a00aca5c3f1b2cebeb1b9a12ccfb7b38c15c96219571a20d72e39a03", + ".opencode/node_modules/zod/src/v4/mini/parse.ts": "f56f7674e03f0ff117dc804c6cb50361d9dc6ad33a12277009ec753ff99ccf0e", + ".opencode/node_modules/zod/src/v4/mini/schemas.ts": "3927bad5c4ac8c77fced35055da583975ad8e8dcdfe8baf75b4550f381bfe553", + ".opencode/node_modules/zod/src/v4/mini/tests/assignability.test.ts": "bf52ca9e89d34717a46dce06d03f2000027009ceaa0798dfa3bf8e47b5d537f6", + ".opencode/node_modules/zod/src/v4/mini/tests/brand.test.ts": "72b40e92c5ad4442024edd3681afbdf986476efff01929efb427b82c5b124710", + ".opencode/node_modules/zod/src/v4/mini/tests/checks.test.ts": "14c8c7c6fba2bc5a06a5f7fbfdf7774bf7a7135c233b47887cb306e5b7f71aa3", + ".opencode/node_modules/zod/src/v4/mini/tests/codec.test.ts": "e9d0836fc48f37a1e476b8a642140d57faecc0a885aefd4828c2ff6324750eec", + ".opencode/node_modules/zod/src/v4/mini/tests/computed.test.ts": "675fc1a036b656b796959fd8f90d6d3d5655c3f6788769e798f56447fba02d58", + ".opencode/node_modules/zod/src/v4/mini/tests/error.test.ts": "72d8e9e476dc934d09d5311fff1823b7f74f169ce30b696cf6870e6b514b1cf4", + ".opencode/node_modules/zod/src/v4/mini/tests/functions.test.ts": "438082fe06c6a89aa9f14ca53acd015be69205fb9aa26e8bb12d6494ee14005e", + ".opencode/node_modules/zod/src/v4/mini/tests/index.test.ts": "7ac070d07667b540db4ff4d87cd0c65574afa16df1e46ac459e720ae1cc08928", + ".opencode/node_modules/zod/src/v4/mini/tests/number.test.ts": "5e546f7b16d97b2a30fea914a900fa48d0de173ee58eb8a3bde04e1783e7c7e5", + ".opencode/node_modules/zod/src/v4/mini/tests/object.test.ts": "8806c1f526663a5a17d2c719dec7779b8070c9f7e8993d5787a303c862a61b21", + ".opencode/node_modules/zod/src/v4/mini/tests/prototypes.test.ts": "53d57aa1c4401cbe1958be7521b65c72ff55cf4993b1ae57543f4138f2c1e2e1", + ".opencode/node_modules/zod/src/v4/mini/tests/recursive-types.test.ts": "3797e364c9c68f42efca56dd1bb70209c70f1d16c3628d23ac302b2667d1e65c", + ".opencode/node_modules/zod/src/v4/mini/tests/string.test.ts": "38d0b96715d3d7472b8defb4e4e9ae2171b96a26f287f810eb30b52853c8db2b", + ".opencode/node_modules/zod/src/v4-mini/index.ts": "cf94ca2939d655e17fdfd1d0a3aceb4cbeb040d8946e9c9c1eaa5cc734dc69c8", + ".opencode/node_modules/zod/v3/ZodError.cjs": "88b9cdd780a91656965e07b94c86c0e4729f97923d45cc9d7944b0973a462503", + ".opencode/node_modules/zod/v3/ZodError.d.cts": "206e73f49f16633113787cc651dc03dc900379395dfa02ab1ef4c9cbbcd5adc2", + ".opencode/node_modules/zod/v3/ZodError.d.ts": "98ee86deadaf36f67986bae4987cfa4b7dfa135ab03706362187d5d00f6282a7", + ".opencode/node_modules/zod/v3/ZodError.js": "f5ac9e86b92e201d41e294d7aa35986a2aa28829e1bcc8ac25f7c8dce0674b99", + ".opencode/node_modules/zod/v3/errors.cjs": "bd32dbc86faeec2de1cc8e42ee3b29af2cbeb24fb5e3b57d7f056bb09cf8d4bd", + ".opencode/node_modules/zod/v3/errors.d.cts": "e3498cf5e428e6c6b9e97bd88736f26d6cf147dedbfa5a8ad3ed8e05e059af8a", + ".opencode/node_modules/zod/v3/errors.d.ts": "8e71e53b02c152a38af6aec45e288cc65bede077b92b9b43b3cb54a37978bb33", + ".opencode/node_modules/zod/v3/errors.js": "d40831f478288d76e82bbbcc3b7e95c8513b6e76471f2d49a37c979bc57d492d", + ".opencode/node_modules/zod/v3/external.cjs": "224ff76e204434ec469f71dbba41d475db1ea873d3f3a5c26d75ab3a9c9f5f11", + ".opencode/node_modules/zod/v3/external.d.cts": "a9ebb67d6bbead6044b43714b50dcb77b8f7541ffe803046fdec1714c1eba206", + ".opencode/node_modules/zod/v3/external.d.ts": "5d26d2e47e2352def36f89a3e8bf8581da22b7f857e07ef3114cd52cf4813445", + ".opencode/node_modules/zod/v3/external.js": "5d26d2e47e2352def36f89a3e8bf8581da22b7f857e07ef3114cd52cf4813445", + ".opencode/node_modules/zod/v3/helpers/enumUtil.cjs": "d43aa81f5bc89faa359e0f97c814ba25155591ff078fbb9bfd40f8c7c9683230", + ".opencode/node_modules/zod/v3/helpers/enumUtil.d.cts": "f672c876c1a04a223cf2023b3d91e8a52bb1544c576b81bf64a8fec82be9969c", + ".opencode/node_modules/zod/v3/helpers/enumUtil.d.ts": "f672c876c1a04a223cf2023b3d91e8a52bb1544c576b81bf64a8fec82be9969c", + ".opencode/node_modules/zod/v3/helpers/enumUtil.js": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", + ".opencode/node_modules/zod/v3/helpers/errorUtil.cjs": "9c9e5dec7d1e9fce6a967735adf0333abd44d5e94a0225537c60ceed0c4f606a", + ".opencode/node_modules/zod/v3/helpers/errorUtil.d.cts": "e4b03ddcf8563b1c0aee782a185286ed85a255ce8a30df8453aade2188bbc904", + ".opencode/node_modules/zod/v3/helpers/errorUtil.d.ts": "e4b03ddcf8563b1c0aee782a185286ed85a255ce8a30df8453aade2188bbc904", + ".opencode/node_modules/zod/v3/helpers/errorUtil.js": "68589c0cf44c8b4ad19b77418f53caad2cd1dbc8d714f526e1876ebdee4828c5", + ".opencode/node_modules/zod/v3/helpers/parseUtil.cjs": "40aaa18765f13ae1a933c6f215ecceb6d3afee89eaa23bb080997e08c30a5b00", + ".opencode/node_modules/zod/v3/helpers/parseUtil.d.cts": "dba3f34531fd9b1b6e072928b6f885aa4d28dd6789cbd0e93563d43f4b62da53", + ".opencode/node_modules/zod/v3/helpers/parseUtil.d.ts": "754a9396b14ca3a4241591afb4edc644b293ccc8a3397f49be4dfd520c08acb3", + ".opencode/node_modules/zod/v3/helpers/parseUtil.js": "b57484013bb360bac2e334d58fcd5f6cbfa63ffcc7d8ff3dbbb3380ca659b956", + ".opencode/node_modules/zod/v3/helpers/partialUtil.cjs": "d43aa81f5bc89faa359e0f97c814ba25155591ff078fbb9bfd40f8c7c9683230", + ".opencode/node_modules/zod/v3/helpers/partialUtil.d.cts": "2329d90062487e1eaca87b5e06abcbbeeecf80a82f65f949fd332cfcf824b87b", + ".opencode/node_modules/zod/v3/helpers/partialUtil.d.ts": "de2316e90fc6d379d83002f04ad9698bc1e5285b4d52779778f454dd12ce9f44", + ".opencode/node_modules/zod/v3/helpers/partialUtil.js": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", + ".opencode/node_modules/zod/v3/helpers/typeAliases.cjs": "d43aa81f5bc89faa359e0f97c814ba25155591ff078fbb9bfd40f8c7c9683230", + ".opencode/node_modules/zod/v3/helpers/typeAliases.d.cts": "d3cfde44f8089768ebb08098c96d01ca260b88bccf238d55eee93f1c620ff5a5", + ".opencode/node_modules/zod/v3/helpers/typeAliases.d.ts": "d3cfde44f8089768ebb08098c96d01ca260b88bccf238d55eee93f1c620ff5a5", + ".opencode/node_modules/zod/v3/helpers/typeAliases.js": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", + ".opencode/node_modules/zod/v3/helpers/util.cjs": "9b75d4955de684d11371054bbf8e4d747cb1ad7284ae1eda85d4c188b84c47fc", + ".opencode/node_modules/zod/v3/helpers/util.d.cts": "293eadad9dead44c6fd1db6de552663c33f215c55a1bfa2802a1bceed88ff0ec", + ".opencode/node_modules/zod/v3/helpers/util.d.ts": "293eadad9dead44c6fd1db6de552663c33f215c55a1bfa2802a1bceed88ff0ec", + ".opencode/node_modules/zod/v3/helpers/util.js": "906ef685e5853d496e293ee8f8a2bb8b1ed2d078a1bcdfedb2dfd61d75b6330c", + ".opencode/node_modules/zod/v3/index.cjs": "d58a1fea61e0f0bccdb2f4fd3b7b098adcfddb44f036302c4d2c4e173540a292", + ".opencode/node_modules/zod/v3/index.d.cts": "833e92c058d033cde3f29a6c7603f517001d1ddd8020bc94d2067a3bc69b2a8e", + ".opencode/node_modules/zod/v3/index.d.ts": "3db2efd285e7328d8014b54a7fce3f4861ebcdc655df40517092ed0050983617", + ".opencode/node_modules/zod/v3/index.js": "3db2efd285e7328d8014b54a7fce3f4861ebcdc655df40517092ed0050983617", + ".opencode/node_modules/zod/v3/locales/en.cjs": "5fe55414e39de7fff8ec19983adc1421c238b679f8aa6b9d3311c7f9f12a21f3", + ".opencode/node_modules/zod/v3/locales/en.d.cts": "fec412ded391a7239ef58f455278154b62939370309c1fed322293d98c8796a6", + ".opencode/node_modules/zod/v3/locales/en.d.ts": "c25ce98cca43a3bfa885862044be0d59557be4ecd06989b2001a83dcf69620fd", + ".opencode/node_modules/zod/v3/locales/en.js": "51a6dcc5d4bd52a64da127c012c6c963395c1d9a343ad75d4a8eca662fc8b205", + ".opencode/node_modules/zod/v3/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", + ".opencode/node_modules/zod/v3/standard-schema.cjs": "d43aa81f5bc89faa359e0f97c814ba25155591ff078fbb9bfd40f8c7c9683230", + ".opencode/node_modules/zod/v3/standard-schema.d.cts": "25b3f581e12ede11e5739f57a86e8668fbc0124f6649506def306cad2c59d262", + ".opencode/node_modules/zod/v3/standard-schema.d.ts": "25b3f581e12ede11e5739f57a86e8668fbc0124f6649506def306cad2c59d262", + ".opencode/node_modules/zod/v3/standard-schema.js": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", + ".opencode/node_modules/zod/v3/types.cjs": "9c7618e57545b5e44faecbcfc3bb00eed67ca46efbb11bd1b6ad3a1d69de95eb", + ".opencode/node_modules/zod/v3/types.d.cts": "93c3e73824ad57f98fd23b39335dbdae2db0bd98199b0dc0b9ccc60bf3c5134a", + ".opencode/node_modules/zod/v3/types.d.ts": "7941f67bd576a61bb48f46cf55c5eb0292c8ee3eb2db621815eb1d02b1aa09fb", + ".opencode/node_modules/zod/v3/types.js": "2faaeed1150d0d7126f6aa85b01594fabdd06771a0b7cb5df2d13f725e175ef1", + ".opencode/node_modules/zod/v4/classic/checks.cjs": "a3ec0b1f2ce36c04dff99eced43eef15bd378c50d0113c723c20ad89ac535fae", + ".opencode/node_modules/zod/v4/classic/checks.d.cts": "7b9496d2e1664155c3c293e1fbbe2aba288614163c88cb81ed6061905924b8f9", + ".opencode/node_modules/zod/v4/classic/checks.d.ts": "3a8449ac50228a454f709360f32277f3670ec62e7aabf1e6710538dd3353b2b4", + ".opencode/node_modules/zod/v4/classic/checks.js": "f20eb2e1153f7b80a52d30261dc0b90ad4a5adcfccc9b150d3998afdaea7f9d3", + ".opencode/node_modules/zod/v4/classic/coerce.cjs": "d25b07eead67ff339c3e2a6d5b7364647e11f57fff3e82055d49575304dce057", + ".opencode/node_modules/zod/v4/classic/coerce.d.cts": "e818471014c77c103330aee11f00a7a00b37b35500b53ea6f337aefacd6174c9", + ".opencode/node_modules/zod/v4/classic/coerce.d.ts": "1f4a8bb5e841d3a1510d12a84640ee327f5a4b55484e8f16e2b285d54eb19924", + ".opencode/node_modules/zod/v4/classic/coerce.js": "fb2efe3b6eacc475c77cbb571b5fb8650d92a10f316dc869e49b722973d83c3c", + ".opencode/node_modules/zod/v4/classic/compat.cjs": "dcd3c3b05fbc82ed0241ec7e015071a528a1e2e8025e96259be932e7340608e7", + ".opencode/node_modules/zod/v4/classic/compat.d.cts": "e27451b24234dfed45f6cf22112a04955183a99c42a2691fb4936d63cfe42761", + ".opencode/node_modules/zod/v4/classic/compat.d.ts": "2197ba08e54182521b3cf4fec0a875a9ff76235849875a42acd19f7e8a0c09b8", + ".opencode/node_modules/zod/v4/classic/compat.js": "0e948b101771cc0e7b753e54b3f5b486929d3db9bd4c09d98fe4ce86271bcf7b", + ".opencode/node_modules/zod/v4/classic/errors.cjs": "f704ed9292288d685a33123242399179eba77f1190299bc83042b434c1ae8cd6", + ".opencode/node_modules/zod/v4/classic/errors.d.cts": "6042774c61ece4ba77b3bf375f15942eb054675b7957882a00c22c0e4fe5865c", + ".opencode/node_modules/zod/v4/classic/errors.d.ts": "03be953961283699e72d526e1bff478be5af0840094b7e37d90a15ffd1426f64", + ".opencode/node_modules/zod/v4/classic/errors.js": "fd807f79ded4f88928ab8205de1922901a4d3a8c9b93d072e1ab8c0a44a95df5", + ".opencode/node_modules/zod/v4/classic/external.cjs": "9df0ca0fe9053f1fb7cda1a7c8cc952c6d3d49fbec811fb3bb63d2a845bef211", + ".opencode/node_modules/zod/v4/classic/external.d.cts": "2fbc91ba70096f93f57e22d1f0af22b707dbb3f9f5692cc4f1200861d3b75d88", + ".opencode/node_modules/zod/v4/classic/external.d.ts": "8bae377629b666ce6e171351d3bf9cb1e041dc69409e54f84c366921384ae1cd", + ".opencode/node_modules/zod/v4/classic/external.js": "f4c3233385332f4fba37de6c0e38ccd8c7ba6fe315a58e9777571a5cfd852815", + ".opencode/node_modules/zod/v4/classic/index.cjs": "d58a1fea61e0f0bccdb2f4fd3b7b098adcfddb44f036302c4d2c4e173540a292", + ".opencode/node_modules/zod/v4/classic/index.d.cts": "d8bc0c5487582c6d887c32c92d8b4ffb23310146fcb1d82adf4b15c77f57c4ac", + ".opencode/node_modules/zod/v4/classic/index.d.ts": "d49030b9a324bab9bcf9f663a70298391b0f5a25328409174d86617512bf3037", + ".opencode/node_modules/zod/v4/classic/index.js": "d49030b9a324bab9bcf9f663a70298391b0f5a25328409174d86617512bf3037", + ".opencode/node_modules/zod/v4/classic/iso.cjs": "f5d840d941b87a868da63fba9e4eb5917cadd87100af4940113f0ddd75dd9043", + ".opencode/node_modules/zod/v4/classic/iso.d.cts": "58d65a2803c3b6629b0e18c8bf1bc883a686fcf0333230dd0151ab6e85b74307", + ".opencode/node_modules/zod/v4/classic/iso.d.ts": "50f3d54845c748597afd9fe6a474862ab957f61f274d5fcba6509f5e4174c988", + ".opencode/node_modules/zod/v4/classic/iso.js": "3cc5e6b4da086b8ffc7ee3188c69e4d3c945b368092e78d2299b5321182752c5", + ".opencode/node_modules/zod/v4/classic/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", + ".opencode/node_modules/zod/v4/classic/parse.cjs": "0fb97825ed1c15a1f6a238df9506e23f2e720edeae4774aa46a60ce7b2a75319", + ".opencode/node_modules/zod/v4/classic/parse.d.cts": "5a3bd57ed7a9d9afef74c75f77fce79ba3c786401af9810cdf45907c4e93f30e", + ".opencode/node_modules/zod/v4/classic/parse.d.ts": "07831f097149800006656666f2d1ea5d8e26caf8b40a2dfe951ed941eb2650ce", + ".opencode/node_modules/zod/v4/classic/parse.js": "14cbcc416ab9169abc2cde97af77da54ca769684eac1e03a3753c16ea4a7cd95", + ".opencode/node_modules/zod/v4/classic/schemas.cjs": "3b61b9192a59d6b28b3ea0c832a7e4a76315b92b71b432f497110ebc74193480", + ".opencode/node_modules/zod/v4/classic/schemas.d.cts": "8610f5dc475d74c4b095aafa0c191548bfd43f65802e6da54b5e526202b8cfe0", + ".opencode/node_modules/zod/v4/classic/schemas.d.ts": "0d6d068c3e5309ebef75a4d17eaf5af0b08debc3987f2f89e29f08e61ad26a4a", + ".opencode/node_modules/zod/v4/classic/schemas.js": "e185cecdafd77117e8081811ef006dd0bd478ebeef3a86b458a258d27b34eafe", + ".opencode/node_modules/zod/v4/core/api.cjs": "89921ccd93f2d360deb9c5ef52856cdf0d38fbca5d6b081079d45a40368d5aa0", + ".opencode/node_modules/zod/v4/core/api.d.cts": "56ccc6238510b913f5e6c21afdc447632873f76748d0b30a87cb313b42f1c196", + ".opencode/node_modules/zod/v4/core/api.d.ts": "2bfc055ab5b626d39e4b3e055fadf0622ad7e958316dccc9b1a52d3f40129f4e", + ".opencode/node_modules/zod/v4/core/api.js": "8722c715be582089faa0b96816db906a913697e8777ec247ed11bd2eed2e9fd7", + ".opencode/node_modules/zod/v4/core/checks.cjs": "1c60ee0c8eea5cdfd8439981ed87daa60a63cb5bbd25e2c3419f2ba1c7b65235", + ".opencode/node_modules/zod/v4/core/checks.d.cts": "8d67b13da77316a8a2fabc21d340866ddf8a4b99e76a6c951cc45189142df652", + ".opencode/node_modules/zod/v4/core/checks.d.ts": "3b22881e19fba860247cde1f60807cca7ce44ad78814fe5789316c3cc16208db", + ".opencode/node_modules/zod/v4/core/checks.js": "6d5342cbfc770541ef5f010c7ac534529012036c6482af1c3e57a1afb78c1828", + ".opencode/node_modules/zod/v4/core/core.cjs": "3238fe404d2e0e6e53d221ad0408567f31689c2e204db1007cfd2a4e0513587b", + ".opencode/node_modules/zod/v4/core/core.d.cts": "21360500b20e0ec570f26f1cbb388c155ede043698970f316969840da4f16465", + ".opencode/node_modules/zod/v4/core/core.d.ts": "f0ebd1c2e1708e7e7d105883430d3ee1d856560e26b346469ff584ec68859e9e", + ".opencode/node_modules/zod/v4/core/core.js": "a1f78ff62ae173c6a722b92b394075f3075c7f7d731bc443827aec4f30302d92", + ".opencode/node_modules/zod/v4/core/doc.cjs": "ae6c5bfe9570d30c119cd6bc9dff33f6956858b4fa6887b2b1f9680f11d1b65d", + ".opencode/node_modules/zod/v4/core/doc.d.cts": "add0ce7b77ba5b308492fa68f77f24d1ed1d9148534bdf05ac17c30763fc1a79", + ".opencode/node_modules/zod/v4/core/doc.d.ts": "add0ce7b77ba5b308492fa68f77f24d1ed1d9148534bdf05ac17c30763fc1a79", + ".opencode/node_modules/zod/v4/core/doc.js": "e084bbcc536746a8942fd33b08afe4db345554b3a0383114f1dca95261c958d9", + ".opencode/node_modules/zod/v4/core/errors.cjs": "f919e2356deaef1fd7555e3e15fb3cb8d7594dd8391ed9662b3a1ef6fcb5acda", + ".opencode/node_modules/zod/v4/core/errors.d.cts": "7952419455ca298776db0005b9b5b75571d484d526a29bfbdf041652213bce6f", + ".opencode/node_modules/zod/v4/core/errors.d.ts": "25dac9cd43c711463382917a7e8997d4a34871f864ba8ab0f53df85d03f90a38", + ".opencode/node_modules/zod/v4/core/errors.js": "66c310c429dd28309b17a5fe7310b99ae7e347fd502dc62ec628ebcc900c4e1b", + ".opencode/node_modules/zod/v4/core/index.cjs": "6f688ca5ed36ee7ca8c69f6169f0b06589b6698e743bb8427cf02150969159ea", + ".opencode/node_modules/zod/v4/core/index.d.cts": "d91805544905a40fbd639ba1b85f65dc13d6996a07034848d634aa9edb63479e", + ".opencode/node_modules/zod/v4/core/index.d.ts": "494aaa47240956231f7de5184f7f0828eaa15ba5c5f59fbda42da7b1348258ce", + ".opencode/node_modules/zod/v4/core/index.js": "494aaa47240956231f7de5184f7f0828eaa15ba5c5f59fbda42da7b1348258ce", + ".opencode/node_modules/zod/v4/core/json-schema.cjs": "d43aa81f5bc89faa359e0f97c814ba25155591ff078fbb9bfd40f8c7c9683230", + ".opencode/node_modules/zod/v4/core/json-schema.d.cts": "c1a2e05eb6d7ca8d7e4a7f4c93ccf0c2857e842a64c98eaee4d85841ee9855e6", + ".opencode/node_modules/zod/v4/core/json-schema.d.ts": "c1a2e05eb6d7ca8d7e4a7f4c93ccf0c2857e842a64c98eaee4d85841ee9855e6", + ".opencode/node_modules/zod/v4/core/json-schema.js": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", + ".opencode/node_modules/zod/v4/core/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", + ".opencode/node_modules/zod/v4/core/parse.cjs": "ea957a211bf66fd211842cfd2bcf4bea43aa27c643dc44da012ca63739672ef6", + ".opencode/node_modules/zod/v4/core/parse.d.cts": "3a819c2928ee06bbcc84e2797fd3558ae2ebb7e0ed8d87f71732fb2e2acc87b4", + ".opencode/node_modules/zod/v4/core/parse.d.ts": "7007626fc0d98e012e02caf70ae36647d7288b06c9121b51b20af592ebec3d53", + ".opencode/node_modules/zod/v4/core/parse.js": "a58b9c7be78e29d71f969f01807fe5f9be1f68d222a0f60c25e32e24f3b6639c", + ".opencode/node_modules/zod/v4/core/regexes.cjs": "06962f6496478a089281df50a84c288f703ac00ee8499fe62cb5f4d3e038b832", + ".opencode/node_modules/zod/v4/core/regexes.d.cts": "1765e61249cb44bf5064d42bfa06956455bbc74dc05f074d5727e8962592c920", + ".opencode/node_modules/zod/v4/core/regexes.d.ts": "1765e61249cb44bf5064d42bfa06956455bbc74dc05f074d5727e8962592c920", + ".opencode/node_modules/zod/v4/core/regexes.js": "f2aee057ccd1e081bbec2d1bf9d3ae57e3df72a13e056ff1cd48829158983fad", + ".opencode/node_modules/zod/v4/core/registries.cjs": "e58ac2fb52c7a0e48857787fc92b382afdd1adb6dd03736403cf38d7782677a2", + ".opencode/node_modules/zod/v4/core/registries.d.cts": "6e5857f38aa297a859cab4ec891408659218a5a2610cd317b6dcbef9979459cc", + ".opencode/node_modules/zod/v4/core/registries.d.ts": "7576d06c1b52d6e3dbd8ea3c53778b5a8d8065fbff3678db495c6166a698eda3", + ".opencode/node_modules/zod/v4/core/registries.js": "cec90d369f7206f701fa3f540bc92855a8c9145d44e4d8642053645e73f70002", + ".opencode/node_modules/zod/v4/core/schemas.cjs": "b9ea344ee137a4715c1c827ef133c5b2e344a5dbe1a19170a3de0bec296bafd7", + ".opencode/node_modules/zod/v4/core/schemas.d.cts": "d9faf4a343833207c6c5cd2322fb6771b56dc1c8ece975072e85227c2d326bc2", + ".opencode/node_modules/zod/v4/core/schemas.d.ts": "c74b22865b9e58e6756484d86cd06e58d25987fbd5c9b8fa95a658e4fce4f7cb", + ".opencode/node_modules/zod/v4/core/schemas.js": "73eb7d7474af05d191b86d654fc551fb7e0aca02ff87f6d56dc72cea9218b91f", + ".opencode/node_modules/zod/v4/core/standard-schema.cjs": "d43aa81f5bc89faa359e0f97c814ba25155591ff078fbb9bfd40f8c7c9683230", + ".opencode/node_modules/zod/v4/core/standard-schema.d.cts": "309ebd217636d68cf8784cbc3272c16fb94fb8e969e18b6fe88c35200340aef1", + ".opencode/node_modules/zod/v4/core/standard-schema.d.ts": "309ebd217636d68cf8784cbc3272c16fb94fb8e969e18b6fe88c35200340aef1", + ".opencode/node_modules/zod/v4/core/standard-schema.js": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", + ".opencode/node_modules/zod/v4/core/to-json-schema.cjs": "966cf5cd00087863083890715da106793c4773707924ef96469117f463371b0a", + ".opencode/node_modules/zod/v4/core/to-json-schema.d.cts": "85021a58f728318a9c83977a8a3a09196dcfc61345e0b8bbbb39422c1594f36b", + ".opencode/node_modules/zod/v4/core/to-json-schema.d.ts": "bdc0676fe9b2011992b6abccd1c15a565d2c201412f30c906697422f591d805f", + ".opencode/node_modules/zod/v4/core/to-json-schema.js": "c5974e08383d8de3daac020b49b5d62cda094231d6471bba1e1e1770ab635ce1", + ".opencode/node_modules/zod/v4/core/util.cjs": "6851a80c1cf0dd4859c7321d2d54cad3d3f658db1c8156a74a16807caa31ec1e", + ".opencode/node_modules/zod/v4/core/util.d.cts": "f987c74a4b4baf361afbf22a16d230ee490d662f9aa2066853bb7ebbb8611355", + ".opencode/node_modules/zod/v4/core/util.d.ts": "7062f2d8d0544f85f4c7398d2b08109e3a8c6fb1aae5b4b32b98567f5a9023fe", + ".opencode/node_modules/zod/v4/core/util.js": "d99bf396f177afe547703eff48b9746c360bfc2a2708adfd4a43e73768107b6c", + ".opencode/node_modules/zod/v4/core/versions.cjs": "1206ffbacc4e789fcd8f5d52566b1bfcc5baae49876c419f9bcd51a0e2d323b6", + ".opencode/node_modules/zod/v4/core/versions.d.cts": "1ff91526fcdd634148c655ef86e912a273ce6a0239e2505701561f086678262b", + ".opencode/node_modules/zod/v4/core/versions.d.ts": "1ff91526fcdd634148c655ef86e912a273ce6a0239e2505701561f086678262b", + ".opencode/node_modules/zod/v4/core/versions.js": "bfacecc9f831ae6cc9fc756424f94fae36d4ed196c4a4cd3a34507e38fbeec6c", + ".opencode/node_modules/zod/v4/index.cjs": "35934fbd99d4ca1d46932dfcddb6071c203b5c980862cf58e254998c49e1e5b8", + ".opencode/node_modules/zod/v4/index.d.cts": "8cb31102790372bebfd78dd56d6752913b0f3e2cefbeb08375acd9f5ba737155", + ".opencode/node_modules/zod/v4/index.d.ts": "a4b634bb8c97cc700dbf165f3bb0095ec669042da72eaf28a7c5e2ddd98169ce", + ".opencode/node_modules/zod/v4/index.js": "a4b634bb8c97cc700dbf165f3bb0095ec669042da72eaf28a7c5e2ddd98169ce", + ".opencode/node_modules/zod/v4/locales/ar.cjs": "25d2ec93b17667a2b8d02fdb170b275c853fefcecdcf7ac95b46877c7048a16e", + ".opencode/node_modules/zod/v4/locales/ar.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/ar.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/ar.js": "0c5613adab2e098069d88056df27f0bc361028d94d6af8a6dc46dda381d2d82e", + ".opencode/node_modules/zod/v4/locales/az.cjs": "06ba53956425e1718ce4c9d818a06c04bc999f8fd00f1981c7c74316d4e99f5b", + ".opencode/node_modules/zod/v4/locales/az.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/az.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/az.js": "cfb3ebe34580912dfb02fd3c22dcc80213f29d0ad99062f30c0cf56a9f511258", + ".opencode/node_modules/zod/v4/locales/be.cjs": "8269e9059c1575b909f559f39e49e2e226f1c6eb329c6f1c083c879cb234de09", + ".opencode/node_modules/zod/v4/locales/be.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/be.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/be.js": "b33d29e5b0f20b0520a8a05bbfcb1d96a43c205abac0f852d7e5e3eefc8450be", + ".opencode/node_modules/zod/v4/locales/bg.cjs": "3658e48af9e2858ad89158e6af06a188e57de14aa7cddc6a4903fbd08adcba40", + ".opencode/node_modules/zod/v4/locales/bg.d.cts": "26384fb401f582cae1234213c3dc75fdc80e3d728a0a1c55b405be8a0c6dddbe", + ".opencode/node_modules/zod/v4/locales/bg.d.ts": "3b25e966fd93475d8ca2834194ea78321d741a21ca9d1f606b25ec99c1bbc29a", + ".opencode/node_modules/zod/v4/locales/bg.js": "cdf9a0d1e081b22d62dc927a8ec1bcc850261bd7b56a8dc84d5e94a306ac0db9", + ".opencode/node_modules/zod/v4/locales/ca.cjs": "9adc2f9540839430681dcbf9b0bcc1b78b2d0a94dfc3eac170df43d9e5a577d1", + ".opencode/node_modules/zod/v4/locales/ca.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/ca.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/ca.js": "e3f8525c1ed6a8c1fa6ae9e2871e1a7bd2aec67c89764d51ef920553042337e3", + ".opencode/node_modules/zod/v4/locales/cs.cjs": "c42ea716c2809e37666fb31185718ffb62edb3e458996b30d2fa91df178a0643", + ".opencode/node_modules/zod/v4/locales/cs.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/cs.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/cs.js": "ca1527d68c849ff85e65b8697688719dca8df431724d8f3a64831439017fc5ef", + ".opencode/node_modules/zod/v4/locales/da.cjs": "6f090b1e6a8e61cafadfe9a8395e4fd0744c2d6a47129843afb40a0638064085", + ".opencode/node_modules/zod/v4/locales/da.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/da.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/da.js": "3187af9e566fdd251d09f3d73435a18025ae35ad3d46aa37272b7685a659ec3f", + ".opencode/node_modules/zod/v4/locales/de.cjs": "f08c911edf7e22b55c9bdef7235eaea4d4c36e6467744705652651cb140f7675", + ".opencode/node_modules/zod/v4/locales/de.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/de.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/de.js": "5b416bd06886b8eeedf0de4f162725636cf4b4af286a439fd4fca6bfae26b717", + ".opencode/node_modules/zod/v4/locales/en.cjs": "05a540ad5c222aab1cb15d99fc2c0984140c27f9b761501fc0641a2d582be63a", + ".opencode/node_modules/zod/v4/locales/en.d.cts": "26384fb401f582cae1234213c3dc75fdc80e3d728a0a1c55b405be8a0c6dddbe", + ".opencode/node_modules/zod/v4/locales/en.d.ts": "3b25e966fd93475d8ca2834194ea78321d741a21ca9d1f606b25ec99c1bbc29a", + ".opencode/node_modules/zod/v4/locales/en.js": "e9c8a8c547a75b2c7e87a22387c21e8ae0592f849c7ebd2c92df9d23c6fb9f44", + ".opencode/node_modules/zod/v4/locales/eo.cjs": "12df106b30a0f2e9d2c4256a5b39a29f7fd7d0f0e73ec573f06ddd8c1f3df898", + ".opencode/node_modules/zod/v4/locales/eo.d.cts": "26384fb401f582cae1234213c3dc75fdc80e3d728a0a1c55b405be8a0c6dddbe", + ".opencode/node_modules/zod/v4/locales/eo.d.ts": "3b25e966fd93475d8ca2834194ea78321d741a21ca9d1f606b25ec99c1bbc29a", + ".opencode/node_modules/zod/v4/locales/eo.js": "bbe19901c076716e9fa04400aa7020d4f15c7e574d31b5c351132f3ffc75ca55", + ".opencode/node_modules/zod/v4/locales/es.cjs": "95aee7c4ee66d1b4b8d36b714175ef58fbd1eb32ee0f28b3e88a066ef35d9248", + ".opencode/node_modules/zod/v4/locales/es.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/es.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/es.js": "d8c2c16a0bfa3159692d76bcba304f21197e3daffffbb1c5bd95ae0b347e6fc9", + ".opencode/node_modules/zod/v4/locales/fa.cjs": "5008aabcc546f9175ef10109475d32266e42d608c4dee9ca8d098f3a960d2560", + ".opencode/node_modules/zod/v4/locales/fa.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/fa.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/fa.js": "a7e02bbae31de8be066017ace75dc6651d22a7a9cb8fe98e0a6075d027a902db", + ".opencode/node_modules/zod/v4/locales/fi.cjs": "9dfc1de66f375928dc558cb705e93c0b9a834a3187de88e2b28dea536582e648", + ".opencode/node_modules/zod/v4/locales/fi.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/fi.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/fi.js": "6e4fe271feac955c71c8597a47c4467191e8146ad548d8d3dcd9564e6b5a86cd", + ".opencode/node_modules/zod/v4/locales/fr-CA.cjs": "510114ebc76a692a7bb944afacae441d577c8d0952444b1616c8575dfb63aa68", + ".opencode/node_modules/zod/v4/locales/fr-CA.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/fr-CA.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/fr-CA.js": "a2d5178451c0bd924d1e44cdbfa87109a3ababaf749eb1bc9db00039a2aa4cf4", + ".opencode/node_modules/zod/v4/locales/fr.cjs": "b251a10358ef0839ea36a945c23ab93905b91a55691821ba3e44ec69d0efe742", + ".opencode/node_modules/zod/v4/locales/fr.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/fr.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/fr.js": "e82a9f7655cd269e03fabfa04bb74096674dfec0ee61308c700eab6f2b7a9bb9", + ".opencode/node_modules/zod/v4/locales/he.cjs": "4214761eb524ab2024b7c3ed70b31dc02e7e3d4a9c32878423b61b11376cf5ea", + ".opencode/node_modules/zod/v4/locales/he.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/he.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/he.js": "015ddb02e660e6fd950f26432c6a9748c84f0ef2dbacd9ebf7b7563ab35abba0", + ".opencode/node_modules/zod/v4/locales/hu.cjs": "e1d62ddc5fb9c235248e6b1cf1c32917bafe53081304aeacd39f4ac555f255cb", + ".opencode/node_modules/zod/v4/locales/hu.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/hu.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/hu.js": "f5d7a779f7c4f2ea52bec6aca1c81f4c3a79924639727d2ca311a393ecfbb045", + ".opencode/node_modules/zod/v4/locales/id.cjs": "da98986abda8993196e484c5e5a33ba25c81cdf5b4ca16608dcfa65e6ef1249f", + ".opencode/node_modules/zod/v4/locales/id.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/id.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/id.js": "b67c815e4a8b91c178b610b5876083de40753f1811928e0a9682ff63223d88c6", + ".opencode/node_modules/zod/v4/locales/index.cjs": "203b86031d42ea980a2eebb40c26fd7778108a0f5de40d1caac5f15156d01885", + ".opencode/node_modules/zod/v4/locales/index.d.cts": "7b9e6b3c726d47935bdc9ebc78fe5398e28e751ba7d70e9e011f01fbd5b618be", + ".opencode/node_modules/zod/v4/locales/index.d.ts": "26965e789e579cd10968fb6ca81caa2ae50c16608185b16dbe84eb00a652b0cf", + ".opencode/node_modules/zod/v4/locales/index.js": "26965e789e579cd10968fb6ca81caa2ae50c16608185b16dbe84eb00a652b0cf", + ".opencode/node_modules/zod/v4/locales/is.cjs": "d37d44638830952defe01c248387522fc006645dccdd117d540fe2c1150c8313", + ".opencode/node_modules/zod/v4/locales/is.d.cts": "26384fb401f582cae1234213c3dc75fdc80e3d728a0a1c55b405be8a0c6dddbe", + ".opencode/node_modules/zod/v4/locales/is.d.ts": "3b25e966fd93475d8ca2834194ea78321d741a21ca9d1f606b25ec99c1bbc29a", + ".opencode/node_modules/zod/v4/locales/is.js": "703d8a1d77fb77192acf93fc4787f02d19d91d5bbd5a3aec21e7695b412cb9b2", + ".opencode/node_modules/zod/v4/locales/it.cjs": "fb2e0ab0d2eaf7d0ab5abd37bc00cbe24dd2564de984dc982ad0f33db4c0330a", + ".opencode/node_modules/zod/v4/locales/it.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/it.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/it.js": "5e444bce52d1505992a9803bf3abd30b91ccf5b022a2a3c21a9885daa2b687f1", + ".opencode/node_modules/zod/v4/locales/ja.cjs": "27ce4a1f40a7b777e905ac942d7a9c3f71f43cfbcf6366a1f416b6ee7467d5cf", + ".opencode/node_modules/zod/v4/locales/ja.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/ja.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/ja.js": "c7ecc379e0230066cc25a63597506521e68a76987b6c99f0b861d5240ce1e7d4", + ".opencode/node_modules/zod/v4/locales/ka.cjs": "12120b7997f17d4fad888d0907ebc34957a919df1053d18f7780512f2cc025fa", + ".opencode/node_modules/zod/v4/locales/ka.d.cts": "26384fb401f582cae1234213c3dc75fdc80e3d728a0a1c55b405be8a0c6dddbe", + ".opencode/node_modules/zod/v4/locales/ka.d.ts": "3b25e966fd93475d8ca2834194ea78321d741a21ca9d1f606b25ec99c1bbc29a", + ".opencode/node_modules/zod/v4/locales/ka.js": "90a891f22c612ad6eecf6975d58e7ce322b193e2fe3895cf8b7c800d9a2c8638", + ".opencode/node_modules/zod/v4/locales/kh.cjs": "5a5f3a63e4e97a25b44692b0910f73cf15e54fc5b03c61f9d87e2a9eb6e2a098", + ".opencode/node_modules/zod/v4/locales/kh.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/kh.d.ts": "e2a9a2ee24da53cf39abdc8ccb2c8b09e8b6b7c8f692448b90698d5208ed5e2d", + ".opencode/node_modules/zod/v4/locales/kh.js": "78c3d1991041bb8c8fc0f7c5d745aae422d4c2e4e8f30bae44f3fef4765900a8", + ".opencode/node_modules/zod/v4/locales/km.cjs": "d363ebe070cb240e3d3acc335c72bf21598329cd274f4f1e8738f45597c257d6", + ".opencode/node_modules/zod/v4/locales/km.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/km.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/km.js": "52059ef27113a2c5609014d8de482a6b7e644f1721691644f818ebc2aa50e6be", + ".opencode/node_modules/zod/v4/locales/ko.cjs": "6cbf291a630b59641d29d86cf01d7efa581675e838e6edb697b268c5ff024f2d", + ".opencode/node_modules/zod/v4/locales/ko.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/ko.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/ko.js": "f87cdcf6b3f89762d1331a670cbff687aa52d34beb7d0e0bcba9f9202f667308", + ".opencode/node_modules/zod/v4/locales/lt.cjs": "b900d411a793f16885c9a85d02eaf82fabd09f52621b4936b36ec3930ee61b44", + ".opencode/node_modules/zod/v4/locales/lt.d.cts": "26384fb401f582cae1234213c3dc75fdc80e3d728a0a1c55b405be8a0c6dddbe", + ".opencode/node_modules/zod/v4/locales/lt.d.ts": "3b25e966fd93475d8ca2834194ea78321d741a21ca9d1f606b25ec99c1bbc29a", + ".opencode/node_modules/zod/v4/locales/lt.js": "f3a346e1f39e0dead4d57ae412f6e40247efc5344de30a3326fbf0cbbd2b3647", + ".opencode/node_modules/zod/v4/locales/mk.cjs": "d0bff5ed169eeb21ce723024b291815a9ddbb6a7a415ab397f7d622a70419bb3", + ".opencode/node_modules/zod/v4/locales/mk.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/mk.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/mk.js": "dc64be99e35535aa7ffd648182dd7cfe3a51be08ad373d3bd12907b08acde24b", + ".opencode/node_modules/zod/v4/locales/ms.cjs": "fdf00d04224748328af8859affc695a1c0c4a110b763a75c6e028c0d924b5819", + ".opencode/node_modules/zod/v4/locales/ms.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/ms.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/ms.js": "5bd7da46aa238951a3f65dfd99d658582005dbb6b6b5586866fc4123fe9da582", + ".opencode/node_modules/zod/v4/locales/nl.cjs": "c53dfc2b6d22aadf40ab20100b6dffbbb6089446ab1c6b0560d0ed1d8f50b236", + ".opencode/node_modules/zod/v4/locales/nl.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/nl.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/nl.js": "b3bc897777d2ab5435c08dc65a75ec3e29f168b7e3fd70cbddd52a4960471712", + ".opencode/node_modules/zod/v4/locales/no.cjs": "4aeaa653cc2c45c2e9a860111bc6d58a9ebe8476b14e90dbbf7f1867128e0e48", + ".opencode/node_modules/zod/v4/locales/no.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/no.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/no.js": "c38a1e0b0cb4398fdabbffdbd1dd59a176535372f6af0226cfd6a600914f918a", + ".opencode/node_modules/zod/v4/locales/ota.cjs": "bfcf6b4ee261e84ac2a3083b6d5b9afb90b63a2d1218075f1edcabf8bad63332", + ".opencode/node_modules/zod/v4/locales/ota.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/ota.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/ota.js": "1a52c600e988011d30a150fac78176323a3ddf9d93db44cc13c665bca2ddc297", + ".opencode/node_modules/zod/v4/locales/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", + ".opencode/node_modules/zod/v4/locales/pl.cjs": "bbe265c24c400f5b889a4e3cb2e0b67d9bb722c825e646ebbc2e3c7a9a892981", + ".opencode/node_modules/zod/v4/locales/pl.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/pl.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/pl.js": "482246e5da9d86498189e027bcc7e034b9c729dc07a875f78de3ec5ee47f0fc9", + ".opencode/node_modules/zod/v4/locales/ps.cjs": "86cc84fc9ce3b849207baada9ffe6b6426f21c02febd2e754c8f57e37e3103d6", + ".opencode/node_modules/zod/v4/locales/ps.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/ps.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/ps.js": "29f11ca76393fc30b3f7aa0af116bd29a3caf07b09e3113bb16bd0b443339552", + ".opencode/node_modules/zod/v4/locales/pt.cjs": "9e28743eea308d73f4671b3f780cb64bbb9a0f89682a82c55110dee5b43e56b4", + ".opencode/node_modules/zod/v4/locales/pt.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/pt.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/pt.js": "d9a033407c895b3cb239d7ae7c7946b7487a5c7c5988e4523fe338566190ac9d", + ".opencode/node_modules/zod/v4/locales/ru.cjs": "6f4e0e348f2c8eca9f8311bc829f8d3ff3c7a18f6b47139fb80b9b61ccfae31c", + ".opencode/node_modules/zod/v4/locales/ru.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/ru.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/ru.js": "12ca3536916e5b947e920e29cd48fba0b1daf0712eff575b9ff43b1f15a4ed47", + ".opencode/node_modules/zod/v4/locales/sl.cjs": "489fce4f844fe664c259836e31f9409dd601c908d373b99631fce26394107aa3", + ".opencode/node_modules/zod/v4/locales/sl.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/sl.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/sl.js": "a6513b3ae705f46deaf3adaf6e7d38642ccb2b8c9844e8d8086efb4d33876007", + ".opencode/node_modules/zod/v4/locales/sv.cjs": "5d918fda16df1c5e16a3b17c74fafb9b35e5f76f44adb93d51fe062f6319fb2a", + ".opencode/node_modules/zod/v4/locales/sv.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/sv.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/sv.js": "132cb1f90932fd7f687a1f7b6f050a589c9e25dab5f78078d1a60bc981d91a1d", + ".opencode/node_modules/zod/v4/locales/ta.cjs": "3c8c82fb7c9c4019a3e642f61e22cac12c77fed7167aa5878d3889e39725da58", + ".opencode/node_modules/zod/v4/locales/ta.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/ta.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/ta.js": "8ceaba6e203b9fc6ff2ef8062f5fe0677c38a06aee0e2eaee4f5c6b6d4c92bad", + ".opencode/node_modules/zod/v4/locales/th.cjs": "d12932c74180558fbc0a049fedf3c7f59a52bf539826dd1740da7dc568cdd2c9", + ".opencode/node_modules/zod/v4/locales/th.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/th.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/th.js": "0c219854cfd59b9c7fac532130c2d8a3c344a493dd3c361b60d2fadea4afa45b", + ".opencode/node_modules/zod/v4/locales/tr.cjs": "28ca52e08a6f4a9433d6302fd2e075b4173ba71fb3ad89c9c6772889d559dad8", + ".opencode/node_modules/zod/v4/locales/tr.d.cts": "26384fb401f582cae1234213c3dc75fdc80e3d728a0a1c55b405be8a0c6dddbe", + ".opencode/node_modules/zod/v4/locales/tr.d.ts": "3b25e966fd93475d8ca2834194ea78321d741a21ca9d1f606b25ec99c1bbc29a", + ".opencode/node_modules/zod/v4/locales/tr.js": "5899564068c44d6321135e441c8841a0c87fcee7f1c2ffb6e075c81b861a5963", + ".opencode/node_modules/zod/v4/locales/ua.cjs": "0565126e33d30fd26046fd5399fe6293e8827fa846c3a7ff414e4e63acbfbf0a", + ".opencode/node_modules/zod/v4/locales/ua.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/ua.d.ts": "6484b25a0de66353ff65d498318289e1ee89515bd6105c73d64dddfff8835699", + ".opencode/node_modules/zod/v4/locales/ua.js": "9391169ffbc72f777a83230b6ffcc3f4cf46ab5945ec540de9ef4609dca1f4cc", + ".opencode/node_modules/zod/v4/locales/uk.cjs": "9d72ea5a461879fe16821cafffdb365d0b4a22ec7c08200d2b4e289bd2443426", + ".opencode/node_modules/zod/v4/locales/uk.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/uk.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/uk.js": "67a00c4c9a06d6a9c84c5d598230d28a9be97eeb023c455c4300bcbb67f85baf", + ".opencode/node_modules/zod/v4/locales/ur.cjs": "c5e01648e76fec946d1b6694ce400208171269a7de767d95a99d76299f51c30e", + ".opencode/node_modules/zod/v4/locales/ur.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/ur.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/ur.js": "673c79cb79ea713c7db86a20d69e8e7276bbc5aa9877c1b9f1230e309aa30ac8", + ".opencode/node_modules/zod/v4/locales/vi.cjs": "8bf4ad084e0cdb1b63dae321139a2704b52392f219c832cee61edf104a0a90e5", + ".opencode/node_modules/zod/v4/locales/vi.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/vi.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/vi.js": "669dc347036da69f481fd43aaf60c24badb4ff52906aa59ea86735028274ac5c", + ".opencode/node_modules/zod/v4/locales/yo.cjs": "5d6308e49a6797df8e4874d35e5368beb565a2d98dd1912837db200fbeb6d29d", + ".opencode/node_modules/zod/v4/locales/yo.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/yo.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/yo.js": "48269d5416f0a497c777ae28f1724263e2051ff69134fcc307c57faabbc3b470", + ".opencode/node_modules/zod/v4/locales/zh-CN.cjs": "e1d86c2f7f1cd5dfec5182b00b6a6db4d788f3898a32fe58e330b256840ed715", + ".opencode/node_modules/zod/v4/locales/zh-CN.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/zh-CN.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/zh-CN.js": "88dd19ebe066ad328ce315b9da17d8c6dbe76a44bda9b48128809d864392637a", + ".opencode/node_modules/zod/v4/locales/zh-TW.cjs": "7149854c4ea48287a43219f14f1cf0638ff6fc1257359d6726e7c9c062d1e449", + ".opencode/node_modules/zod/v4/locales/zh-TW.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", + ".opencode/node_modules/zod/v4/locales/zh-TW.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", + ".opencode/node_modules/zod/v4/locales/zh-TW.js": "40d3639fd0b600c2eea303d359f36dcb34ccaceccbb27c00fabfd1d6d5761415", + ".opencode/node_modules/zod/v4/mini/checks.cjs": "751987cbe8e6346d30c25dc3ca04643e3ab7301a83cf31d0ee0ecf4dfb8b9149", + ".opencode/node_modules/zod/v4/mini/checks.d.cts": "ffcc1e3d8a6c4a3dcc6291d1f559f6853d2cbdb3df8c7e57eb99059c90d89925", + ".opencode/node_modules/zod/v4/mini/checks.d.ts": "3fbf0aa59f99b6e4d640cd2b062cff9537530af3f5bfed4f862a7f18a9caa839", + ".opencode/node_modules/zod/v4/mini/checks.js": "3fbf0aa59f99b6e4d640cd2b062cff9537530af3f5bfed4f862a7f18a9caa839", + ".opencode/node_modules/zod/v4/mini/coerce.cjs": "6dca70ae45d9a876d730898fada9316a066944d6dfd90d92c43fb10c6bb6b2b2", + ".opencode/node_modules/zod/v4/mini/coerce.d.cts": "d50434c282f9e0cfe9d728f6e7a12988bfc6de7be655f40ad44d6bee6f9fe9a3", + ".opencode/node_modules/zod/v4/mini/coerce.d.ts": "ddbf61f8fb60c82085677c1ea3393c534f3885f61ff7ebb4559575a03f82a4cf", + ".opencode/node_modules/zod/v4/mini/coerce.js": "ef3cb07ee9217997976afe9e2639d63eb4b926ef58a5fb9c24ac80855e529a93", + ".opencode/node_modules/zod/v4/mini/external.cjs": "05c2bb69db0d26b115d9e146b85831c52f2341d23a40f8872f873af089e40661", + ".opencode/node_modules/zod/v4/mini/external.d.cts": "48a578dc7819fe791ca85d2aa6eea01122c0f7c460a6def56ca37fbb6cab3378", + ".opencode/node_modules/zod/v4/mini/external.d.ts": "305cb5e50a3e4e49a2004e59c17fce5caa25c8d22ec340b0e762b23164c5a641", + ".opencode/node_modules/zod/v4/mini/external.js": "d56f0c9c15524cd7dc217b912f68c589863fa515401c315982f06cac917c8727", + ".opencode/node_modules/zod/v4/mini/index.cjs": "2a1c77f9c68bd47cd079ea601248dad70025528bc1062c2c657444000edabcac", + ".opencode/node_modules/zod/v4/mini/index.d.cts": "0f29e474ba3555dd52ad61783dc6277683dd19c36473589d6fe2d29e06d99f5a", + ".opencode/node_modules/zod/v4/mini/index.d.ts": "455d93e660b61feec18cd4c31e2f394627546ae8ab31f9bacdab6318112ac31c", + ".opencode/node_modules/zod/v4/mini/index.js": "455d93e660b61feec18cd4c31e2f394627546ae8ab31f9bacdab6318112ac31c", + ".opencode/node_modules/zod/v4/mini/iso.cjs": "b461dc1b91755b88cd22a50b54d1ab8b515357343bb20c7b7e68e1c2ea2b5ba8", + ".opencode/node_modules/zod/v4/mini/iso.d.cts": "17a9a3fe407d0e6bfc00f7e480e6090efd455cd1ce4204572543d0492d17a721", + ".opencode/node_modules/zod/v4/mini/iso.d.ts": "3f2f3b037cd2ba39395aa6628fb616f4642ae8cbab3b0f9ef786213745538658", + ".opencode/node_modules/zod/v4/mini/iso.js": "1829996a8bbebfce7e4e6ca384e3ef6aeb2d88cda5ae7f7eeec24904ba688cef", + ".opencode/node_modules/zod/v4/mini/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", + ".opencode/node_modules/zod/v4/mini/parse.cjs": "3a1db8a15f2d19426e3a774543a1988e11d112c1e345a51b038d33eeaff573ac", + ".opencode/node_modules/zod/v4/mini/parse.d.cts": "18fe15c9645cea0541cb0664b82fdd03069cb33544989ad87e0091dc70cae250", + ".opencode/node_modules/zod/v4/mini/parse.d.ts": "db605a698f917c4006462596e6526fe6a9c6eff9b8409e8472fe545d4b70027c", + ".opencode/node_modules/zod/v4/mini/parse.js": "db605a698f917c4006462596e6526fe6a9c6eff9b8409e8472fe545d4b70027c", + ".opencode/node_modules/zod/v4/mini/schemas.cjs": "6ff53249f8d550e1559d99e37c6107b83a69b33302a57c299f078769db7d7952", + ".opencode/node_modules/zod/v4/mini/schemas.d.cts": "3a80a0f1075f9eac86dc6555ca4409535b74cfe1eb803c566c252014cff13912", + ".opencode/node_modules/zod/v4/mini/schemas.d.ts": "ec8e232365211878d47e3db95701346e8d3f1aea5c7049c6e84b3ca1be0d934b", + ".opencode/node_modules/zod/v4/mini/schemas.js": "d79e807c5eb58cfcb80597b7a38270862b1af876721baf8556210c9a27cb82cc", + ".opencode/node_modules/zod/v4/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", + ".opencode/node_modules/zod/v4-mini/index.cjs": "c3ef916ed5e1bb397f04352f010f97961e98f610c13ecc05c25a384ef0e5a5a2", + ".opencode/node_modules/zod/v4-mini/index.d.cts": "d34c4532b0004150342d04ab1a6f61d19751c4fc7c465c72ec582b180b0904c0", + ".opencode/node_modules/zod/v4-mini/index.d.ts": "cf94ca2939d655e17fdfd1d0a3aceb4cbeb040d8946e9c9c1eaa5cc734dc69c8", + ".opencode/node_modules/zod/v4-mini/index.js": "cf94ca2939d655e17fdfd1d0a3aceb4cbeb040d8946e9c9c1eaa5cc734dc69c8", + ".opencode/node_modules/zod/v4-mini/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", + ".opencode/opencode.json": "dcc34cd8e53f5df784c7c50970c830f598b7637c1b824d9089cd70c59389a797", + ".opencode/opencode.json.old": "245a87dd300b32ba610c3415fbbbcf4f6d902aac340e24107dd6e5ebbc2dded8", + ".opencode/package.json": "6867538b2f6025286a70c943d15db4191d2412daa8bf7431a846b00c863f26b8", + ".opencode/plugin/inject-subagent-context.js": "6a1257d7a2e485d3de3b0e34c40ab285dfe48e976d442288423eab23d2a0e717", + ".opencode/plugin/session-start.js": "ce013e3b329b954cd1317fc69d804ad69f6524ff73531b29bac8d67e6a4d8d81", + ".agents/skills/ui-ux-pro-max/SKILL.md": "9bd26c52dfd10dfd613dd3cc85df217dc25a172e380bde41fdfa6fedda288b2a", + ".agents/skills/ui-ux-pro-max/data/_sync_all.py": "b540b3a4f87598ae29048b3cceae1fc17b39b629aee2cbe5c802ffc7b48abf6d", + ".agents/skills/ui-ux-pro-max/data/app-interface.csv": "2a17ef810dab715ce1f339861817a8fbe3ccc38142b70517301e874803e838ac", + ".agents/skills/ui-ux-pro-max/data/charts.csv": "ebb565308115f955791b0431797a89d9b3587c25d5babb9428d10712c4924817", + ".agents/skills/ui-ux-pro-max/data/colors.csv": "69ee8c1147b269599d20ca418bdd6f32563a9aa2d962fb9a9b8dec66bf7b1ba4", + ".agents/skills/ui-ux-pro-max/data/design.csv": "6f0ae42f16b3cbfa3f07050268387557859ff666e2651b1b82763f099d724b3f", + ".agents/skills/ui-ux-pro-max/data/draft.csv": "e190c796b707858a5436dc4c27f9ee9bb6618014ca73f533e423abe5ce9e4d06", + ".agents/skills/ui-ux-pro-max/data/google-fonts.csv": "2c03a3cd134d126bd9d6a7dc2a6360dc5272219ad6df3eb9315b031f806e1487", + ".agents/skills/ui-ux-pro-max/data/icons.csv": "f376c29fb4df37b4bdb366a5aa70cb211ba3dd8b435390aaa03152a64b07d2e8", + ".agents/skills/ui-ux-pro-max/data/landing.csv": "080cedbcd61ff8ec9520f33929baa76bee9589e783f83b2f8d824a466b6a46d7", + ".agents/skills/ui-ux-pro-max/data/products.csv": "9fd9e776ba847cf44c1ea78f95fe5e33b2c56bb7e186e3cfff9c49bc7fcb691b", + ".agents/skills/ui-ux-pro-max/data/react-performance.csv": "904c8afcda229629545912dde0e8ac37503757131f0169f80b016f1f58c4fd3f", + ".agents/skills/ui-ux-pro-max/data/stacks/react-native.csv": "a08ca77fcf6b6d9531982dce465366296013bfcf12d2938ac72ad57cf0c4f085", + ".agents/skills/ui-ux-pro-max/data/styles.csv": "9b5089dcde8999333b36878252a255cc3bacbb2fe7b836c76cc7f7aa2abb643d", + ".agents/skills/ui-ux-pro-max/data/typography.csv": "dbea262a54e3bfa2e6c3b15989a365d5ef4c43349316aff46635e82ca825adce", + ".agents/skills/ui-ux-pro-max/data/ui-reasoning.csv": "41976082ecae1100da937c949215dc6694393e03f3c2a7444dd92a9edb43cb11", + ".agents/skills/ui-ux-pro-max/data/ux-guidelines.csv": "1870ee048f2a2bdd60709f8f7adf7f3b6dcad560bc005c8b2915a8ac8639820d", + ".agents/skills/ui-ux-pro-max/scripts/__pycache__/core.cpython-313.pyc": "6fbe6d7129ee4b3055472e4f8d6417229fd160ed764e94935b9b9c1b0fe1a41f", + ".agents/skills/ui-ux-pro-max/scripts/__pycache__/design_system.cpython-313.pyc": "225b14b080c8fec56ee3ba08904d7fccae04607e2806a688b368d31f7c66a869", + ".agents/skills/ui-ux-pro-max/scripts/core.py": "18e00b1a2952fb919dcba0010ee71f75129a670ad565e8d0907958d6be8caeca", + ".agents/skills/ui-ux-pro-max/scripts/design_system.py": "4da1d341f3c7749df51b51db4a543a48a427c3c746eb0e9882a1ab86acf3bb54", + ".agents/skills/ui-ux-pro-max/scripts/search.py": "18b1efa4ee5a2fc1cf14d7b25429ab423ef6026d123878fb93c5884f33cd10db" +} \ No newline at end of file diff --git a/.trellis/.version b/.trellis/.version new file mode 100644 index 0000000..81de5c5 --- /dev/null +++ b/.trellis/.version @@ -0,0 +1 @@ +0.3.10 \ No newline at end of file diff --git a/.trellis/config.yaml b/.trellis/config.yaml new file mode 100644 index 0000000..7d18551 --- /dev/null +++ b/.trellis/config.yaml @@ -0,0 +1,33 @@ +# 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 + +#------------------------------------------------------------------------------- +# 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'" diff --git a/.trellis/scripts/__init__.py b/.trellis/scripts/__init__.py new file mode 100755 index 0000000..815a137 --- /dev/null +++ b/.trellis/scripts/__init__.py @@ -0,0 +1,5 @@ +""" +Trellis Python Scripts + +This module provides Python implementations of Trellis workflow scripts. +""" diff --git a/.trellis/scripts/add_session.py b/.trellis/scripts/add_session.py new file mode 100755 index 0000000..71606e5 --- /dev/null +++ b/.trellis/scripts/add_session.py @@ -0,0 +1,423 @@ +#!/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" + echo "content" | python3 add_session.py --title "Title" --commit "hash" +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +from common.paths import ( + FILE_JOURNAL_PREFIX, + get_repo_root, + get_developer, + get_workspace_dir, +) +from common.developer import ensure_developer +from common.config import get_session_commit_message, get_max_journal_lines + + +# ============================================================================= +# 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 +) -> 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)" + + return f""" + +## Session {session_num}: {title} + +**Date**: {today} +**Task**: {title} + +### 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 +) -> 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: + new_lines.append(line) + if re.match(r"^\|\s*-", line) and not header_written: + new_lines.append(f"| {new_session} | {today} | {title} | {commit_display} |") + header_written = True + 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/workspace and .trellis/tasks, then commit with a configured message.""" + commit_msg = get_session_commit_message(repo_root) + subprocess.run( + ["git", "add", "-A", ".trellis/workspace", ".trellis/tasks"], + cwd=repo_root, + capture_output=True, + ) + # Check if there are staged changes + result = subprocess.run( + ["git", "diff", "--cached", "--quiet", "--", ".trellis/workspace", ".trellis/tasks"], + cwd=repo_root, + ) + if result.returncode == 0: + print("[OK] No workspace changes to commit.", file=sys.stderr) + return + commit_result = subprocess.run( + ["git", "commit", "-m", commit_msg], + cwd=repo_root, + capture_output=True, + text=True, + ) + if commit_result.returncode == 0: + print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr) + else: + print(f"[WARN] Auto-commit failed: {commit_result.stderr.strip()}", file=sys.stderr) + + +def add_session( + title: str, + commit: str = "-", + summary: str = "(Add summary)", + extra_content: str = "(Add details)", + auto_commit: bool = True, +) -> 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 + ) + 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): + 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("--no-commit", action="store_true", + help="Skip auto-commit of workspace changes") + + 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 not sys.stdin.isatty(): + extra_content = sys.stdin.read() + + return add_session( + args.title, args.commit, args.summary, extra_content, + auto_commit=not args.no_commit, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.trellis/scripts/common/__init__.py b/.trellis/scripts/common/__init__.py new file mode 100755 index 0000000..1772978 --- /dev/null +++ b/.trellis/scripts/common/__init__.py @@ -0,0 +1,82 @@ +""" +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, + set_current_task, + clear_current_task, + has_current_task, + generate_task_date_prefix, +) diff --git a/.trellis/scripts/common/cli_adapter.py b/.trellis/scripts/common/cli_adapter.py new file mode 100755 index 0000000..ce3323b --- /dev/null +++ b/.trellis/scripts/common/cli_adapter.py @@ -0,0 +1,628 @@ +""" +CLI Adapter for Multi-Platform Support. + +Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, and Qoder 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) +- qoder: Qoder + +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", + "qoder", +] + + +@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', '.agents', '.kilocode', '.kiro', '.gemini', '.agent', or '.qoder') + """ + if self.platform == "opencode": + return ".opencode" + elif self.platform == "cursor": + return ".cursor" + elif self.platform == "iflow": + return ".iflow" + elif self.platform == "codex": + return ".agents" + 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 == "qoder": + return ".qoder" + 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, .agents, .kilocode, .kiro, .gemini, .agent, or .qoder) + """ + 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 .md file + """ + mapped_name = self.get_agent_name(agent) + 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 + Claude/OpenCode use subdirectory: .claude/commands/trellis/<name>.md + """ + 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 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-backend') + + Returns: + Relative path string for use in JSONL entries + + Note: + Cursor: .cursor/commands/trellis-<name>.md + Codex: .agents/skills/<name>/SKILL.md + Kiro: .kiro/skills/<name>/SKILL.md + Gemini: .gemini/commands/trellis/<name>.toml + Antigravity: .agent/workflows/<name>.md + Others: .{platform}/commands/trellis/<name>.md + """ + if self.platform == "cursor": + return f".cursor/commands/trellis-{name}.md" + elif self.platform == "codex": + return f".agents/skills/{name}/SKILL.md" + elif self.platform == "kiro": + return f".kiro/skills/{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 == "kilo": + return f".kilocode/workflows/{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 == "qoder": + 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", "-p"] + cmd.extend(["-y", "--agent", mapped_agent]) + # iFlow doesn't support --session-id on creation + if verbose: + cmd.append("--verbose") + cmd.append(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 == "qoder": + cmd = ["qodercli", "-p", prompt] + + else: # claude + cmd = ["claude", "-p"] + cmd.extend(["--agent", mapped_agent]) + + if session_id: + cmd.extend(["--session-id", session_id]) + + if skip_permissions: + cmd.append("--dangerously-skip-permissions") + + if json_output: + cmd.extend(["--output-format", "stream-json"]) + + if verbose: + cmd.append("--verbose") + + cmd.append(prompt) + + return cmd + + def build_resume_command(self, session_id: str) -> list[str]: + """Build CLI command for resuming a session. + + Args: + session_id: Session ID to resume (ignored for iFlow) + + Returns: + List of command arguments + """ + if self.platform == "opencode": + return ["opencode", "run", "--session", session_id] + elif self.platform == "iflow": + # iFlow uses -c to continue most recent conversation + # session_id is ignored as iFlow doesn't support session IDs + return ["iflow", "-c"] + elif self.platform == "codex": + return ["codex", "resume", session_id] + elif self.platform == "kiro": + return ["kiro", "resume", session_id] + elif self.platform == "gemini": + return ["gemini", "--resume", session_id] + elif self.platform == "antigravity": + raise ValueError( + "Antigravity workflows are UI slash commands; CLI resume is not supported." + ) + elif self.platform == "qoder": + return ["qodercli", "--resume", session_id] + else: + return ["claude", "--resume", session_id] + + def get_resume_command_str(self, session_id: str, cwd: str | None = None) -> str: + """Get human-readable resume command string. + + Args: + session_id: Session ID to resume + cwd: Optional working directory to cd into + + Returns: + Command string for display + """ + cmd = self.build_resume_command(session_id) + cmd_str = " ".join(cmd) + + if cwd: + return f"cd {cwd} && {cmd_str}" + return cmd_str + + # ========================================================================= + # Platform Detection Helpers + # ========================================================================= + + @property + def is_opencode(self) -> bool: + """Check if platform is OpenCode.""" + return self.platform == "opencode" + + @property + def is_claude(self) -> bool: + """Check if platform is Claude Code.""" + return self.platform == "claude" + + @property + def is_cursor(self) -> bool: + """Check if platform is Cursor.""" + return self.platform == "cursor" + + @property + def is_iflow(self) -> bool: + """Check if platform is iFlow CLI.""" + return self.platform == "iflow" + + @property + def cli_name(self) -> str: + """Get CLI executable name. + + Note: Cursor doesn't have a CLI tool, returns None-like value. + """ + if self.is_opencode: + return "opencode" + elif self.is_cursor: + return "cursor" # Note: Cursor is IDE-only, no CLI + elif self.platform == "iflow": + return "iflow" + elif self.platform == "kiro": + return "kiro" + elif self.platform == "gemini": + return "gemini" + elif self.platform == "antigravity": + return "agy" + elif self.platform == "qoder": + return "qodercli" + else: + return "claude" + + @property + def supports_cli_agents(self) -> bool: + """Check if platform supports running agents via CLI. + + Claude Code, OpenCode, and iFlow support CLI agent execution. + Cursor is IDE-only and doesn't support CLI agents. + """ + 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', or 'qoder') + + Returns: + CLIAdapter instance + + Raises: + ValueError: If platform is not supported + """ + if platform not in ( + "claude", + "opencode", + "cursor", + "iflow", + "codex", + "kilo", + "kiro", + "gemini", + "antigravity", + "qoder", + ): + raise ValueError( + f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', or 'qoder')" + ) + + return CLIAdapter(platform=platform) # type: ignore + + +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. .agents/skills exists and no other platform dirs → codex + 6. .kilocode directory exists → kilo + 7. .kiro/skills exists and no other platform dirs → kiro + 8. .gemini directory exists → gemini + 9. .agent/workflows exists and no other platform dirs → antigravity + 10. .qoder directory exists → qoder + 11. Default → claude + + Args: + project_root: Project root directory + + Returns: + Detected platform ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', or 'qoder') + """ + import os + + # Check environment variable first + env_platform = os.environ.get("TRELLIS_PLATFORM", "").lower() + if env_platform in ( + "claude", + "opencode", + "cursor", + "iflow", + "codex", + "kilo", + "kiro", + "gemini", + "antigravity", + "qoder", + ): + return env_platform # type: ignore + + # Check for .opencode directory (OpenCode-specific) + # Note: .claude might exist in both platforms during migration + if (project_root / ".opencode").is_dir(): + return "opencode" + + # Check for .iflow directory (iFlow-specific) + # Note: .claude might exist in both platforms during migration + 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 skills directory only when no other platform config exists + other_platform_dirs_codex = ( + ".claude", + ".cursor", + ".iflow", + ".opencode", + ".kilocode", + ".kiro", + ".gemini", + ".agent", + ) + has_other_platform_config = any( + (project_root / directory).is_dir() for directory in other_platform_dirs_codex + ) + if (project_root / ".agents" / "skills").is_dir() and not has_other_platform_config: + 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 + other_platform_dirs_kiro = ( + ".claude", + ".cursor", + ".iflow", + ".opencode", + ".agents", + ".kilocode", + ".gemini", + ".agent", + ) + has_other_platform_config = any( + (project_root / directory).is_dir() for directory in other_platform_dirs_kiro + ) + if (project_root / ".kiro" / "skills").is_dir() and not has_other_platform_config: + return "kiro" + + # Check for Antigravity workflow directory only when no other platform config exists + other_platform_dirs_antigravity = ( + ".claude", + ".cursor", + ".iflow", + ".opencode", + ".agents", + ".kilocode", + ".kiro", + ) + has_other_platform_config = any( + (project_root / directory).is_dir() + for directory in other_platform_dirs_antigravity + ) + if ( + project_root / ".agent" / "workflows" + ).is_dir() and not has_other_platform_config: + return "antigravity" + + # Check for .qoder directory (Qoder-specific) + if (project_root / ".qoder").is_dir(): + return "qoder" + + 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) diff --git a/.trellis/scripts/common/config.py b/.trellis/scripts/common/config.py new file mode 100755 index 0000000..601ab32 --- /dev/null +++ b/.trellis/scripts/common/config.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +""" +Trellis configuration reader. + +Reads settings from .trellis/config.yaml with sensible defaults. +""" + +from __future__ import annotations + +from pathlib import Path + +from .paths import DIR_WORKFLOW, get_repo_root +from .worktree import parse_simple_yaml + + +# Defaults +DEFAULT_SESSION_COMMIT_MESSAGE = "chore: record journal" +DEFAULT_MAX_JOURNAL_LINES = 2000 + +CONFIG_FILE = "config.yaml" + + +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_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 [] diff --git a/.trellis/scripts/common/developer.py b/.trellis/scripts/common/developer.py new file mode 100755 index 0000000..7f3cf0c --- /dev/null +++ b/.trellis/scripts/common/developer.py @@ -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 | +|---|------|-------|---------| +<!-- @@@/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() diff --git a/.trellis/scripts/common/git_context.py b/.trellis/scripts/common/git_context.py new file mode 100755 index 0000000..39b9ff5 --- /dev/null +++ b/.trellis/scripts/common/git_context.py @@ -0,0 +1,641 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Git and Session Context utilities. + +Provides: + output_json - Output context in JSON format + output_text - Output context in text format +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +from .paths import ( + DIR_SCRIPTS, + DIR_SPEC, + DIR_TASKS, + DIR_WORKFLOW, + DIR_WORKSPACE, + FILE_TASK_JSON, + count_lines, + get_active_journal_file, + get_current_task, + get_developer, + get_repo_root, + get_tasks_dir, +) + +# ============================================================================= +# Helper Functions +# ============================================================================= + + +def _run_git_command(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: + # Force git to output UTF-8 for consistent cross-platform behavior + 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) + + +def _read_json_file(path: Path) -> dict | None: + """Read and parse a JSON file.""" + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + + +# ============================================================================= +# 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}" + ) + + # Git info + _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root) + branch = branch_out.strip() or "unknown" + + _, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root) + git_status_count = len([line for line in status_out.splitlines() if line.strip()]) + is_clean = git_status_count == 0 + + # Recent commits + _, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root) + commits = [] + for line in log_out.splitlines(): + if line.strip(): + 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": ""}) + + # Tasks + tasks = [] + if tasks_dir.is_dir(): + for d in tasks_dir.iterdir(): + if d.is_dir() and d.name != "archive": + task_json_path = d / FILE_TASK_JSON + if task_json_path.is_file(): + data = _read_json_file(task_json_path) + if data: + tasks.append( + { + "dir": d.name, + "name": data.get("name") or data.get("id") or "unknown", + "status": data.get("status", "unknown"), + "children": data.get("children", []), + "parent": data.get("parent"), + } + ) + + return { + "developer": developer or "", + "git": { + "branch": branch, + "isClean": is_clean, + "uncommittedChanges": git_status_count, + "recentCommits": commits, + }, + "tasks": { + "active": tasks, + "directory": f"{DIR_WORKFLOW}/{DIR_TASKS}", + }, + "journal": { + "file": journal_relative, + "lines": journal_lines, + "nearLimit": journal_lines > 1800, + }, + } + + +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("") + + # Git status + lines.append("## GIT STATUS") + _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root) + branch = branch_out.strip() or "unknown" + lines.append(f"Branch: {branch}") + + _, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root) + status_lines = [line for line in status_out.splitlines() if line.strip()] + status_count = len(status_lines) + + if status_count == 0: + lines.append("Working directory: Clean") + else: + lines.append(f"Working directory: {status_count} uncommitted change(s)") + lines.append("") + lines.append("Changes:") + _, short_out, _ = _run_git_command(["status", "--short"], cwd=repo_root) + for line in short_out.splitlines()[:10]: + lines.append(line) + lines.append("") + + # Recent commits + lines.append("## RECENT COMMITS") + _, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root) + if log_out.strip(): + for line in log_out.splitlines(): + lines.append(line) + else: + lines.append("(no commits)") + lines.append("") + + # Current task + lines.append("## CURRENT TASK") + current_task = get_current_task(repo_root) + if current_task: + current_task_dir = repo_root / current_task + task_json_path = current_task_dir / FILE_TASK_JSON + lines.append(f"Path: {current_task}") + + if task_json_path.is_file(): + data = _read_json_file(task_json_path) + if data: + t_name = data.get("name") or data.get("id") or "unknown" + t_status = data.get("status", "unknown") + t_created = data.get("createdAt", "unknown") + t_desc = data.get("description", "") + + lines.append(f"Name: {t_name}") + lines.append(f"Status: {t_status}") + lines.append(f"Created: {t_created}") + if t_desc: + lines.append(f"Description: {t_desc}") + + # 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_task_data: dict[str, dict] = {} + if tasks_dir.is_dir(): + for d in sorted(tasks_dir.iterdir()): + if d.is_dir() and d.name != "archive": + dir_name = d.name + t_json = d / FILE_TASK_JSON + status = "unknown" + assignee = "-" + children: list[str] = [] + parent: str | None = None + + if t_json.is_file(): + data = _read_json_file(t_json) + if data: + status = data.get("status", "unknown") + assignee = data.get("assignee", "-") + children = data.get("children", []) + parent = data.get("parent") + + all_task_data[dir_name] = { + "status": status, + "assignee": assignee, + "children": children, + "parent": parent, + } + + def _children_progress(children_list: list[str]) -> str: + if not children_list: + return "" + done = 0 + for c in children_list: + if c in all_task_data and all_task_data[c]["status"] in ("completed", "done"): + done += 1 + return f" [{done}/{len(children_list)} done]" + + def _print_task_tree(name: str, indent: int = 0) -> None: + nonlocal task_count + info = all_task_data[name] + progress = _children_progress(info["children"]) if info["children"] else "" + prefix = " " * indent + lines.append(f"{prefix}- {name}/ ({info['status']}){progress} @{info['assignee']}") + task_count += 1 + for child in info["children"]: + if child in all_task_data: + _print_task_tree(child, indent + 1) + + for dir_name in sorted(all_task_data.keys()): + if not all_task_data[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 + + if tasks_dir.is_dir(): + for d in sorted(tasks_dir.iterdir()): + if d.is_dir() and d.name != "archive": + t_json = d / FILE_TASK_JSON + if t_json.is_file(): + data = _read_json_file(t_json) + if data: + assignee = data.get("assignee", "") + status = data.get("status", "planning") + + if assignee == developer and status != "done": + title = data.get("title") or data.get("name") or "unknown" + priority = data.get("priority", "P2") + children_list = data.get("children", []) + progress = _children_progress(children_list) if children_list else "" + lines.append(f"- [{priority}] {title} ({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("") + + # 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) + + +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) + + # Git info + _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root) + branch = branch_out.strip() or "unknown" + + _, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root) + git_status_count = len([line for line in status_out.splitlines() if line.strip()]) + + _, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root) + commits = [] + for line in log_out.splitlines(): + if line.strip(): + parts = line.split(" ", 1) + if len(parts) >= 2: + commits.append({"hash": parts[0], "message": parts[1]}) + + # My tasks + my_tasks = [] + all_task_statuses: dict[str, str] = {} + if tasks_dir.is_dir(): + for d in sorted(tasks_dir.iterdir()): + if d.is_dir() and d.name != "archive": + t_json = d / FILE_TASK_JSON + if t_json.is_file(): + data = _read_json_file(t_json) + if data: + all_task_statuses[d.name] = data.get("status", "unknown") + + if tasks_dir.is_dir(): + for d in sorted(tasks_dir.iterdir()): + if d.is_dir() and d.name != "archive": + t_json = d / FILE_TASK_JSON + if t_json.is_file(): + data = _read_json_file(t_json) + if data and data.get("assignee") == developer: + children_list = data.get("children", []) + done = sum(1 for c in children_list if all_task_statuses.get(c) in ("completed", "done")) + my_tasks.append({ + "dir": d.name, + "title": data.get("title") or data.get("name") or "unknown", + "status": data.get("status", "unknown"), + "priority": data.get("priority", "P2"), + "children": children_list, + "childrenDone": done, + "parent": data.get("parent"), + "meta": data.get("meta", {}), + }) + + # Current task + current_task_info = None + current_task = get_current_task(repo_root) + if current_task: + task_json_path = (repo_root / current_task) / FILE_TASK_JSON + if task_json_path.is_file(): + data = _read_json_file(task_json_path) + if data: + current_task_info = { + "path": current_task, + "name": data.get("name") or data.get("id") or "unknown", + "status": data.get("status", "unknown"), + } + + return { + "developer": developer or "", + "git": { + "branch": branch, + "isClean": git_status_count == 0, + "uncommittedChanges": git_status_count, + "recentCommits": commits, + }, + "myTasks": my_tasks, + "currentTask": current_task_info, + } + + +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. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Formatted text output for record-session. + """ + 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 + + # Collect task data for children progress + all_task_statuses: dict[str, str] = {} + if tasks_dir.is_dir(): + for d in sorted(tasks_dir.iterdir()): + if d.is_dir() and d.name != "archive": + t_json = d / FILE_TASK_JSON + if t_json.is_file(): + data = _read_json_file(t_json) + if data: + all_task_statuses[d.name] = data.get("status", "unknown") + + def _record_children_progress(children_list: list[str]) -> str: + if not children_list: + return "" + done = 0 + for c in children_list: + if all_task_statuses.get(c) in ("completed", "done"): + done += 1 + return f" [{done}/{len(children_list)} done]" + + if tasks_dir.is_dir(): + for d in sorted(tasks_dir.iterdir()): + if d.is_dir() and d.name != "archive": + t_json = d / FILE_TASK_JSON + if t_json.is_file(): + data = _read_json_file(t_json) + if data: + assignee = data.get("assignee", "") + status = data.get("status", "planning") + + if assignee == developer: + title = data.get("title") or data.get("name") or "unknown" + priority = data.get("priority", "P2") + children_list = data.get("children", []) + progress = _record_children_progress(children_list) if children_list else "" + lines.append(f"- [{priority}] {title} ({status}){progress} — {d.name}") + my_task_count += 1 + + if my_task_count == 0: + lines.append("(no active tasks assigned to you)") + lines.append("") + + # GIT STATUS + lines.append("## GIT STATUS") + _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root) + branch = branch_out.strip() or "unknown" + lines.append(f"Branch: {branch}") + + _, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root) + status_lines = [line for line in status_out.splitlines() if line.strip()] + status_count = len(status_lines) + + if status_count == 0: + lines.append("Working directory: Clean") + else: + lines.append(f"Working directory: {status_count} uncommitted change(s)") + lines.append("") + lines.append("Changes:") + _, short_out, _ = _run_git_command(["status", "--short"], cwd=repo_root) + for line in short_out.splitlines()[:10]: + lines.append(line) + lines.append("") + + # RECENT COMMITS + lines.append("## RECENT COMMITS") + _, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root) + if log_out.strip(): + for line in log_out.splitlines(): + lines.append(line) + else: + lines.append("(no commits)") + lines.append("") + + # CURRENT TASK + lines.append("## CURRENT TASK") + current_task = get_current_task(repo_root) + if current_task: + current_task_dir = repo_root / current_task + task_json_path = current_task_dir / FILE_TASK_JSON + lines.append(f"Path: {current_task}") + + if task_json_path.is_file(): + data = _read_json_file(task_json_path) + if data: + t_name = data.get("name") or data.get("id") or "unknown" + t_status = data.get("status", "unknown") + lines.append(f"Name: {t_name}") + lines.append(f"Status: {t_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. + """ + print(get_context_text(repo_root)) + + +# ============================================================================= +# 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"], + default="default", + help="Output mode: default (full context) or record (for record-session)", + ) + + 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()) + else: + if args.json: + output_json() + else: + output_text() + + +if __name__ == "__main__": + main() diff --git a/.trellis/scripts/common/paths.py b/.trellis/scripts/common/paths.py new file mode 100755 index 0000000..dcbb66b --- /dev/null +++ b/.trellis/scripts/common/paths.py @@ -0,0 +1,347 @@ +#!/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 _get_current_task_file(repo_root: Path | None = None) -> Path: + """Get .current-task file path. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Path to .current-task file. + """ + if repo_root is None: + repo_root = get_repo_root() + return repo_root / DIR_WORKFLOW / FILE_CURRENT_TASK + + +def get_current_task(repo_root: Path | 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. + """ + current_file = _get_current_task_file(repo_root) + + if not current_file.is_file(): + return None + + try: + return current_file.read_text(encoding="utf-8").strip() + except (OSError, IOError): + return None + + +def get_current_task_abs(repo_root: Path | 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) + if relative: + return repo_root / relative + return None + + +def set_current_task(task_path: str, repo_root: Path | None = None) -> bool: + """Set current task. + + 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() + + if not task_path: + return False + + # Verify task directory exists + full_path = repo_root / task_path + if not full_path.is_dir(): + return False + + current_file = _get_current_task_file(repo_root) + + try: + current_file.write_text(task_path, encoding="utf-8") + return True + except (OSError, IOError): + return False + + +def clear_current_task(repo_root: Path | None = None) -> bool: + """Clear current task. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True on success. + """ + current_file = _get_current_task_file(repo_root) + + try: + if current_file.is_file(): + current_file.unlink() + return True + except (OSError, IOError): + return False + + +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") + + +# ============================================================================= +# 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)}") diff --git a/.trellis/scripts/common/phase.py b/.trellis/scripts/common/phase.py new file mode 100755 index 0000000..c3a8039 --- /dev/null +++ b/.trellis/scripts/common/phase.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +""" +Phase Management Utilities. + +Centralized phase tracking for multi-agent pipeline. + +Provides: + get_current_phase - Returns current phase number + get_total_phases - Returns total phase count + get_phase_action - Returns action name for phase + get_phase_info - Returns "N/M (action)" format + set_phase - Sets current_phase + advance_phase - Advances to next phase + get_phase_for_action - Returns phase number for action + map_subagent_to_action - Map subagent type to action name + is_phase_completed - Check if phase is completed + is_current_action - Check if at specific action +""" + +from __future__ import annotations + +import json +from pathlib import Path + + +def _read_json_file(path: Path) -> dict | None: + """Read and parse a JSON file.""" + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + + +def _write_json_file(path: Path, data: dict) -> bool: + """Write dict to JSON file.""" + try: + path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + return True + except (OSError, IOError): + return False + + +# ============================================================================= +# Phase Functions +# ============================================================================= + +def get_current_phase(task_json: Path) -> int: + """Get current phase number. + + Args: + task_json: Path to task.json file. + + Returns: + Current phase number, or 0 if not found. + """ + data = _read_json_file(task_json) + if not data: + return 0 + return data.get("current_phase", 0) or 0 + + +def get_total_phases(task_json: Path) -> int: + """Get total number of phases. + + Args: + task_json: Path to task.json file. + + Returns: + Total phase count, or 0 if not found. + """ + data = _read_json_file(task_json) + if not data: + return 0 + + next_action = data.get("next_action", []) + if isinstance(next_action, list): + return len(next_action) + return 0 + + +def get_phase_action(task_json: Path, phase: int) -> str: + """Get action name for a specific phase. + + Args: + task_json: Path to task.json file. + phase: Phase number. + + Returns: + Action name, or "unknown" if not found. + """ + data = _read_json_file(task_json) + if not data: + return "unknown" + + next_action = data.get("next_action", []) + if isinstance(next_action, list): + for item in next_action: + if isinstance(item, dict) and item.get("phase") == phase: + return item.get("action", "unknown") + return "unknown" + + +def get_phase_info(task_json: Path) -> str: + """Get formatted phase info: "N/M (action)". + + Args: + task_json: Path to task.json file. + + Returns: + Formatted string like "1/4 (implement)". + """ + data = _read_json_file(task_json) + if not data: + return "N/A" + + current_phase = data.get("current_phase", 0) or 0 + total_phases = get_total_phases(task_json) + action_name = get_phase_action(task_json, current_phase) + + if current_phase == 0 or current_phase is None: + return f"0/{total_phases} (pending)" + else: + return f"{current_phase}/{total_phases} ({action_name})" + + +def set_phase(task_json: Path, phase: int) -> bool: + """Set current phase to a specific value. + + Args: + task_json: Path to task.json file. + phase: Phase number to set. + + Returns: + True on success, False on error. + """ + data = _read_json_file(task_json) + if not data: + return False + + data["current_phase"] = phase + return _write_json_file(task_json, data) + + +def advance_phase(task_json: Path) -> bool: + """Advance to next phase. + + Args: + task_json: Path to task.json file. + + Returns: + True on success, False on error or at final phase. + """ + data = _read_json_file(task_json) + if not data: + return False + + current = data.get("current_phase", 0) or 0 + total = get_total_phases(task_json) + next_phase = current + 1 + + if next_phase > total: + return False # Already at final phase + + data["current_phase"] = next_phase + return _write_json_file(task_json, data) + + +def get_phase_for_action(task_json: Path, action: str) -> int: + """Get phase number for a specific action name. + + Args: + task_json: Path to task.json file. + action: Action name. + + Returns: + Phase number, or 0 if not found. + """ + data = _read_json_file(task_json) + if not data: + return 0 + + next_action = data.get("next_action", []) + if isinstance(next_action, list): + for item in next_action: + if isinstance(item, dict) and item.get("action") == action: + return item.get("phase", 0) + return 0 + + +def map_subagent_to_action(subagent_type: str) -> str: + """Map subagent type to action name. + + Used by hooks to determine which action a subagent corresponds to. + + Args: + subagent_type: Subagent type string. + + Returns: + Corresponding action name. + """ + mapping = { + "implement": "implement", + "check": "check", + "debug": "debug", + "research": "research", + } + return mapping.get(subagent_type, subagent_type) + + +def is_phase_completed(task_json: Path, phase: int) -> bool: + """Check if a phase is completed (current_phase > phase). + + Args: + task_json: Path to task.json file. + phase: Phase number to check. + + Returns: + True if phase is completed. + """ + current = get_current_phase(task_json) + return current > phase + + +def is_current_action(task_json: Path, action: str) -> bool: + """Check if we're at a specific action. + + Args: + task_json: Path to task.json file. + action: Action name to check. + + Returns: + True if current phase matches the action. + """ + current = get_current_phase(task_json) + action_phase = get_phase_for_action(task_json, action) + return current == action_phase + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + import sys + + if len(sys.argv) > 1: + path = Path(sys.argv[1]) + print(f"Task JSON: {path}") + print(f"Phase info: {get_phase_info(path)}") + print(f"Current phase: {get_current_phase(path)}") + print(f"Total phases: {get_total_phases(path)}") + else: + print("Usage: python3 phase.py <task.json>") diff --git a/.trellis/scripts/common/registry.py b/.trellis/scripts/common/registry.py new file mode 100755 index 0000000..7f2bc6f --- /dev/null +++ b/.trellis/scripts/common/registry.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +""" +Registry utility functions for multi-agent pipeline. + +Provides: + registry_get_file - Get registry file path + registry_get_agent_by_id - Find agent by ID + registry_get_agent_by_worktree - Find agent by worktree path + registry_get_task_dir - Get task dir for a worktree + registry_remove_by_id - Remove agent by ID + registry_remove_by_worktree - Remove agent by worktree path + registry_add_agent - Add agent to registry + registry_search_agent - Search agent by ID or task_dir + registry_list_agents - List all agents +""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path + +from .paths import get_repo_root +from .worktree import get_agents_dir + + +def _read_json_file(path: Path) -> dict | None: + """Read and parse a JSON file.""" + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + + +def _write_json_file(path: Path, data: dict) -> bool: + """Write dict to JSON file.""" + try: + path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + return True + except (OSError, IOError): + return False + + +# ============================================================================= +# Registry File Access +# ============================================================================= + +def registry_get_file(repo_root: Path | None = None) -> Path | None: + """Get registry file path. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Path to registry.json, or None if agents dir not found. + """ + if repo_root is None: + repo_root = get_repo_root() + + agents_dir = get_agents_dir(repo_root) + if agents_dir: + return agents_dir / "registry.json" + return None + + +def _ensure_registry(repo_root: Path | None = None) -> Path | None: + """Ensure registry file exists with valid structure. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Path to registry file, or None if cannot create. + """ + if repo_root is None: + repo_root = get_repo_root() + + registry_file = registry_get_file(repo_root) + if not registry_file: + return None + + agents_dir = registry_file.parent + + try: + agents_dir.mkdir(parents=True, exist_ok=True) + + if not registry_file.exists(): + _write_json_file(registry_file, {"agents": []}) + + return registry_file + except (OSError, IOError): + return None + + +# ============================================================================= +# Agent Lookup +# ============================================================================= + +def registry_get_agent_by_id( + agent_id: str, + repo_root: Path | None = None +) -> dict | None: + """Get agent by ID. + + Args: + agent_id: Agent ID. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Agent dict, or None if not found. + """ + if repo_root is None: + repo_root = get_repo_root() + + registry_file = registry_get_file(repo_root) + if not registry_file or not registry_file.is_file(): + return None + + data = _read_json_file(registry_file) + if not data: + return None + + for agent in data.get("agents", []): + if agent.get("id") == agent_id: + return agent + + return None + + +def registry_get_agent_by_worktree( + worktree_path: str, + repo_root: Path | None = None +) -> dict | None: + """Get agent by worktree path. + + Args: + worktree_path: Worktree path. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Agent dict, or None if not found. + """ + if repo_root is None: + repo_root = get_repo_root() + + registry_file = registry_get_file(repo_root) + if not registry_file or not registry_file.is_file(): + return None + + data = _read_json_file(registry_file) + if not data: + return None + + for agent in data.get("agents", []): + if agent.get("worktree_path") == worktree_path: + return agent + + return None + + +def registry_search_agent( + search: str, + repo_root: Path | None = None +) -> dict | None: + """Search agent by ID or task_dir containing search term. + + Args: + search: Search term. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + First matching agent dict, or None if not found. + """ + if repo_root is None: + repo_root = get_repo_root() + + registry_file = registry_get_file(repo_root) + if not registry_file or not registry_file.is_file(): + return None + + data = _read_json_file(registry_file) + if not data: + return None + + for agent in data.get("agents", []): + # Exact ID match + if agent.get("id") == search: + return agent + # Partial match on task_dir + task_dir = agent.get("task_dir", "") + if search in task_dir: + return agent + + return None + + +def registry_get_task_dir( + worktree_path: str, + repo_root: Path | None = None +) -> str | None: + """Get task directory for a worktree. + + Args: + worktree_path: Worktree path. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Task directory path, or None if not found. + """ + agent = registry_get_agent_by_worktree(worktree_path, repo_root) + if agent: + return agent.get("task_dir") + return None + + +# ============================================================================= +# Agent Modification +# ============================================================================= + +def registry_remove_by_id(agent_id: str, repo_root: Path | None = None) -> bool: + """Remove agent by ID. + + Args: + agent_id: Agent ID. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True on success. + """ + if repo_root is None: + repo_root = get_repo_root() + + registry_file = registry_get_file(repo_root) + if not registry_file or not registry_file.is_file(): + return True # Nothing to remove + + data = _read_json_file(registry_file) + if not data: + return True + + agents = data.get("agents", []) + data["agents"] = [a for a in agents if a.get("id") != agent_id] + + return _write_json_file(registry_file, data) + + +def registry_remove_by_worktree( + worktree_path: str, + repo_root: Path | None = None +) -> bool: + """Remove agent by worktree path. + + Args: + worktree_path: Worktree path. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True on success. + """ + if repo_root is None: + repo_root = get_repo_root() + + registry_file = registry_get_file(repo_root) + if not registry_file or not registry_file.is_file(): + return True # Nothing to remove + + data = _read_json_file(registry_file) + if not data: + return True + + agents = data.get("agents", []) + data["agents"] = [a for a in agents if a.get("worktree_path") != worktree_path] + + return _write_json_file(registry_file, data) + + +def registry_add_agent( + agent_id: str, + worktree_path: str, + pid: int, + task_dir: str, + repo_root: Path | None = None, + platform: str = "claude", +) -> bool: + """Add agent to registry (replaces if same ID exists). + + Args: + agent_id: Agent ID. + worktree_path: Worktree path. + pid: Process ID. + task_dir: Task directory path. + repo_root: Repository root path. Defaults to auto-detected. + platform: Platform used (e.g., 'claude', 'opencode', 'codex', 'kiro', 'antigravity'). Defaults to 'claude'. + + Returns: + True on success. + """ + if repo_root is None: + repo_root = get_repo_root() + + registry_file = _ensure_registry(repo_root) + if not registry_file: + return False + + data = _read_json_file(registry_file) + if not data: + data = {"agents": []} + + # Remove existing agent with same ID + agents = data.get("agents", []) + agents = [a for a in agents if a.get("id") != agent_id] + + # Create new agent record + started_at = datetime.now().isoformat() + new_agent = { + "id": agent_id, + "worktree_path": worktree_path, + "pid": pid, + "started_at": started_at, + "task_dir": task_dir, + "platform": platform, + } + + agents.append(new_agent) + data["agents"] = agents + + return _write_json_file(registry_file, data) + + +def registry_list_agents(repo_root: Path | None = None) -> list[dict]: + """List all agents. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of agent dicts. + """ + if repo_root is None: + repo_root = get_repo_root() + + registry_file = registry_get_file(repo_root) + if not registry_file or not registry_file.is_file(): + return [] + + data = _read_json_file(registry_file) + if not data: + return [] + + return data.get("agents", []) + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + import json as json_mod + + repo = get_repo_root() + print(f"Repository root: {repo}") + print(f"Registry file: {registry_get_file(repo)}") + print() + print("Agents:") + agents = registry_list_agents(repo) + print(json_mod.dumps(agents, indent=2)) diff --git a/.trellis/scripts/common/task_queue.py b/.trellis/scripts/common/task_queue.py new file mode 100755 index 0000000..70378a1 --- /dev/null +++ b/.trellis/scripts/common/task_queue.py @@ -0,0 +1,259 @@ +#!/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 + +import json +from pathlib import Path + +from .paths import ( + FILE_TASK_JSON, + get_repo_root, + get_developer, + get_tasks_dir, +) + + +def _read_json_file(path: Path) -> dict | None: + """Read and parse a JSON file.""" + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + + +# ============================================================================= +# 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 = [] + + if not tasks_dir.is_dir(): + return results + + for d in tasks_dir.iterdir(): + if not d.is_dir() or d.name == "archive": + continue + + task_json = d / FILE_TASK_JSON + if not task_json.is_file(): + continue + + data = _read_json_file(task_json) + if not data: + continue + + task_id = data.get("id", "") + title = data.get("title") or data.get("name", "") + priority = data.get("priority", "P2") + status = data.get("status", "planning") + assignee = data.get("assignee", "-") + + # Apply filter + if filter_status and status != filter_status: + continue + + results.append({ + "priority": priority, + "id": task_id, + "title": title, + "status": status, + "assignee": assignee, + "dir": d.name, + "children": data.get("children", []), + "parent": data.get("parent"), + }) + + 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 = [] + + if not tasks_dir.is_dir(): + return results + + for d in tasks_dir.iterdir(): + if not d.is_dir() or d.name == "archive": + continue + + task_json = d / FILE_TASK_JSON + if not task_json.is_file(): + continue + + data = _read_json_file(task_json) + if not data: + continue + + task_assignee = data.get("assignee", "-") + + # Apply assignee filter + if task_assignee != assignee: + continue + + task_id = data.get("id", "") + title = data.get("title") or data.get("name", "") + priority = data.get("priority", "P2") + status = data.get("status", "planning") + + # Apply status filter + if filter_status and status != filter_status: + continue + + results.append({ + "priority": priority, + "id": task_id, + "title": title, + "status": status, + "assignee": task_assignee, + "dir": d.name, + "children": data.get("children", []), + "parent": data.get("parent"), + }) + + 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} + + if not tasks_dir.is_dir(): + return stats + + for d in tasks_dir.iterdir(): + if not d.is_dir() or d.name == "archive": + continue + + task_json = d / FILE_TASK_JSON + if not task_json.is_file(): + continue + + data = _read_json_file(task_json) + if not data: + continue + + priority = data.get("priority", "P2") + if priority in stats: + stats[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']}") diff --git a/.trellis/scripts/common/task_utils.py b/.trellis/scripts/common/task_utils.py new file mode 100755 index 0000000..84df2fa --- /dev/null +++ b/.trellis/scripts/common/task_utils.py @@ -0,0 +1,178 @@ +#!/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 + archive_task_dir - Archive task to monthly directory +""" + +from __future__ import annotations + +import shutil +import sys +from datetime import datetime +from pathlib import Path + +from .paths import get_repo_root + + +# ============================================================================= +# 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() + + # Check empty or null + if not task_path or task_path == "null": + print("Error: empty or null task path", file=sys.stderr) + return False + + # Reject absolute paths + if task_path.startswith("/"): + print(f"Error: absolute path not allowed: {task_path}", file=sys.stderr) + return False + + # Reject ".", "..", paths starting with "./" or "../", or containing ".." + if task_path in (".", "..") or task_path.startswith("./") or task_path.startswith("../") or ".." in task_path: + 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 / task_path + 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 {} + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + from .paths import get_tasks_dir + + 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)}") diff --git a/.trellis/scripts/common/worktree.py b/.trellis/scripts/common/worktree.py new file mode 100755 index 0000000..f9aa4ba --- /dev/null +++ b/.trellis/scripts/common/worktree.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +""" +Worktree utilities for Multi-Agent Pipeline. + +Provides: + get_worktree_config - Get worktree.yaml path + get_worktree_base_dir - Get worktree storage directory + get_worktree_copy_files - Get files to copy list + get_worktree_post_create_hooks - Get post-create hooks + get_agents_dir - Get agents registry directory +""" + +from __future__ import annotations + +from pathlib import Path + +from .paths import ( + DIR_WORKFLOW, + get_repo_root, + get_workspace_dir, +) + + +# ============================================================================= +# 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 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 = _unquote(value.strip()) + 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, "" + + +def _yaml_get_value(config_file: Path, key: str) -> str | None: + """Read simple value from worktree.yaml. + + Args: + config_file: Path to config file. + key: Key to read. + + Returns: + Value string or None. + """ + try: + content = config_file.read_text(encoding="utf-8") + data = parse_simple_yaml(content) + value = data.get(key) + if isinstance(value, str): + return value + except (OSError, IOError): + pass + return None + + +def _yaml_get_list(config_file: Path, section: str) -> list[str]: + """Read list from worktree.yaml. + + Args: + config_file: Path to config file. + section: Section name. + + Returns: + List of items. + """ + try: + content = config_file.read_text(encoding="utf-8") + data = parse_simple_yaml(content) + value = data.get(section) + if isinstance(value, list): + return [str(item) for item in value] + except (OSError, IOError): + pass + return [] + + +# ============================================================================= +# Worktree Configuration +# ============================================================================= + +# Worktree config file relative path (relative to repo root) +WORKTREE_CONFIG_PATH = f"{DIR_WORKFLOW}/worktree.yaml" + + +def get_worktree_config(repo_root: Path | None = None) -> Path: + """Get worktree.yaml config file path. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Absolute path to config file. + """ + if repo_root is None: + repo_root = get_repo_root() + return repo_root / WORKTREE_CONFIG_PATH + + +def get_worktree_base_dir(repo_root: Path | None = None) -> Path: + """Get worktree base directory. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Absolute path to worktree base directory. + """ + if repo_root is None: + repo_root = get_repo_root() + + config = get_worktree_config(repo_root) + worktree_dir = _yaml_get_value(config, "worktree_dir") + + # Default value + if not worktree_dir: + worktree_dir = "../worktrees" + + # Handle relative path + if worktree_dir.startswith("../") or worktree_dir.startswith("./"): + # Relative to repo_root + return repo_root / worktree_dir + else: + # Absolute path + return Path(worktree_dir) + + +def get_worktree_copy_files(repo_root: Path | None = None) -> list[str]: + """Get files to copy list. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of file paths to copy. + """ + if repo_root is None: + repo_root = get_repo_root() + config = get_worktree_config(repo_root) + return _yaml_get_list(config, "copy") + + +def get_worktree_post_create_hooks(repo_root: Path | None = None) -> list[str]: + """Get post_create hooks. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of commands to run. + """ + if repo_root is None: + repo_root = get_repo_root() + config = get_worktree_config(repo_root) + return _yaml_get_list(config, "post_create") + + +# ============================================================================= +# Agents Registry +# ============================================================================= + +def get_agents_dir(repo_root: Path | None = None) -> Path | None: + """Get agents directory for current developer. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Absolute path to agents directory, or None if no workspace. + """ + if repo_root is None: + repo_root = get_repo_root() + + workspace_dir = get_workspace_dir(repo_root) + if workspace_dir: + return workspace_dir / ".agents" + return None + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + repo = get_repo_root() + print(f"Repository root: {repo}") + print(f"Worktree config: {get_worktree_config(repo)}") + print(f"Worktree base dir: {get_worktree_base_dir(repo)}") + print(f"Copy files: {get_worktree_copy_files(repo)}") + print(f"Post create hooks: {get_worktree_post_create_hooks(repo)}") + print(f"Agents dir: {get_agents_dir(repo)}") diff --git a/.trellis/scripts/create_bootstrap.py b/.trellis/scripts/create_bootstrap.py new file mode 100755 index 0000000..201146f --- /dev/null +++ b/.trellis/scripts/create_bootstrap.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +""" +Create Bootstrap Task for First-Time Setup. + +Creates a guided task to help users fill in project guidelines +after initializing Trellis for the first time. + +Usage: + python3 create_bootstrap.py [project-type] + +Arguments: + project-type: frontend | backend | fullstack (default: fullstack) + +Prerequisites: + - .trellis/.developer must exist (run init_developer.py first) + +Creates: + .trellis/tasks/00-bootstrap-guidelines/ + - task.json # Task metadata + - prd.md # Task description and guidance +""" + +from __future__ import annotations + +import json +import sys +from datetime import datetime +from pathlib import Path + +from common.paths import ( + DIR_WORKFLOW, + DIR_SCRIPTS, + DIR_TASKS, + get_repo_root, + get_developer, + get_tasks_dir, + set_current_task, +) + + +# ============================================================================= +# Constants +# ============================================================================= + +TASK_NAME = "00-bootstrap-guidelines" + + +# ============================================================================= +# PRD Content +# ============================================================================= + +def write_prd_header() -> str: + """Write PRD header section.""" + return """# Bootstrap: Fill Project Development Guidelines + +## Purpose + +Welcome to Trellis! This is your first task. + +AI agents use `.trellis/spec/` to understand YOUR project's coding conventions. +**Empty templates = AI writes generic code that doesn't match your project style.** + +Filling these guidelines is a one-time setup that pays off for every future AI session. + +--- + +## Your Task + +Fill in the guideline files based on your **existing codebase**. +""" + + +def write_prd_backend_section() -> str: + """Write PRD backend section.""" + return """ + +### 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 | +""" + + +def write_prd_frontend_section() -> str: + """Write PRD frontend section.""" + return """ + +### 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 | +""" + + +def write_prd_footer() -> str: + """Write PRD footer section.""" + return """ + +### Thinking Guides (Optional) + +The `.trellis/spec/guides/` directory contains thinking guides that are already +filled with general best practices. You can customize them for your project if needed. + +--- + +## How to Fill Guidelines + +### Principle: Document Reality, Not Ideals + +Write what your codebase **actually does**, not what you wish it did. +AI needs to match existing patterns, not introduce new ones. + +### Steps + +1. **Look at existing code** - Find 2-3 examples of each pattern +2. **Document the pattern** - Describe what you see +3. **Include file paths** - Reference real files as examples +4. **List anti-patterns** - What does your team avoid? + +--- + +## Tips for Using AI + +Ask AI to help analyze your codebase: + +- "Look at my codebase and document the patterns you see" +- "Analyze my code structure and summarize the conventions" +- "Find error handling patterns and document them" + +The AI will read your code and help you document it. + +--- + +## Completion Checklist + +- [ ] Guidelines filled for your project type +- [ ] At least 2-3 real code examples in each guideline +- [ ] Anti-patterns documented + +When done: + +```bash +python3 ./.trellis/scripts/task.py finish +python3 ./.trellis/scripts/task.py archive 00-bootstrap-guidelines +``` + +--- + +## Why This Matters + +After completing this task: + +1. AI will write code that matches your project style +2. Relevant `/trellis:before-*-dev` commands will inject real context +3. `/trellis:check-*` commands will validate against your actual standards +4. Future developers (human or AI) will onboard faster +""" + + +def write_prd(task_dir: Path, project_type: str) -> None: + """Write prd.md file.""" + content = write_prd_header() + + if project_type == "frontend": + content += write_prd_frontend_section() + elif project_type == "backend": + content += write_prd_backend_section() + else: # fullstack + content += write_prd_backend_section() + content += write_prd_frontend_section() + + content += write_prd_footer() + + prd_file = task_dir / "prd.md" + prd_file.write_text(content, encoding="utf-8") + + +# ============================================================================= +# Task JSON +# ============================================================================= + +def write_task_json(task_dir: Path, developer: str, project_type: str) -> None: + """Write task.json file.""" + today = datetime.now().strftime("%Y-%m-%d") + + # Generate subtasks and related files based on project type + if project_type == "frontend": + subtasks = [ + {"name": "Fill frontend guidelines", "status": "pending"}, + {"name": "Add code examples", "status": "pending"}, + ] + related_files = [".trellis/spec/frontend/"] + elif project_type == "backend": + subtasks = [ + {"name": "Fill backend guidelines", "status": "pending"}, + {"name": "Add code examples", "status": "pending"}, + ] + related_files = [".trellis/spec/backend/"] + else: # fullstack + subtasks = [ + {"name": "Fill backend guidelines", "status": "pending"}, + {"name": "Fill frontend guidelines", "status": "pending"}, + {"name": "Add code examples", "status": "pending"}, + ] + related_files = [".trellis/spec/backend/", ".trellis/spec/frontend/"] + + task_data = { + "id": TASK_NAME, + "name": "Bootstrap Guidelines", + "description": "Fill in project development guidelines for AI agents", + "status": "in_progress", + "dev_type": "docs", + "priority": "P1", + "creator": developer, + "assignee": developer, + "createdAt": today, + "completedAt": None, + "commit": None, + "subtasks": subtasks, + "children": [], + "parent": None, + "relatedFiles": related_files, + "notes": f"First-time setup task created by trellis init ({project_type} project)", + "meta": {}, + } + + task_json = task_dir / "task.json" + task_json.write_text(json.dumps(task_data, indent=2, ensure_ascii=False), encoding="utf-8") + + +# ============================================================================= +# Main +# ============================================================================= + +def main() -> int: + """Main entry point.""" + # Parse project type argument + project_type = "fullstack" + if len(sys.argv) > 1: + project_type = sys.argv[1] + + # Validate project type + if project_type not in ("frontend", "backend", "fullstack"): + print(f"Unknown project type: {project_type}, defaulting to fullstack") + project_type = "fullstack" + + repo_root = get_repo_root() + developer = get_developer(repo_root) + + # Check developer initialized + if not developer: + print("Error: Developer not initialized") + print(f"Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <your-name>") + return 1 + + tasks_dir = get_tasks_dir(repo_root) + task_dir = tasks_dir / TASK_NAME + relative_path = f"{DIR_WORKFLOW}/{DIR_TASKS}/{TASK_NAME}" + + # Check if already exists + if task_dir.exists(): + print(f"Bootstrap task already exists: {relative_path}") + return 0 + + # Create task directory + task_dir.mkdir(parents=True, exist_ok=True) + + # Write files + write_task_json(task_dir, developer, project_type) + write_prd(task_dir, project_type) + + # Set as current task + set_current_task(relative_path, repo_root) + + # Silent output - init command handles user-facing messages + # Only output the task path for programmatic use + print(relative_path) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.trellis/scripts/get_context.py b/.trellis/scripts/get_context.py new file mode 100755 index 0000000..bc63463 --- /dev/null +++ b/.trellis/scripts/get_context.py @@ -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() diff --git a/.trellis/scripts/get_developer.py b/.trellis/scripts/get_developer.py new file mode 100755 index 0000000..f8a89eb --- /dev/null +++ b/.trellis/scripts/get_developer.py @@ -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() diff --git a/.trellis/scripts/init_developer.py b/.trellis/scripts/init_developer.py new file mode 100755 index 0000000..9fb53f5 --- /dev/null +++ b/.trellis/scripts/init_developer.py @@ -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() diff --git a/.trellis/scripts/multi_agent/__init__.py b/.trellis/scripts/multi_agent/__init__.py new file mode 100755 index 0000000..c7c7e7d --- /dev/null +++ b/.trellis/scripts/multi_agent/__init__.py @@ -0,0 +1,5 @@ +""" +Multi-Agent Pipeline Scripts. + +This module provides orchestration for multi-agent workflows. +""" diff --git a/.trellis/scripts/multi_agent/cleanup.py b/.trellis/scripts/multi_agent/cleanup.py new file mode 100755 index 0000000..f81e370 --- /dev/null +++ b/.trellis/scripts/multi_agent/cleanup.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +""" +Multi-Agent Pipeline: Cleanup Worktree. + +Usage: + python3 cleanup.py <branch-name> Remove specific worktree + python3 cleanup.py --list List all worktrees + python3 cleanup.py --merged Remove merged worktrees + python3 cleanup.py --all Remove all worktrees (with confirmation) + +Options: + -y, --yes Skip confirmation prompts + --keep-branch Don't delete the git branch + +This script: +1. Archives task directory to archive/{YYYY-MM}/ +2. Removes agent from registry +3. Removes git worktree +4. Optionally deletes git branch +""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from common.git_context import _run_git_command +from common.paths import get_repo_root +from common.registry import ( + registry_get_file, + registry_get_task_dir, + registry_remove_by_id, + registry_remove_by_worktree, + registry_search_agent, +) +from common.task_utils import ( + archive_task_complete, + is_safe_task_path, +) + +# ============================================================================= +# Colors +# ============================================================================= + + +class Colors: + RED = "\033[0;31m" + GREEN = "\033[0;32m" + YELLOW = "\033[1;33m" + BLUE = "\033[0;34m" + NC = "\033[0m" + + +def log_info(msg: str) -> None: + print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}") + + +def log_success(msg: str) -> None: + print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}") + + +def log_warn(msg: str) -> None: + print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}") + + +def log_error(msg: str) -> None: + print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}") + + +# ============================================================================= +# Helper Functions +# ============================================================================= + + +def confirm(prompt: str, skip_confirm: bool) -> bool: + """Ask for confirmation.""" + if skip_confirm: + return True + + if not sys.stdin.isatty(): + log_error("Non-interactive mode detected. Use -y to skip confirmation.") + return False + + response = input(f"{prompt} [y/N] ") + return response.lower() in ("y", "yes") + + +# ============================================================================= +# Commands +# ============================================================================= + + +def cmd_list(repo_root: Path) -> int: + """List worktrees.""" + print(f"{Colors.BLUE}=== Git Worktrees ==={Colors.NC}") + print() + + subprocess.run(["git", "worktree", "list"], cwd=repo_root) + print() + + # Show registry info + registry_file = registry_get_file(repo_root) + if registry_file and registry_file.is_file(): + print(f"{Colors.BLUE}=== Registered Agents ==={Colors.NC}") + print() + + import json + + data = json.loads(registry_file.read_text(encoding="utf-8")) + agents = data.get("agents", []) + + if agents: + for agent in agents: + print( + f" {agent.get('id', '?')}: PID={agent.get('pid', '?')} [{agent.get('worktree_path', '?')}]" + ) + else: + print(" (none)") + print() + + return 0 + + +def archive_task(worktree_path: str, repo_root: Path) -> None: + """Archive task directory.""" + task_dir = registry_get_task_dir(worktree_path, repo_root) + + if not task_dir or not is_safe_task_path(task_dir, repo_root): + return + + task_dir_abs = repo_root / task_dir + if not task_dir_abs.is_dir(): + return + + result = archive_task_complete(task_dir_abs, repo_root) + if "archived_to" in result: + dest = Path(result["archived_to"]) + log_success(f"Archived task: {dest.name} -> archive/{dest.parent.name}/") + + +def cleanup_registry_only(search: str, repo_root: Path, skip_confirm: bool) -> int: + """Cleanup from registry only (no worktree).""" + agent_info = registry_search_agent(search, repo_root) + + if not agent_info: + log_error(f"No agent found in registry matching: {search}") + return 1 + + agent_id = agent_info.get("id", "?") + task_dir = agent_info.get("task_dir", "?") + + print() + print(f"{Colors.BLUE}=== Cleanup Agent (no worktree) ==={Colors.NC}") + print(f" Agent ID: {agent_id}") + print(f" Task Dir: {task_dir}") + print() + + if not confirm("Archive task and remove from registry?", skip_confirm): + log_info("Aborted") + return 0 + + # Archive task directory if exists + if task_dir and is_safe_task_path(task_dir, repo_root): + task_dir_abs = repo_root / task_dir + if task_dir_abs.is_dir(): + result = archive_task_complete(task_dir_abs, repo_root) + if "archived_to" in result: + dest = Path(result["archived_to"]) + log_success( + f"Archived task: {dest.name} -> archive/{dest.parent.name}/" + ) + else: + log_warn("Invalid task_dir in registry, skipping archive") + + # Remove from registry + registry_remove_by_id(agent_id, repo_root) + log_success(f"Removed from registry: {agent_id}") + + log_success("Cleanup complete") + return 0 + + +def cleanup_worktree( + branch: str, repo_root: Path, skip_confirm: bool, keep_branch: bool +) -> int: + """Cleanup single worktree.""" + # Find worktree path for branch + _, worktree_list, _ = _run_git_command( + ["worktree", "list", "--porcelain"], cwd=repo_root + ) + + worktree_path = None + current_worktree = None + + for line in worktree_list.splitlines(): + if line.startswith("worktree "): + current_worktree = line[9:] # Remove "worktree " prefix + elif line.startswith("branch refs/heads/"): + current_branch = line[18:] # Remove "branch refs/heads/" prefix + if current_branch == branch: + worktree_path = current_worktree + break + + if not worktree_path: + # No worktree found, try to cleanup from registry only + log_warn(f"No worktree found for: {branch}") + log_info("Trying to cleanup from registry...") + return cleanup_registry_only(branch, repo_root, skip_confirm) + + print() + print(f"{Colors.BLUE}=== Cleanup Worktree ==={Colors.NC}") + print(f" Branch: {branch}") + print(f" Worktree: {worktree_path}") + print() + + if not confirm("Remove this worktree?", skip_confirm): + log_info("Aborted") + return 0 + + # 1. Archive task + archive_task(worktree_path, repo_root) + + # 2. Remove from registry + registry_remove_by_worktree(worktree_path, repo_root) + log_info("Removed from registry") + + # 3. Remove worktree + log_info("Removing worktree...") + ret, _, _ = _run_git_command( + ["worktree", "remove", worktree_path, "--force"], cwd=repo_root + ) + if ret != 0: + # Try removing directory manually + try: + shutil.rmtree(worktree_path) + except Exception as e: + log_error(f"Failed to remove worktree: {e}") + + log_success("Worktree removed") + + # 4. Delete branch (optional) + if not keep_branch: + log_info("Deleting branch...") + ret, _, _ = _run_git_command(["branch", "-D", branch], cwd=repo_root) + if ret != 0: + log_warn("Could not delete branch (may be checked out elsewhere)") + + log_success(f"Cleanup complete for: {branch}") + return 0 + + +def cmd_merged(repo_root: Path, skip_confirm: bool, keep_branch: bool) -> int: + """Cleanup merged worktrees.""" + # Get main branch + _, head_out, _ = _run_git_command( + ["symbolic-ref", "refs/remotes/origin/HEAD"], cwd=repo_root + ) + main_branch = head_out.strip().replace("refs/remotes/origin/", "") or "main" + + print(f"{Colors.BLUE}=== Finding Merged Worktrees ==={Colors.NC}") + print() + + # Get merged branches + _, merged_out, _ = _run_git_command( + ["branch", "--merged", main_branch], cwd=repo_root + ) + merged_branches = [] + for line in merged_out.splitlines(): + branch = line.strip().lstrip("* ") + if branch and branch != main_branch: + merged_branches.append(branch) + + if not merged_branches: + log_info("No merged branches found") + return 0 + + # Get worktree list + _, worktree_list, _ = _run_git_command(["worktree", "list"], cwd=repo_root) + + worktree_branches = [] + for branch in merged_branches: + if f"[{branch}]" in worktree_list: + worktree_branches.append(branch) + print(f" - {branch}") + + if not worktree_branches: + log_info("No merged worktrees found") + return 0 + + print() + if not confirm("Remove these merged worktrees?", skip_confirm): + log_info("Aborted") + return 0 + + for branch in worktree_branches: + cleanup_worktree(branch, repo_root, True, keep_branch) + + return 0 + + +def cmd_all(repo_root: Path, skip_confirm: bool, keep_branch: bool) -> int: + """Cleanup all worktrees.""" + print(f"{Colors.BLUE}=== All Worktrees ==={Colors.NC}") + print() + + # Get worktree list + _, worktree_list, _ = _run_git_command( + ["worktree", "list", "--porcelain"], cwd=repo_root + ) + + worktrees = [] + main_worktree = str(repo_root.resolve()) + + for line in worktree_list.splitlines(): + if line.startswith("worktree "): + wt = line[9:] + if wt != main_worktree: + worktrees.append(wt) + + if not worktrees: + log_info("No worktrees to remove") + return 0 + + for wt in worktrees: + print(f" - {wt}") + + print() + print(f"{Colors.RED}WARNING: This will remove ALL worktrees!{Colors.NC}") + + if not confirm("Are you sure?", skip_confirm): + log_info("Aborted") + return 0 + + # Get branch for each worktree + for wt in worktrees: + # Find branch name from worktree list + _, wt_list, _ = _run_git_command(["worktree", "list"], cwd=repo_root) + for line in wt_list.splitlines(): + if wt in line: + # Extract branch from [branch] format + import re + + match = re.search(r"\[([^\]]+)\]", line) + if match: + branch = match.group(1) + cleanup_worktree(branch, repo_root, True, keep_branch) + break + + return 0 + + +# ============================================================================= +# Main +# ============================================================================= + + +def main() -> int: + """Main entry point.""" + parser = argparse.ArgumentParser( + description="Multi-Agent Pipeline: Cleanup Worktree" + ) + parser.add_argument("branch", nargs="?", help="Branch name to cleanup") + parser.add_argument("-y", "--yes", action="store_true", help="Skip confirmation") + parser.add_argument( + "--keep-branch", action="store_true", help="Don't delete git branch" + ) + parser.add_argument("--list", action="store_true", help="List all worktrees") + parser.add_argument("--merged", action="store_true", help="Remove merged worktrees") + parser.add_argument("--all", action="store_true", help="Remove all worktrees") + + args = parser.parse_args() + repo_root = get_repo_root() + + if args.list: + return cmd_list(repo_root) + elif args.merged: + return cmd_merged(repo_root, args.yes, args.keep_branch) + elif args.all: + return cmd_all(repo_root, args.yes, args.keep_branch) + elif args.branch: + return cleanup_worktree(args.branch, repo_root, args.yes, args.keep_branch) + else: + print("""Usage: + python3 cleanup.py <branch-name> Remove specific worktree + python3 cleanup.py --list List all worktrees + python3 cleanup.py --merged Remove merged worktrees + python3 cleanup.py --all Remove all worktrees + +Options: + -y, --yes Skip confirmation + --keep-branch Don't delete git branch +""") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.trellis/scripts/multi_agent/create_pr.py b/.trellis/scripts/multi_agent/create_pr.py new file mode 100755 index 0000000..54df3db --- /dev/null +++ b/.trellis/scripts/multi_agent/create_pr.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +""" +Multi-Agent Pipeline: Create PR. + +Usage: + python3 create_pr.py [task-dir] [--dry-run] + +This script: +1. Stages and commits all changes (excluding workspace/) +2. Pushes to origin +3. Creates a Draft PR using `gh pr create` +4. Updates task.json with status="completed", pr_url, and current_phase + +Note: This is the only action that performs git commit, as it's the final +step after all implementation and checks are complete. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from common.git_context import _run_git_command +from common.paths import ( + DIR_WORKFLOW, + FILE_TASK_JSON, + get_current_task, + get_repo_root, +) +from common.phase import get_phase_for_action + +# ============================================================================= +# Colors +# ============================================================================= + + +class Colors: + RED = "\033[0;31m" + GREEN = "\033[0;32m" + YELLOW = "\033[1;33m" + BLUE = "\033[0;34m" + NC = "\033[0m" + + +# ============================================================================= +# Helper Functions +# ============================================================================= + + +def _read_json_file(path: Path) -> dict | None: + """Read and parse a JSON file.""" + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + + +def _write_json_file(path: Path, data: dict) -> bool: + """Write dict to JSON file.""" + try: + path.write_text( + json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8" + ) + return True + except (OSError, IOError): + return False + + +# ============================================================================= +# Main +# ============================================================================= + + +def main() -> int: + """Main entry point.""" + parser = argparse.ArgumentParser(description="Multi-Agent Pipeline: Create PR") + parser.add_argument("dir", nargs="?", help="Task directory") + parser.add_argument( + "--dry-run", action="store_true", help="Show what would be done" + ) + + args = parser.parse_args() + repo_root = get_repo_root() + + # ============================================================================= + # Get Task Directory + # ============================================================================= + target_dir = args.dir + if not target_dir: + # Try to get from .current-task + current_task = get_current_task(repo_root) + if current_task: + target_dir = current_task + + if not target_dir: + print( + f"{Colors.RED}Error: No task directory specified and no current task set{Colors.NC}" + ) + print("Usage: python3 create_pr.py [task-dir] [--dry-run]") + return 1 + + # Support relative paths + if not target_dir.startswith("/"): + target_dir_path = repo_root / target_dir + else: + target_dir_path = Path(target_dir) + + task_json = target_dir_path / FILE_TASK_JSON + if not task_json.is_file(): + print(f"{Colors.RED}Error: task.json not found at {target_dir_path}{Colors.NC}") + return 1 + + # ============================================================================= + # Main + # ============================================================================= + print(f"{Colors.BLUE}=== Create PR ==={Colors.NC}") + if args.dry_run: + print( + f"{Colors.YELLOW}[DRY-RUN MODE] No actual changes will be made{Colors.NC}" + ) + print() + + # Read task config + task_data = _read_json_file(task_json) + if not task_data: + print(f"{Colors.RED}Error: Failed to read task.json{Colors.NC}") + return 1 + + task_name = task_data.get("name", "") + base_branch = task_data.get("base_branch", "main") + scope = task_data.get("scope", "core") + dev_type = task_data.get("dev_type", "feature") + + # Map dev_type to commit prefix + prefix_map = { + "feature": "feat", + "frontend": "feat", + "backend": "feat", + "fullstack": "feat", + "bugfix": "fix", + "fix": "fix", + "refactor": "refactor", + "docs": "docs", + "test": "test", + } + commit_prefix = prefix_map.get(dev_type, "feat") + + print(f"Task: {task_name}") + print(f"Base branch: {base_branch}") + print(f"Scope: {scope}") + print(f"Commit prefix: {commit_prefix}") + print() + + # Get current branch + _, branch_out, _ = _run_git_command(["branch", "--show-current"]) + current_branch = branch_out.strip() + print(f"Current branch: {current_branch}") + + # Check for changes + print(f"{Colors.YELLOW}Checking for changes...{Colors.NC}") + + # Stage changes + _run_git_command(["add", "-A"]) + + # Exclude workspace and temp files + _run_git_command(["reset", f"{DIR_WORKFLOW}/workspace/"]) + _run_git_command(["reset", ".agent-log", ".session-id"]) + + # Check if there are staged changes + ret, _, _ = _run_git_command(["diff", "--cached", "--quiet"]) + has_staged_changes = ret != 0 + + if not has_staged_changes: + print(f"{Colors.YELLOW}No staged changes to commit{Colors.NC}") + + # Check for unpushed commits + ret, log_out, _ = _run_git_command( + ["log", f"origin/{current_branch}..HEAD", "--oneline"] + ) + unpushed = len([line for line in log_out.splitlines() if line.strip()]) + + if unpushed == 0: + if args.dry_run: + _run_git_command(["reset", "HEAD"]) + print(f"{Colors.RED}No changes to create PR{Colors.NC}") + return 1 + + print(f"Found {unpushed} unpushed commit(s)") + else: + # Commit changes + print(f"{Colors.YELLOW}Committing changes...{Colors.NC}") + commit_msg = f"{commit_prefix}({scope}): {task_name}" + + if args.dry_run: + print(f"[DRY-RUN] Would commit with message: {commit_msg}") + print("[DRY-RUN] Staged files:") + _, staged_out, _ = _run_git_command(["diff", "--cached", "--name-only"]) + for line in staged_out.splitlines(): + print(f" - {line}") + else: + _run_git_command(["commit", "-m", commit_msg]) + print(f"{Colors.GREEN}Committed: {commit_msg}{Colors.NC}") + + # Push to remote + print(f"{Colors.YELLOW}Pushing to remote...{Colors.NC}") + if args.dry_run: + print(f"[DRY-RUN] Would push to: origin/{current_branch}") + else: + ret, _, err = _run_git_command(["push", "-u", "origin", current_branch]) + if ret != 0: + print(f"{Colors.RED}Failed to push: {err}{Colors.NC}") + return 1 + print(f"{Colors.GREEN}Pushed to origin/{current_branch}{Colors.NC}") + + # Create PR + print(f"{Colors.YELLOW}Creating PR...{Colors.NC}") + pr_title = f"{commit_prefix}({scope}): {task_name}" + pr_url = "" + + if args.dry_run: + print("[DRY-RUN] Would create PR:") + print(f" Title: {pr_title}") + print(f" Base: {base_branch}") + print(f" Head: {current_branch}") + prd_file = target_dir_path / "prd.md" + if prd_file.is_file(): + print(" Body: (from prd.md)") + pr_url = "https://github.com/example/repo/pull/DRY-RUN" + else: + # Check if PR already exists + result = subprocess.run( + [ + "gh", + "pr", + "list", + "--head", + current_branch, + "--base", + base_branch, + "--json", + "url", + "--jq", + ".[0].url", + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + existing_pr = result.stdout.strip() + + if existing_pr: + print(f"{Colors.YELLOW}PR already exists: {existing_pr}{Colors.NC}") + pr_url = existing_pr + else: + # Read PRD as PR body + pr_body = "" + prd_file = target_dir_path / "prd.md" + if prd_file.is_file(): + pr_body = prd_file.read_text(encoding="utf-8") + + # Create PR + result = subprocess.run( + [ + "gh", + "pr", + "create", + "--draft", + "--base", + base_branch, + "--title", + pr_title, + "--body", + pr_body, + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + + if result.returncode != 0: + print(f"{Colors.RED}Failed to create PR: {result.stderr}{Colors.NC}") + return 1 + + pr_url = result.stdout.strip() + print(f"{Colors.GREEN}PR created: {pr_url}{Colors.NC}") + + # Update task.json + print(f"{Colors.YELLOW}Updating task status...{Colors.NC}") + if args.dry_run: + print("[DRY-RUN] Would update task.json:") + print(" status: completed") + print(f" pr_url: {pr_url}") + print(" current_phase: (set to create-pr phase)") + else: + # Get the phase number for create-pr action + create_pr_phase = get_phase_for_action(task_json, "create-pr") + if not create_pr_phase: + create_pr_phase = 4 # Default fallback + + task_data["status"] = "completed" + task_data["pr_url"] = pr_url + task_data["current_phase"] = create_pr_phase + + _write_json_file(task_json, task_data) + print( + f"{Colors.GREEN}Task status updated to 'completed', phase {create_pr_phase}{Colors.NC}" + ) + + # In dry-run, reset the staging area + if args.dry_run: + _run_git_command(["reset", "HEAD"]) + + print() + print(f"{Colors.GREEN}=== PR Created Successfully ==={Colors.NC}") + print(f"PR URL: {pr_url}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.trellis/scripts/multi_agent/plan.py b/.trellis/scripts/multi_agent/plan.py new file mode 100755 index 0000000..7ce5e6f --- /dev/null +++ b/.trellis/scripts/multi_agent/plan.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +""" +Multi-Agent Pipeline: Plan Agent Launcher. + +Usage: python3 plan.py --name <task-name> --type <dev-type> --requirement "<requirement>" + +This script: +1. Creates task directory +2. Starts Plan Agent in background +3. Plan Agent produces fully configured task directory + +After completion, use start.py to launch the Dispatch Agent. + +Prerequisites: + - agents/plan.md must exist (in .claude/, .cursor/, .iflow/, or .opencode/) + - Developer must be initialized +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from common.cli_adapter import get_cli_adapter +from common.paths import get_repo_root +from common.developer import ensure_developer + + +# ============================================================================= +# Colors +# ============================================================================= + +class Colors: + RED = "\033[0;31m" + GREEN = "\033[0;32m" + YELLOW = "\033[1;33m" + BLUE = "\033[0;34m" + NC = "\033[0m" + + +def log_info(msg: str) -> None: + print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}") + + +def log_success(msg: str) -> None: + print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}") + + +def log_error(msg: str) -> None: + print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}") + + +# ============================================================================= +# Constants +# ============================================================================= + +DEFAULT_PLATFORM = "claude" + + +# ============================================================================= +# Main +# ============================================================================= + +def main() -> int: + """Main entry point.""" + parser = argparse.ArgumentParser( + description="Multi-Agent Pipeline: Plan Agent Launcher" + ) + parser.add_argument("--name", "-n", required=True, help="Task name (e.g., user-auth)") + parser.add_argument("--type", "-t", required=True, help="Dev type: backend|frontend|fullstack") + parser.add_argument("--requirement", "-r", required=True, help="Requirement description") + parser.add_argument( + "--platform", "-p", + choices=["claude", "cursor", "iflow", "opencode", "qoder"], + default=DEFAULT_PLATFORM, + help="Platform to use (default: claude)" + ) + + args = parser.parse_args() + + task_name = args.name + dev_type = args.type + requirement = args.requirement + platform = args.platform + + # Initialize CLI adapter + adapter = get_cli_adapter(platform) + + # Validate dev type + if dev_type not in ("backend", "frontend", "fullstack"): + log_error(f"Invalid dev type: {dev_type} (must be: backend, frontend, fullstack)") + return 1 + + project_root = get_repo_root() + + # Check plan agent exists (path varies by platform) + plan_md = adapter.get_agent_path("plan", project_root) + if not plan_md.is_file(): + log_error(f"plan agent not found at {plan_md}") + log_info(f"Platform: {platform}") + return 1 + + ensure_developer(project_root) + + # ============================================================================= + # Step 1: Create Task Directory + # ============================================================================= + print() + print(f"{Colors.BLUE}=== Multi-Agent Pipeline: Plan ==={Colors.NC}") + log_info(f"Task: {task_name}") + log_info(f"Type: {dev_type}") + log_info(f"Requirement: {requirement}") + print() + + log_info("Step 1: Creating task directory...") + + # Import task module to create task + from task import cmd_create + import argparse as ap + + # Create task using task.py's create command + create_args = ap.Namespace( + title=requirement, + slug=task_name, + assignee=None, + priority="P2", + description="" + ) + + # Capture stdout to get task dir + import io + from contextlib import redirect_stdout + + stdout_capture = io.StringIO() + with redirect_stdout(stdout_capture): + ret = cmd_create(create_args) + + if ret != 0: + log_error("Failed to create task directory") + return 1 + + task_dir = stdout_capture.getvalue().strip().split("\n")[-1] + task_dir_abs = project_root / task_dir + + log_success(f"Task directory: {task_dir}") + + # ============================================================================= + # Step 2: Prepare and Start Plan Agent + # ============================================================================= + log_info("Step 2: Starting Plan Agent in background...") + + log_file = task_dir_abs / ".plan-log" + log_file.touch() + + # Get proxy environment variables + https_proxy = os.environ.get("https_proxy", "") + http_proxy = os.environ.get("http_proxy", "") + all_proxy = os.environ.get("all_proxy", "") + + # Start agent in background (cross-platform, no shell script needed) + env = os.environ.copy() + env["PLAN_TASK_NAME"] = task_name + env["PLAN_DEV_TYPE"] = dev_type + env["PLAN_TASK_DIR"] = task_dir + env["PLAN_REQUIREMENT"] = requirement + env["https_proxy"] = https_proxy + env["http_proxy"] = http_proxy + env["all_proxy"] = all_proxy + + # Clear nested-session detection so the new CLI process can start + env.pop("CLAUDECODE", None) + + # Set non-interactive env var based on platform + env.update(adapter.get_non_interactive_env()) + + # Build CLI command using adapter + cli_cmd = adapter.build_run_command( + agent="plan", # Will be mapped to "trellis-plan" for OpenCode + prompt=f"Start planning for task: {task_name}", + skip_permissions=True, + verbose=True, + json_output=True, + ) + + with log_file.open("w") as log_f: + # Use shell=False for cross-platform compatibility + # creationflags for Windows, start_new_session for Unix + popen_kwargs = { + "stdout": log_f, + "stderr": subprocess.STDOUT, + "cwd": str(project_root), + "env": env, + } + if sys.platform == "win32": + popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + popen_kwargs["start_new_session"] = True + + process = subprocess.Popen(cli_cmd, **popen_kwargs) + agent_pid = process.pid + + log_success(f"Plan Agent started (PID: {agent_pid})") + + # ============================================================================= + # Summary + # ============================================================================= + print() + print(f"{Colors.GREEN}=== Plan Agent Running ==={Colors.NC}") + print() + print(f" Task: {task_name}") + print(f" Type: {dev_type}") + print(f" Dir: {task_dir}") + print(f" Log: {log_file}") + print(f" PID: {agent_pid}") + print() + print(f"{Colors.YELLOW}To monitor:{Colors.NC}") + print(f" tail -f {log_file}") + print() + print(f"{Colors.YELLOW}To check status:{Colors.NC}") + print(f" ps -p {agent_pid}") + print(f" ls -la {task_dir}") + print() + print(f"{Colors.YELLOW}After completion, run:{Colors.NC}") + print(f" python3 ./.trellis/scripts/multi_agent/start.py {task_dir}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.trellis/scripts/multi_agent/start.py b/.trellis/scripts/multi_agent/start.py new file mode 100755 index 0000000..40c2747 --- /dev/null +++ b/.trellis/scripts/multi_agent/start.py @@ -0,0 +1,465 @@ +#!/usr/bin/env python3 +""" +Multi-Agent Pipeline: Start Worktree Agent. + +Usage: python3 start.py <task-dir> +Example: python3 start.py .trellis/tasks/01-21-my-task + +This script: +1. Creates worktree (if not exists) with dependency install +2. Copies environment files (from worktree.yaml config) +3. Sets .current-task in worktree +4. Starts claude agent in background +5. Registers agent to registry.json + +Prerequisites: + - task.json must exist with 'branch' field + - agents/dispatch.md must exist (in .claude/, .cursor/, .iflow/, or .opencode/) + +Configuration: .trellis/worktree.yaml +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import uuid +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from common.cli_adapter import CLIAdapter, get_cli_adapter +from common.git_context import _run_git_command +from common.paths import ( + DIR_WORKFLOW, + FILE_CURRENT_TASK, + FILE_TASK_JSON, + get_repo_root, +) +from common.registry import ( + registry_add_agent, + registry_get_file, +) +from common.worktree import ( + get_worktree_base_dir, + get_worktree_config, + get_worktree_copy_files, + get_worktree_post_create_hooks, +) + +# ============================================================================= +# Colors +# ============================================================================= + + +class Colors: + RED = "\033[0;31m" + GREEN = "\033[0;32m" + YELLOW = "\033[1;33m" + BLUE = "\033[0;34m" + NC = "\033[0m" + + +def log_info(msg: str) -> None: + print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}") + + +def log_success(msg: str) -> None: + print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}") + + +def log_warn(msg: str) -> None: + print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}") + + +def log_error(msg: str) -> None: + print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}") + + +# ============================================================================= +# Helper Functions +# ============================================================================= + + +def _read_json_file(path: Path) -> dict | None: + """Read and parse a JSON file.""" + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + + +def _write_json_file(path: Path, data: dict) -> bool: + """Write dict to JSON file.""" + try: + path.write_text( + json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8" + ) + return True + except (OSError, IOError): + return False + + +# ============================================================================= +# Constants +# ============================================================================= + +DEFAULT_PLATFORM = "claude" + + +# ============================================================================= +# Main +# ============================================================================= + + +def main() -> int: + """Main entry point.""" + import argparse + + parser = argparse.ArgumentParser(description="Multi-Agent Pipeline: Start Worktree Agent") + parser.add_argument("task_dir", help="Task directory path") + parser.add_argument( + "--platform", "-p", + choices=["claude", "cursor", "iflow", "opencode", "qoder"], + default=DEFAULT_PLATFORM, + help="Platform to use (default: claude)" + ) + + args = parser.parse_args() + task_dir_arg = args.task_dir + platform = args.platform + + # Initialize CLI adapter + adapter = get_cli_adapter(platform) + + project_root = get_repo_root() + + # Normalize paths + if task_dir_arg.startswith("/"): + task_dir_relative = task_dir_arg[len(str(project_root)) + 1 :] + task_dir_abs = Path(task_dir_arg) + else: + task_dir_relative = task_dir_arg + task_dir_abs = project_root / task_dir_arg + + task_json_path = task_dir_abs / FILE_TASK_JSON + + # ============================================================================= + # Validation + # ============================================================================= + if not task_json_path.is_file(): + log_error(f"task.json not found at {task_json_path}") + return 1 + + dispatch_md = adapter.get_agent_path("dispatch", project_root) + if not dispatch_md.is_file(): + log_error(f"dispatch.md not found at {dispatch_md}") + log_info(f"Platform: {platform}") + return 1 + + config_file = get_worktree_config(project_root) + if not config_file.is_file(): + log_error(f"worktree.yaml not found at {config_file}") + return 1 + + # ============================================================================= + # Read Task Config + # ============================================================================= + print() + print(f"{Colors.BLUE}=== Multi-Agent Pipeline: Start ==={Colors.NC}") + log_info(f"Task: {task_dir_abs}") + + task_data = _read_json_file(task_json_path) + if not task_data: + log_error("Failed to read task.json") + return 1 + + branch = task_data.get("branch") + task_name = task_data.get("name") + task_status = task_data.get("status") + worktree_path = task_data.get("worktree_path") + + # Check if task was rejected + if task_status == "rejected": + log_error("Task was rejected by Plan Agent") + rejected_file = task_dir_abs / "REJECTED.md" + if rejected_file.is_file(): + print() + print(f"{Colors.YELLOW}Rejection reason:{Colors.NC}") + print(rejected_file.read_text(encoding="utf-8")) + print() + log_info( + "To retry, delete this directory and run plan.py again with revised requirements" + ) + return 1 + + # Check if prd.md exists (plan completed successfully) + prd_file = task_dir_abs / "prd.md" + if not prd_file.is_file(): + log_error("prd.md not found - Plan Agent may not have completed") + log_info(f"Check plan log: {task_dir_abs}/.plan-log") + return 1 + + if not branch: + log_error("branch field not set in task.json") + log_info("Please set branch field first, e.g.:") + log_info( + " jq '.branch = \"task/my-task\"' task.json > tmp && mv tmp task.json" + ) + return 1 + + log_info(f"Branch: {branch}") + log_info(f"Name: {task_name}") + + # ============================================================================= + # Step 1: Create Worktree (if not exists) + # ============================================================================= + if not worktree_path or not Path(worktree_path).is_dir(): + log_info("Step 1: Creating worktree...") + + # Record current branch as base_branch (PR target) + _, base_branch_out, _ = _run_git_command( + ["branch", "--show-current"], cwd=project_root + ) + base_branch = base_branch_out.strip() + log_info(f"Base branch (PR target): {base_branch}") + + # Calculate worktree path + worktree_base = get_worktree_base_dir(project_root) + worktree_base.mkdir(parents=True, exist_ok=True) + worktree_base = worktree_base.resolve() + worktree_path_obj = worktree_base / branch + worktree_path = str(worktree_path_obj) + + # Create parent directory + worktree_path_obj.parent.mkdir(parents=True, exist_ok=True) + + # Create branch if not exists + ret, _, _ = _run_git_command( + ["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"], + cwd=project_root, + ) + if ret == 0: + log_info("Branch exists, checking out...") + ret, _, err = _run_git_command( + ["worktree", "add", worktree_path, branch], cwd=project_root + ) + else: + log_info(f"Creating new branch: {branch}") + ret, _, err = _run_git_command( + ["worktree", "add", "-b", branch, worktree_path], cwd=project_root + ) + + if ret != 0: + log_error(f"Failed to create worktree: {err}") + return 1 + + log_success(f"Worktree created: {worktree_path}") + + # Update task.json with worktree_path and base_branch + task_data["worktree_path"] = worktree_path + task_data["base_branch"] = base_branch + _write_json_file(task_json_path, task_data) + + # ----- Copy environment files ----- + log_info("Copying environment files...") + copy_list = get_worktree_copy_files(project_root) + copy_count = 0 + + for item in copy_list: + if not item: + continue + + source = project_root / item + target = Path(worktree_path) / item + + if source.is_file(): + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(str(source), str(target)) + copy_count += 1 + + if copy_count > 0: + log_success(f"Copied {copy_count} file(s)") + + # ----- Copy task directory (may not be committed yet) ----- + log_info("Copying task directory...") + task_target_dir = Path(worktree_path) / task_dir_relative + task_target_dir.parent.mkdir(parents=True, exist_ok=True) + if task_target_dir.exists(): + shutil.rmtree(str(task_target_dir)) + shutil.copytree(str(task_dir_abs), str(task_target_dir)) + log_success("Task directory copied to worktree") + + # ----- Run post_create hooks ----- + log_info("Running post_create hooks...") + post_create = get_worktree_post_create_hooks(project_root) + hook_count = 0 + + for cmd in post_create: + if not cmd: + continue + + log_info(f" Running: {cmd}") + ret = subprocess.run(cmd, shell=True, cwd=worktree_path) + if ret.returncode != 0: + log_error(f"Hook failed: {cmd}") + return 1 + hook_count += 1 + + if hook_count > 0: + log_success(f"Ran {hook_count} hook(s)") + else: + log_info(f"Step 1: Using existing worktree: {worktree_path}") + + # ============================================================================= + # Step 2: Set .current-task in Worktree + # ============================================================================= + log_info("Step 2: Setting current task in worktree...") + + worktree_workflow_dir = Path(worktree_path) / DIR_WORKFLOW + worktree_workflow_dir.mkdir(parents=True, exist_ok=True) + + current_task_file = worktree_workflow_dir / FILE_CURRENT_TASK + current_task_file.write_text(task_dir_relative, encoding="utf-8") + log_success(f"Current task set: {task_dir_relative}") + + # ============================================================================= + # Step 3: Prepare and Start Claude Agent + # ============================================================================= + log_info(f"Step 3: Starting {adapter.cli_name} agent...") + + # Update task status + task_data["status"] = "in_progress" + _write_json_file(task_json_path, task_data) + + log_file = Path(worktree_path) / ".agent-log" + session_id_file = Path(worktree_path) / ".session-id" + + log_file.touch() + + # Generate session ID for resume support (Claude Code only) + # OpenCode generates its own session ID, we'll extract it from logs later + if adapter.supports_session_id_on_create: + session_id = str(uuid.uuid4()).lower() + session_id_file.write_text(session_id, encoding="utf-8") + log_info(f"Session ID: {session_id}") + else: + session_id = None # Will be extracted from logs after startup + log_info("Session ID will be extracted from logs after startup") + + # Get proxy environment variables + https_proxy = os.environ.get("https_proxy", "") + http_proxy = os.environ.get("http_proxy", "") + all_proxy = os.environ.get("all_proxy", "") + + # Start agent in background (cross-platform, no shell script needed) + env = os.environ.copy() + env["https_proxy"] = https_proxy + env["http_proxy"] = http_proxy + env["all_proxy"] = all_proxy + + # Clear nested-session detection so the new CLI process can start + # (when this script runs inside a Claude Code session, CLAUDECODE=1 is inherited) + env.pop("CLAUDECODE", None) + + # Set non-interactive env var based on platform + env.update(adapter.get_non_interactive_env()) + + # Build CLI command using adapter + # Note: Use explicit prompt to avoid confusion with CI/CD pipelines + # Also remind the model to follow its agent definition for better cross-model compatibility + cli_cmd = adapter.build_run_command( + agent="dispatch", + prompt="Follow your agent instructions to execute the task workflow. Start by reading .trellis/.current-task to get the task directory, then execute each action in task.json next_action array in order.", + session_id=session_id if adapter.supports_session_id_on_create else None, + skip_permissions=True, + verbose=True, + json_output=True, + ) + + with log_file.open("w") as log_f: + # Use shell=False for cross-platform compatibility + # creationflags for Windows, start_new_session for Unix + popen_kwargs = { + "stdout": log_f, + "stderr": subprocess.STDOUT, + "cwd": worktree_path, + "env": env, + } + if sys.platform == "win32": + popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + popen_kwargs["start_new_session"] = True + + process = subprocess.Popen(cli_cmd, **popen_kwargs) + agent_pid = process.pid + + log_success(f"Agent started with PID: {agent_pid}") + + # For OpenCode: extract session ID from logs after startup + if not adapter.supports_session_id_on_create: + import time + log_info("Waiting for session ID from logs...") + # Wait a bit for the log to have session ID + for _ in range(10): # Try for up to 5 seconds + time.sleep(0.5) + try: + log_content = log_file.read_text(encoding="utf-8", errors="replace") + session_id = adapter.extract_session_id_from_log(log_content) + if session_id: + session_id_file.write_text(session_id, encoding="utf-8") + log_success(f"Session ID extracted: {session_id}") + break + except Exception: + pass + else: + log_warn("Could not extract session ID from logs") + session_id = "unknown" + + # ============================================================================= + # Step 4: Register to Registry (in main repo, not worktree) + # ============================================================================= + log_info("Step 4: Registering agent to registry...") + + # Generate agent ID + task_id = task_data.get("id") + if not task_id: + task_id = branch.replace("/", "-") + + registry_add_agent( + task_id, worktree_path, agent_pid, task_dir_relative, project_root, platform + ) + + log_success(f"Agent registered: {task_id}") + + # ============================================================================= + # Summary + # ============================================================================= + print() + print(f"{Colors.GREEN}=== Agent Started ==={Colors.NC}") + print() + print(f" ID: {task_id}") + print(f" PID: {agent_pid}") + print(f" Session: {session_id}") + print(f" Worktree: {worktree_path}") + print(f" Task: {task_dir_relative}") + print(f" Log: {log_file}") + print(f" Registry: {registry_get_file(project_root)}") + print() + print(f"{Colors.YELLOW}To monitor:{Colors.NC} tail -f {log_file}") + print(f"{Colors.YELLOW}To stop:{Colors.NC} kill {agent_pid}") + if session_id and session_id != "unknown": + resume_cmd = adapter.get_resume_command_str(session_id, cwd=worktree_path) + print(f"{Colors.YELLOW}To resume:{Colors.NC} {resume_cmd}") + else: + print(f"{Colors.YELLOW}To resume:{Colors.NC} (session ID not available)") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.trellis/scripts/multi_agent/status.py b/.trellis/scripts/multi_agent/status.py new file mode 100755 index 0000000..e83ac60 --- /dev/null +++ b/.trellis/scripts/multi_agent/status.py @@ -0,0 +1,817 @@ +#!/usr/bin/env python3 +""" +Multi-Agent Pipeline: Status Monitor. + +Usage: + python3 status.py Show summary of all tasks (default) + python3 status.py -a <assignee> Filter tasks by assignee + python3 status.py --list List all worktrees and agents + python3 status.py --detail <task> Detailed task status + python3 status.py --watch <task> Watch agent log in real-time + python3 status.py --log <task> Show recent log entries + python3 status.py --registry Show agent registry +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import time +from datetime import datetime +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from common.cli_adapter import get_cli_adapter +from common.developer import ensure_developer +from common.paths import ( + FILE_TASK_JSON, + get_repo_root, + get_tasks_dir, +) +from common.phase import get_phase_info +from common.task_queue import format_task_stats, get_task_stats +from common.worktree import get_agents_dir + +# ============================================================================= +# Colors +# ============================================================================= + + +class Colors: + 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" + + +# ============================================================================= +# Helper Functions +# ============================================================================= + + +def _read_json_file(path: Path) -> dict | None: + """Read and parse a JSON file.""" + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + + +def is_running(pid: int | str | None) -> bool: + """Check if PID is running.""" + if not pid: + return False + try: + pid_int = int(pid) + os.kill(pid_int, 0) + return True + except (ProcessLookupError, ValueError, PermissionError, TypeError): + return False + + +def status_color(status: str) -> str: + """Get status color.""" + colors = { + "completed": Colors.GREEN, + "in_progress": Colors.BLUE, + "planning": Colors.YELLOW, + } + return colors.get(status, Colors.DIM) + + +def get_registry_file(repo_root: Path) -> Path | None: + """Get registry file path.""" + agents_dir = get_agents_dir(repo_root) + if agents_dir: + return agents_dir / "registry.json" + return None + + +def find_agent(search: str, repo_root: Path) -> dict | None: + """Find agent by task name or ID.""" + registry_file = get_registry_file(repo_root) + if not registry_file or not registry_file.is_file(): + return None + + data = _read_json_file(registry_file) + if not data: + return None + + for agent in data.get("agents", []): + # Exact ID match + if agent.get("id") == search: + return agent + # Partial match on task_dir + task_dir = agent.get("task_dir", "") + if search in task_dir: + return agent + + return None + + +def calc_elapsed(started: str | None) -> str: + """Calculate elapsed time from ISO timestamp.""" + if not started: + return "N/A" + + try: + # Parse ISO format + if "+" in started: + started = started.split("+")[0] + if "T" in started: + start_dt = datetime.fromisoformat(started) + else: + return "N/A" + + now = datetime.now() + elapsed = (now - start_dt).total_seconds() + + if elapsed < 60: + return f"{int(elapsed)}s" + elif elapsed < 3600: + mins = int(elapsed // 60) + secs = int(elapsed % 60) + return f"{mins}m {secs}s" + else: + hours = int(elapsed // 3600) + mins = int((elapsed % 3600) // 60) + return f"{hours}h {mins}m" + except (ValueError, TypeError): + return "N/A" + + +def count_modified_files(worktree: str) -> int: + """Count modified files in worktree.""" + if not Path(worktree).is_dir(): + return 0 + + try: + result = subprocess.run( + ["git", "status", "--short"], + cwd=worktree, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + return len([line for line in result.stdout.splitlines() if line.strip()]) + except Exception: + return 0 + + +def tail_follow(file_path: Path) -> None: + """Follow a file like 'tail -f', cross-platform compatible.""" + with open(file_path, "r", encoding="utf-8", errors="replace") as f: + # Seek to end of file + f.seek(0, 2) + + while True: + line = f.readline() + if line: + print(line, end="", flush=True) + else: + time.sleep(0.1) + + +def get_last_tool(log_file: Path, platform: str = "claude") -> str | None: + """Get the last tool call from agent log. + + Supports both Claude Code and OpenCode log formats. + + Claude Code format: + {"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Read"}]}} + + OpenCode format: + {"type": "tool_use", "tool": "bash", "state": {"status": "completed"}} + """ + if not log_file.is_file(): + return None + + try: + lines = log_file.read_text(encoding="utf-8").splitlines() + for line in reversed(lines[-100:]): + try: + data = json.loads(line) + + if platform == "opencode": + # OpenCode format: {"type": "tool_use", "tool": "bash", ...} + if data.get("type") == "tool_use": + return data.get("tool") + else: + # Claude Code format: {"type": "assistant", "message": {"content": [...]}} + if data.get("type") == "assistant": + content = data.get("message", {}).get("content", []) + for item in content: + if item.get("type") == "tool_use": + return item.get("name") + except json.JSONDecodeError: + continue + except Exception: + pass + return None + + +def get_last_message(log_file: Path, max_len: int = 100, platform: str = "claude") -> str | None: + """Get the last assistant text from agent log. + + Supports both Claude Code and OpenCode log formats. + + Claude Code format: + {"type": "assistant", "message": {"content": [{"type": "text", "text": "..."}]}} + + OpenCode format: + {"type": "text", "text": "..."} + """ + if not log_file.is_file(): + return None + + try: + lines = log_file.read_text(encoding="utf-8").splitlines() + for line in reversed(lines[-100:]): + try: + data = json.loads(line) + + if platform == "opencode": + # OpenCode format: {"type": "text", "text": "..."} + if data.get("type") == "text": + text = data.get("text", "") + if text: + return text[:max_len] + else: + # Claude Code format: {"type": "assistant", "message": {"content": [...]}} + if data.get("type") == "assistant": + content = data.get("message", {}).get("content", []) + for item in content: + if item.get("type") == "text": + text = item.get("text", "") + if text: + return text[:max_len] + except json.JSONDecodeError: + continue + except Exception: + pass + return None + + +# ============================================================================= +# Commands +# ============================================================================= + + +def cmd_help() -> int: + """Show help.""" + print("""Multi-Agent Pipeline: Status Monitor + +Usage: + python3 status.py Show summary of all tasks + python3 status.py -a <assignee> Filter tasks by assignee + python3 status.py --list List all worktrees and agents + python3 status.py --detail <task> Detailed task status + python3 status.py --progress <task> Quick progress view with recent activity + python3 status.py --watch <task> Watch agent log in real-time + python3 status.py --log <task> Show recent log entries + python3 status.py --registry Show agent registry + +Examples: + python3 status.py -a taosu + python3 status.py --detail my-task + python3 status.py --progress my-task + python3 status.py --watch 01-16-worktree-support + python3 status.py --log worktree-support +""") + return 0 + + +def cmd_list(repo_root: Path) -> int: + """List worktrees and agents.""" + print(f"{Colors.BLUE}=== Git Worktrees ==={Colors.NC}") + print() + + subprocess.run(["git", "worktree", "list"], cwd=repo_root) + print() + + print(f"{Colors.BLUE}=== Registered Agents ==={Colors.NC}") + print() + + registry_file = get_registry_file(repo_root) + if not registry_file or not registry_file.is_file(): + print(" (no registry found)") + return 0 + + data = _read_json_file(registry_file) + if not data or not data.get("agents"): + print(" (no agents registered)") + return 0 + + for agent in data["agents"]: + agent_id = agent.get("id", "?") + pid = agent.get("pid") + wt = agent.get("worktree_path", "?") + started = agent.get("started_at", "?") + + if is_running(pid): + status_icon = f"{Colors.GREEN}●{Colors.NC}" + else: + status_icon = f"{Colors.RED}○{Colors.NC}" + + print(f" {status_icon} {agent_id} (PID: {pid})") + print(f" {Colors.DIM}Worktree: {wt}{Colors.NC}") + print(f" {Colors.DIM}Started: {started}{Colors.NC}") + print() + + return 0 + + +def cmd_summary(repo_root: Path, filter_assignee: str | None = None) -> int: + """Show summary of all tasks.""" + ensure_developer(repo_root) + + tasks_dir = get_tasks_dir(repo_root) + if not tasks_dir.is_dir(): + print("No tasks directory found") + return 0 + + registry_file = get_registry_file(repo_root) + + # Count running agents + running_count = 0 + total_agents = 0 + + if registry_file and registry_file.is_file(): + data = _read_json_file(registry_file) + if data: + agents = data.get("agents", []) + total_agents = len(agents) + for agent in agents: + if is_running(agent.get("pid")): + running_count += 1 + + # Task queue stats + task_stats = get_task_stats(repo_root) + + print(f"{Colors.BLUE}=== Multi-Agent Status ==={Colors.NC}") + print( + f" Agents: {Colors.GREEN}{running_count}{Colors.NC} running / {total_agents} registered" + ) + print(f" Tasks: {format_task_stats(task_stats)}") + print() + + # Process tasks + running_tasks = [] + stopped_tasks = [] + regular_tasks = [] + + registry_data = ( + _read_json_file(registry_file) + if registry_file and registry_file.is_file() + else None + ) + + for d in sorted(tasks_dir.iterdir()): + if not d.is_dir() or d.name == "archive": + continue + + name = d.name + task_json = d / FILE_TASK_JSON + status = "unknown" + assignee = "unassigned" + priority = "P2" + + if task_json.is_file(): + data = _read_json_file(task_json) + if data: + status = data.get("status", "unknown") + assignee = data.get("assignee", "unassigned") + priority = data.get("priority", "P2") + + # Filter by assignee + if filter_assignee and assignee != filter_assignee: + continue + + # Check agent status + agent_info = None + if registry_data: + for agent in registry_data.get("agents", []): + if name in agent.get("task_dir", ""): + agent_info = agent + break + + if agent_info: + pid = agent_info.get("pid") + worktree = agent_info.get("worktree_path", "") + started = agent_info.get("started_at") + agent_platform = agent_info.get("platform", "claude") + + if is_running(pid): + # Running agent + task_dir_rel = agent_info.get("task_dir", "") + worktree_task_json = Path(worktree) / task_dir_rel / "task.json" + phase_source = task_json + if worktree_task_json.is_file(): + phase_source = worktree_task_json + + phase_info_str = get_phase_info(phase_source) + elapsed = calc_elapsed(started) + modified = count_modified_files(worktree) + + worktree_data = _read_json_file(phase_source) + branch = worktree_data.get("branch", "N/A") if worktree_data else "N/A" + + log_file = Path(worktree) / ".agent-log" + last_tool = get_last_tool(log_file, platform=agent_platform) + + running_tasks.append( + { + "name": name, + "priority": priority, + "assignee": assignee, + "phase_info": phase_info_str, + "elapsed": elapsed, + "branch": branch, + "modified": modified, + "last_tool": last_tool, + "pid": pid, + } + ) + else: + # Stopped agent + task_dir_rel = agent_info.get("task_dir", "") + worktree_task_json = Path(worktree) / task_dir_rel / "task.json" + worktree_status = "unknown" + + if worktree_task_json.is_file(): + wt_data = _read_json_file(worktree_task_json) + if wt_data: + worktree_status = wt_data.get("status", "unknown") + + session_id_file = Path(worktree) / ".session-id" + log_file = Path(worktree) / ".agent-log" + + stopped_tasks.append( + { + "name": name, + "worktree": worktree, + "status": worktree_status, + "session_id_file": session_id_file, + "log_file": log_file, + "platform": agent_info.get("platform", "claude"), + } + ) + else: + # Regular task + regular_tasks.append( + { + "name": name, + "status": status, + "priority": priority, + "assignee": assignee, + } + ) + + # Output running agents + if running_tasks: + print(f"{Colors.CYAN}Running Agents:{Colors.NC}") + for t in running_tasks: + priority_color = ( + Colors.RED + if t["priority"] == "P0" + else (Colors.YELLOW if t["priority"] == "P1" else Colors.BLUE) + ) + print( + f"{Colors.GREEN}▶{Colors.NC} {Colors.CYAN}{t['name']}{Colors.NC} {Colors.GREEN}[running]{Colors.NC} {priority_color}[{t['priority']}]{Colors.NC} @{t['assignee']}" + ) + print(f" Phase: {t['phase_info']}") + print(f" Elapsed: {t['elapsed']}") + print(f" Branch: {Colors.DIM}{t['branch']}{Colors.NC}") + print(f" Modified: {t['modified']} file(s)") + if t["last_tool"]: + print(f" Activity: {Colors.YELLOW}{t['last_tool']}{Colors.NC}") + print(f" PID: {Colors.DIM}{t['pid']}{Colors.NC}") + print() + + # Output stopped agents + if stopped_tasks: + print(f"{Colors.RED}Stopped Agents:{Colors.NC}") + for t in stopped_tasks: + if t["status"] == "completed": + print( + f"{Colors.GREEN}✓{Colors.NC} {t['name']} {Colors.GREEN}[completed]{Colors.NC}" + ) + else: + if t["session_id_file"].is_file(): + session_id = ( + t["session_id_file"].read_text(encoding="utf-8").strip() + ) + last_msg = get_last_message(t["log_file"], 150, platform=t.get("platform", "claude")) + print( + f"{Colors.RED}○{Colors.NC} {t['name']} {Colors.RED}[stopped]{Colors.NC}" + ) + if last_msg: + print(f'{Colors.DIM}"{last_msg}"{Colors.NC}') + # Use CLI adapter for platform-specific resume command + adapter = get_cli_adapter(t.get("platform", "claude")) + resume_cmd = adapter.get_resume_command_str(session_id, cwd=t["worktree"]) + print(f"{Colors.YELLOW}{resume_cmd}{Colors.NC}") + else: + print( + f"{Colors.RED}○{Colors.NC} {t['name']} {Colors.RED}[stopped]{Colors.NC} {Colors.DIM}(no session-id){Colors.NC}" + ) + print() + + # Separator + if (running_tasks or stopped_tasks) and regular_tasks: + print(f"{Colors.DIM}───────────────────────────────────────{Colors.NC}") + print() + + # Output regular tasks grouped by assignee + if regular_tasks: + # Sort by assignee, priority, status + regular_tasks.sort( + key=lambda x: ( + x["assignee"], + {"P0": 0, "P1": 1, "P2": 2, "P3": 3}.get(x["priority"], 2), + {"in_progress": 0, "planning": 1, "completed": 2}.get(x["status"], 1), + ) + ) + + current_assignee = None + for t in regular_tasks: + if t["assignee"] != current_assignee: + if current_assignee is not None: + print() + print(f"{Colors.CYAN}@{t['assignee']}:{Colors.NC}") + current_assignee = t["assignee"] + + color = status_color(t["status"]) + priority_color = ( + Colors.RED + if t["priority"] == "P0" + else (Colors.YELLOW if t["priority"] == "P1" else Colors.BLUE) + ) + print( + f" {color}●{Colors.NC} {t['name']} ({t['status']}) {priority_color}[{t['priority']}]{Colors.NC}" + ) + + if running_tasks: + print() + print(f"{Colors.DIM}─────────────────────────────────────{Colors.NC}") + print(f"{Colors.DIM}Use --progress <name> for quick activity view{Colors.NC}") + print(f"{Colors.DIM}Use --detail <name> for more info{Colors.NC}") + + print() + return 0 + + +def cmd_detail(target: str, repo_root: Path) -> int: + """Show detailed task status.""" + agent = find_agent(target, repo_root) + if not agent: + print(f"Agent not found: {target}") + return 1 + + agent_id = agent.get("id", "?") + pid = agent.get("pid") + worktree = agent.get("worktree_path", "?") + task_dir = agent.get("task_dir", "?") + started = agent.get("started_at", "?") + platform = agent.get("platform", "claude") + + # Check for session-id + session_id = "" + session_id_file = Path(worktree) / ".session-id" + if session_id_file.is_file(): + session_id = session_id_file.read_text(encoding="utf-8").strip() + + print(f"{Colors.BLUE}=== Agent Detail: {agent_id} ==={Colors.NC}") + print() + print(f" ID: {agent_id}") + print(f" PID: {pid}") + print(f" Session: {session_id or 'N/A'}") + print(f" Worktree: {worktree}") + print(f" Task Dir: {task_dir}") + print(f" Started: {started}") + print() + + # Status + if is_running(pid): + print(f" Status: {Colors.GREEN}Running{Colors.NC}") + else: + print(f" Status: {Colors.RED}Stopped{Colors.NC}") + if session_id: + print() + # Use CLI adapter for platform-specific resume command + adapter = get_cli_adapter(platform) + resume_cmd = adapter.get_resume_command_str(session_id, cwd=worktree) + print(f" {Colors.YELLOW}Resume:{Colors.NC} {resume_cmd}") + + # Task info + task_json = repo_root / task_dir / "task.json" + if task_json.is_file(): + print() + print(f"{Colors.BLUE}=== Task Info ==={Colors.NC}") + print() + data = _read_json_file(task_json) + if data: + print(f" Status: {data.get('status', 'unknown')}") + print(f" Branch: {data.get('branch', 'N/A')}") + print(f" Base Branch: {data.get('base_branch', 'N/A')}") + + # Git changes + if Path(worktree).is_dir(): + print() + print(f"{Colors.BLUE}=== Git Changes ==={Colors.NC}") + print() + + result = subprocess.run( + ["git", "status", "--short"], + cwd=worktree, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + changes = result.stdout.strip() + if changes: + for line in changes.splitlines()[:10]: + print(f" {line}") + total = len(changes.splitlines()) + if total > 10: + print(f" ... and {total - 10} more") + else: + print(" (no changes)") + + print() + return 0 + + +def cmd_watch(target: str, repo_root: Path) -> int: + """Watch agent log in real-time.""" + agent = find_agent(target, repo_root) + if not agent: + print(f"Agent not found: {target}") + return 1 + + worktree = agent.get("worktree_path", "") + log_file = Path(worktree) / ".agent-log" + + if not log_file.is_file(): + print(f"Log file not found: {log_file}") + return 1 + + print(f"{Colors.BLUE}Watching:{Colors.NC} {log_file}") + print(f"{Colors.DIM}Press Ctrl+C to stop{Colors.NC}") + print() + + try: + tail_follow(log_file) + except KeyboardInterrupt: + print() # Clean newline after Ctrl+C + return 0 + + +def cmd_log(target: str, repo_root: Path) -> int: + """Show recent log entries.""" + agent = find_agent(target, repo_root) + if not agent: + print(f"Agent not found: {target}") + return 1 + + worktree = agent.get("worktree_path", "") + platform = agent.get("platform", "claude") + log_file = Path(worktree) / ".agent-log" + + if not log_file.is_file(): + print(f"Log file not found: {log_file}") + return 1 + + print(f"{Colors.BLUE}=== Recent Log: {target} ==={Colors.NC}") + print(f"{Colors.DIM}Platform: {platform}{Colors.NC}") + print() + + lines = log_file.read_text(encoding="utf-8").splitlines() + for line in lines[-50:]: + try: + data = json.loads(line) + msg_type = data.get("type", "") + + if platform == "opencode": + # OpenCode format + if msg_type == "text": + text = data.get("text", "") + if text: + display = text[:300] + if len(text) > 300: + display += "..." + print(f"{Colors.BLUE}[TEXT]{Colors.NC} {display}") + elif msg_type == "tool_use": + tool_name = data.get("tool", "unknown") + status = data.get("state", {}).get("status", "") + print(f"{Colors.YELLOW}[TOOL]{Colors.NC} {tool_name} ({status})") + elif msg_type == "step_start": + print(f"{Colors.CYAN}[STEP]{Colors.NC} Start") + elif msg_type == "step_finish": + reason = data.get("reason", "") + print(f"{Colors.CYAN}[STEP]{Colors.NC} Finish ({reason})") + elif msg_type == "error": + error_msg = data.get("message", "") + print(f"{Colors.RED}[ERROR]{Colors.NC} {error_msg}") + else: + # Claude Code format + if msg_type == "system": + subtype = data.get("subtype", "") + print(f"{Colors.CYAN}[SYSTEM]{Colors.NC} {subtype}") + elif msg_type == "user": + content = data.get("message", {}).get("content", "") + if content: + print(f"{Colors.GREEN}[USER]{Colors.NC} {content[:200]}") + elif msg_type == "assistant": + content = data.get("message", {}).get("content", []) + if content: + item = content[0] + text = item.get("text") + tool = item.get("name") + if text: + display = text[:300] + if len(text) > 300: + display += "..." + print(f"{Colors.BLUE}[ASSISTANT]{Colors.NC} {display}") + elif tool: + print(f"{Colors.YELLOW}[TOOL]{Colors.NC} {tool}") + elif msg_type == "result": + tool_name = data.get("tool", "unknown") + print(f"{Colors.DIM}[RESULT]{Colors.NC} {tool_name} completed") + except json.JSONDecodeError: + continue + + return 0 + + +def cmd_registry(repo_root: Path) -> int: + """Show agent registry.""" + registry_file = get_registry_file(repo_root) + + print(f"{Colors.BLUE}=== Agent Registry ==={Colors.NC}") + print() + print(f"File: {registry_file}") + print() + + if registry_file and registry_file.is_file(): + data = _read_json_file(registry_file) + if data: + print(json.dumps(data, indent=2)) + else: + print("(registry not found)") + + return 0 + + +# ============================================================================= +# Main +# ============================================================================= + + +def main() -> int: + """Main entry point.""" + parser = argparse.ArgumentParser(description="Multi-Agent Pipeline: Status Monitor") + parser.add_argument("-a", "--assignee", help="Filter by assignee") + parser.add_argument( + "--list", action="store_true", help="List all worktrees and agents" + ) + parser.add_argument("--detail", metavar="TASK", help="Detailed task status") + parser.add_argument("--progress", metavar="TASK", help="Quick progress view") + parser.add_argument("--watch", metavar="TASK", help="Watch agent log") + parser.add_argument("--log", metavar="TASK", help="Show recent log entries") + parser.add_argument("--registry", action="store_true", help="Show agent registry") + parser.add_argument("target", nargs="?", help="Target task") + + args = parser.parse_args() + repo_root = get_repo_root() + + if args.list: + return cmd_list(repo_root) + elif args.detail: + return cmd_detail(args.detail, repo_root) + elif args.progress: + return cmd_detail(args.progress, repo_root) # Similar to detail + elif args.watch: + return cmd_watch(args.watch, repo_root) + elif args.log: + return cmd_log(args.log, repo_root) + elif args.registry: + return cmd_registry(repo_root) + elif args.target: + return cmd_detail(args.target, repo_root) + else: + return cmd_summary(repo_root, args.assignee) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.trellis/scripts/task.py b/.trellis/scripts/task.py new file mode 100755 index 0000000..29f614c --- /dev/null +++ b/.trellis/scripts/task.py @@ -0,0 +1,1370 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Task Management Script for Multi-Agent Pipeline. + +Usage: + python3 task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] + python3 task.py init-context <dir> <type> # Initialize jsonl files + 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 as current task + python3 task.py finish # Clear current 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 create-pr [dir] [--dry-run] # Create PR from task + python3 task.py archive <task-name> # 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 sys + +# IMPORTANT: Force stdout to use UTF-8 on Windows +# This fixes UnicodeEncodeError when outputting non-ASCII characters +if sys.platform == "win32": + import io as _io + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + elif hasattr(sys.stdout, "detach"): + sys.stdout = _io.TextIOWrapper(sys.stdout.detach(), encoding="utf-8", errors="replace") # type: ignore[union-attr] + +import argparse +import json +import re +import sys +from datetime import datetime +from pathlib import Path + +from common.cli_adapter import get_cli_adapter_auto +from common.git_context import _run_git_command +from common.paths import ( + DIR_WORKFLOW, + DIR_TASKS, + DIR_SPEC, + DIR_ARCHIVE, + FILE_TASK_JSON, + get_repo_root, + get_developer, + get_tasks_dir, + get_current_task, + set_current_task, + clear_current_task, + generate_task_date_prefix, +) +from common.task_utils import ( + find_task_by_name, + archive_task_complete, +) +from common.config import get_hooks + + +# ============================================================================= +# Colors +# ============================================================================= + +class Colors: + RED = "\033[0;31m" + GREEN = "\033[0;32m" + YELLOW = "\033[1;33m" + BLUE = "\033[0;34m" + CYAN = "\033[0;36m" + NC = "\033[0m" + + +def colored(text: str, color: str) -> str: + """Apply color to text.""" + return f"{color}{text}{Colors.NC}" + + +# ============================================================================= +# Lifecycle Hooks +# ============================================================================= + +def _run_hooks(event: str, task_json_path: Path, repo_root: Path) -> None: + """Run lifecycle hooks for an 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 + + 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, + ) + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def _read_json_file(path: Path) -> dict | None: + """Read and parse a JSON file.""" + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + + +def _write_json_file(path: Path, data: dict) -> bool: + """Write dict to JSON file.""" + try: + path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + return True + except (OSError, IOError): + return False + + +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 _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) + """ + if not target_dir: + return Path() + + # Absolute path + if target_dir.startswith("/"): + return Path(target_dir) + + # Relative path (contains path separator or starts with .trellis) + if "/" in target_dir or target_dir.startswith(".trellis"): + return repo_root / target_dir + + # 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 / target_dir + + +# ============================================================================= +# JSONL Default Content Generators +# ============================================================================= + +def get_implement_base() -> list[dict]: + """Get base implement context entries.""" + return [ + {"file": f"{DIR_WORKFLOW}/workflow.md", "reason": "Project workflow and conventions"}, + ] + + +def get_implement_backend() -> list[dict]: + """Get backend implement context entries.""" + return [ + {"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/backend/index.md", "reason": "Backend development guide"}, + ] + + +def get_implement_frontend() -> list[dict]: + """Get frontend implement context entries.""" + return [ + {"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/frontend/index.md", "reason": "Frontend development guide"}, + ] + + +def get_check_context(dev_type: str, repo_root: Path) -> list[dict]: + """Get check context entries.""" + adapter = get_cli_adapter_auto(repo_root) + + entries = [ + {"file": adapter.get_trellis_command_path("finish-work"), "reason": "Finish work checklist"}, + ] + + if dev_type in ("backend", "fullstack"): + entries.append({"file": adapter.get_trellis_command_path("check-backend"), "reason": "Backend check spec"}) + if dev_type in ("frontend", "fullstack"): + entries.append({"file": adapter.get_trellis_command_path("check-frontend"), "reason": "Frontend check spec"}) + + return entries + + +def get_debug_context(dev_type: str, repo_root: Path) -> list[dict]: + """Get debug context entries.""" + adapter = get_cli_adapter_auto(repo_root) + + entries: list[dict] = [] + + if dev_type in ("backend", "fullstack"): + entries.append({"file": adapter.get_trellis_command_path("check-backend"), "reason": "Backend check spec"}) + if dev_type in ("frontend", "fullstack"): + entries.append({"file": adapter.get_trellis_command_path("check-frontend"), "reason": "Frontend check spec"}) + + return entries + + +def _write_jsonl(path: Path, entries: list[dict]) -> None: + """Write entries to JSONL file.""" + lines = [json.dumps(entry, ensure_ascii=False) for entry in entries] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +# ============================================================================= +# Task Operations +# ============================================================================= + +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 + + +# ============================================================================= +# 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 + + # 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 + + 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_command(["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, + "priority": args.priority, + "creator": creator, + "assignee": assignee, + "createdAt": today, + "completedAt": None, + "branch": None, + "base_branch": current_branch, + "worktree_path": None, + "current_phase": 0, + "next_action": [ + {"phase": 1, "action": "implement"}, + {"phase": 2, "action": "check"}, + {"phase": 3, "action": "finish"}, + {"phase": 4, "action": "create-pr"}, + ], + "commit": None, + "pr_url": None, + "subtasks": [], + "children": [], + "parent": None, + "relatedFiles": [], + "notes": "", + "meta": {}, + } + + _write_json_file(task_json_path, task_data) + + # 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_file(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_file(parent_json_path, parent_data) + + # Set parent in child's task.json + task_data["parent"] = parent_dir.name + _write_json_file(task_json_path, task_data) + + print(colored(f"Linked as child of: {parent_dir.name}", Colors.GREEN), file=sys.stderr) + + print(colored(f"Created task: {dir_name}", Colors.GREEN), file=sys.stderr) + print("", file=sys.stderr) + print(colored("Next steps:", Colors.BLUE), file=sys.stderr) + print(" 1. Create prd.md with requirements", file=sys.stderr) + print(" 2. Run: python3 task.py init-context <dir> <dev_type>", file=sys.stderr) + print(" 3. Run: python3 task.py start <dir>", file=sys.stderr) + print("", file=sys.stderr) + + # Output relative path for script chaining + print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}") + + _run_hooks("after_create", task_json_path, repo_root) + return 0 + + +# ============================================================================= +# Command: init-context +# ============================================================================= + +def cmd_init_context(args: argparse.Namespace) -> int: + """Initialize JSONL context files for a task.""" + repo_root = get_repo_root() + target_dir = _resolve_task_dir(args.dir, repo_root) + dev_type = args.type + + if not dev_type: + print(colored("Error: Missing arguments", Colors.RED)) + print("Usage: python3 task.py init-context <task-dir> <dev_type>") + print(" dev_type: backend | frontend | fullstack | test | docs") + return 1 + + if not target_dir.is_dir(): + print(colored(f"Error: Directory not found: {target_dir}", Colors.RED)) + return 1 + + print(colored("=== Initializing Agent Context Files ===", Colors.BLUE)) + print(f"Target dir: {target_dir}") + print(f"Dev type: {dev_type}") + print() + + # implement.jsonl + print(colored("Creating implement.jsonl...", Colors.CYAN)) + implement_entries = get_implement_base() + if dev_type in ("backend", "test"): + implement_entries.extend(get_implement_backend()) + elif dev_type == "frontend": + implement_entries.extend(get_implement_frontend()) + elif dev_type == "fullstack": + implement_entries.extend(get_implement_backend()) + implement_entries.extend(get_implement_frontend()) + + implement_file = target_dir / "implement.jsonl" + _write_jsonl(implement_file, implement_entries) + print(f" {colored('✓', Colors.GREEN)} {len(implement_entries)} entries") + + # check.jsonl + print(colored("Creating check.jsonl...", Colors.CYAN)) + check_entries = get_check_context(dev_type, repo_root) + check_file = target_dir / "check.jsonl" + _write_jsonl(check_file, check_entries) + print(f" {colored('✓', Colors.GREEN)} {len(check_entries)} entries") + + # debug.jsonl + print(colored("Creating debug.jsonl...", Colors.CYAN)) + debug_entries = get_debug_context(dev_type, repo_root) + debug_file = target_dir / "debug.jsonl" + _write_jsonl(debug_file, debug_entries) + print(f" {colored('✓', Colors.GREEN)} {len(debug_entries)} entries") + + print() + print(colored("✓ All context files created", Colors.GREEN)) + print() + print(colored("Next steps:", Colors.BLUE)) + print(" 1. Add task-specific specs: python3 task.py add-context <dir> <jsonl> <path>") + print(" 2. Set as current: python3 task.py start <dir>") + + return 0 + + +# ============================================================================= +# 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", "debug.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.""" + 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 + 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: + print(f" {colored(f'{file_name}:{line_num}: Missing file field', Colors.RED)}") + errors += 1 + continue + + 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}: ✓ ({line_num} 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", "debug.jsonl"]: + jsonl_file = target_dir / jsonl_name + if not jsonl_file.is_file(): + continue + + print(colored(f"[{jsonl_name}]", Colors.CYAN)) + + count = 0 + 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 + + count += 1 + file_path = data.get("file", "?") + 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}") + + print() + + return 0 + + +# ============================================================================= +# Command: start / finish +# ============================================================================= + +def cmd_start(args: argparse.Namespace) -> int: + """Set current 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 = str(full_path.relative_to(repo_root)) + except ValueError: + task_dir = str(full_path) + + if set_current_task(task_dir, repo_root): + print(colored(f"✓ Current task set to: {task_dir}", Colors.GREEN)) + print() + print(colored("The hook will now inject context from this task's jsonl files.", Colors.BLUE)) + + task_json_path = full_path / FILE_TASK_JSON + _run_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 current task.""" + repo_root = get_repo_root() + current = get_current_task(repo_root) + + 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 + + clear_current_task(repo_root) + print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN)) + + if task_json_path.is_file(): + _run_hooks("after_finish", 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) + + # Find task directory + task_dir = find_task_by_name(task_name, tasks_dir) + + 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) + cmd_list(argparse.Namespace(mine=False, status=None)) + 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") + if task_json_path.is_file(): + data = _read_json_file(task_json_path) + if data: + data["status"] = "completed" + data["completedAt"] = today + _write_json_file(task_json_path, data) + + # Handle subtask relationships on archive + task_parent = data.get("parent") + task_children = data.get("children", []) + + # If this is a child, remove from parent's children list + if task_parent: + parent_dir = find_task_by_name(task_parent, tasks_dir) + if parent_dir: + parent_json = parent_dir / FILE_TASK_JSON + if parent_json.is_file(): + parent_data = _read_json_file(parent_json) + if parent_data: + parent_children = parent_data.get("children", []) + if dir_name in parent_children: + parent_children.remove(dir_name) + parent_data["children"] = parent_children + _write_json_file(parent_json, parent_data) + + # 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_file(child_json) + if child_data: + child_data["parent"] = None + _write_json_file(child_json, child_data) + + # Clear if current task + current = get_current_task(repo_root) + if current and dir_name in current: + clear_current_task(repo_root) + + # Archive + result = archive_task_complete(task_dir, repo_root) + if "archived_to" in result: + archive_dest = Path(result["archived_to"]) + year_month = archive_dest.parent.name + print(colored(f"Archived: {dir_name} -> archive/{year_month}/", Colors.GREEN), file=sys.stderr) + + # Auto-commit unless --no-commit + if not getattr(args, "no_commit", False): + _auto_commit_archive(dir_name, repo_root) + + # 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_hooks("after_archive", archived_json, repo_root) + return 0 + + return 1 + + +def _auto_commit_archive(task_name: str, repo_root: Path) -> None: + """Stage .trellis/tasks/ changes and commit after archive.""" + tasks_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}" + _run_git_command(["add", "-A", tasks_rel], cwd=repo_root) + + # Check if there are staged changes + rc, _, _ = _run_git_command( + ["diff", "--cached", "--quiet", "--", tasks_rel], cwd=repo_root + ) + if rc == 0: + print("[OK] No task changes to commit.", file=sys.stderr) + return + + commit_msg = f"chore(task): archive {task_name}" + rc, _, err = _run_git_command(["commit", "-m", commit_msg], cwd=repo_root) + if rc == 0: + print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr) + else: + print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr) + + +# ============================================================================= +# Command: add-subtask +# ============================================================================= + +def cmd_add_subtask(args: argparse.Namespace) -> int: + """Link a child task to a parent task.""" + repo_root = get_repo_root() + + parent_dir = _resolve_task_dir(args.parent_dir, repo_root) + child_dir = _resolve_task_dir(args.child_dir, repo_root) + + parent_json_path = parent_dir / FILE_TASK_JSON + child_json_path = child_dir / FILE_TASK_JSON + + if not parent_json_path.is_file(): + print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr) + return 1 + + if not child_json_path.is_file(): + print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) + return 1 + + parent_data = _read_json_file(parent_json_path) + child_data = _read_json_file(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_file(parent_json_path, parent_data) + _write_json_file(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_file(parent_json_path) + child_data = _read_json_file(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_file(parent_json_path, parent_data) + _write_json_file(child_json_path, child_data) + + print(colored(f"Unlinked: {child_dir.name} from {parent_dir.name}", Colors.GREEN), file=sys.stderr) + return 0 + + +# ============================================================================= +# Command: list +# ============================================================================= + +def _get_children_progress(children: list[str], tasks_dir: Path) -> str: + """Get children progress summary like '[2/3 done]'.""" + if not children: + return "" + done_count = 0 + total = len(children) + for child_name in children: + child_dir = tasks_dir / child_name + child_json = child_dir / FILE_TASK_JSON + if child_json.is_file(): + data = _read_json_file(child_json) + if data: + status = data.get("status", "") + if status in ("completed", "done"): + done_count += 1 + return f" [{done_count}/{total} done]" + + +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() + + # First pass: collect all task data and identify parent/child relationships + all_tasks: dict[str, dict] = {} + if tasks_dir.is_dir(): + for d in sorted(tasks_dir.iterdir()): + if not d.is_dir() or d.name == "archive": + continue + + dir_name = d.name + task_json = d / FILE_TASK_JSON + status = "unknown" + assignee = "-" + children: list[str] = [] + parent: str | None = None + + if task_json.is_file(): + data = _read_json_file(task_json) + if data: + status = data.get("status", "unknown") + assignee = data.get("assignee", "-") + children = data.get("children", []) + parent = data.get("parent") + + all_tasks[dir_name] = { + "status": status, + "assignee": assignee, + "children": children, + "parent": parent, + } + + # Second pass: display tasks hierarchically + count = 0 + + def _print_task(dir_name: str, indent: int = 0) -> None: + nonlocal count + info = all_tasks[dir_name] + status = info["status"] + assignee = info["assignee"] + children = info["children"] + + # Apply --mine filter + if filter_mine and assignee != developer: + return + + # Apply --status filter + if filter_status and 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 = _get_children_progress(children, tasks_dir) if children else "" + + prefix = " " * indent + " - " + + if filter_mine: + print(f"{prefix}{dir_name}/ ({status}){progress}{marker}") + else: + print(f"{prefix}{dir_name}/ ({status}){progress} [{colored(assignee, Colors.CYAN)}]{marker}") + count += 1 + + # Print children indented + for child_name in 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()): + info = all_tasks[dir_name] + if not info["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 + + +# ============================================================================= +# 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_file(task_json) + if not data: + return 1 + + data["branch"] = branch + _write_json_file(task_json, data) + + print(colored(f"✓ Branch set to: {branch}", Colors.GREEN)) + print() + print(colored("Now you can start the multi-agent pipeline:", Colors.BLUE)) + print(f" python3 ./.trellis/scripts/multi_agent/start.py {args.dir}") + 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_file(task_json) + if not data: + return 1 + + data["base_branch"] = base_branch + _write_json_file(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_file(task_json) + if not data: + return 1 + + data["scope"] = scope + _write_json_file(task_json, data) + + print(colored(f"✓ Scope set to: {scope}", Colors.GREEN)) + return 0 + + +# ============================================================================= +# Command: create-pr (delegates to multi-agent script) +# ============================================================================= + +def cmd_create_pr(args: argparse.Namespace) -> int: + """Create PR from task - delegates to multi_agent/create_pr.py.""" + import subprocess + script_dir = Path(__file__).parent + create_pr_script = script_dir / "multi_agent" / "create_pr.py" + + cmd = [sys.executable, str(create_pr_script)] + if args.dir: + cmd.append(args.dir) + if args.dry_run: + cmd.append("--dry-run") + + result = subprocess.run(cmd) + return result.returncode + + +# ============================================================================= +# Help +# ============================================================================= + +def show_usage() -> None: + """Show usage help.""" + print("""Task Management Script for Multi-Agent Pipeline + +Usage: + python3 task.py create <title> Create new task directory + python3 task.py create <title> --parent <dir> Create task as child of parent + python3 task.py init-context <dir> <dev_type> Initialize jsonl files + 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 as current task + python3 task.py finish Clear current task + python3 task.py set-branch <dir> <branch> Set git branch for multi-agent + python3 task.py set-scope <dir> <scope> Set scope for PR title + python3 task.py create-pr [dir] [--dry-run] Create PR from task + python3 task.py archive <task-name> 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 + +Arguments: + dev_type: backend | frontend | fullstack | test | docs + +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 "Child task" --slug child --parent .trellis/tasks/01-21-parent + python3 task.py init-context .trellis/tasks/01-21-add-login backend + python3 task.py add-context <dir> implement .trellis/spec/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 create-pr # Uses current task + python3 task.py create-pr <dir> --dry-run # Preview without changes + 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.""" + parser = argparse.ArgumentParser( + description="Task Management Script for Multi-Agent Pipeline", + 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)") + + # init-context + p_init = subparsers.add_parser("init-context", help="Initialize context files") + p_init.add_argument("dir", help="Task directory") + p_init.add_argument("type", help="Dev type: backend|frontend|fullstack|test|docs") + + # 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|debug)") + 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 current task") + p_start.add_argument("dir", help="Task directory") + + # finish + subparsers.add_parser("finish", help="Clear current 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") + + # create-pr + p_pr = subparsers.add_parser("create-pr", help="Create PR") + p_pr.add_argument("dir", nargs="?", help="Task directory") + p_pr.add_argument("--dry-run", action="store_true", help="Dry run mode") + + # archive + p_archive = subparsers.add_parser("archive", help="Archive task") + p_archive.add_argument("name", help="Task 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, + "init-context": cmd_init_context, + "add-context": cmd_add_context, + "validate": cmd_validate, + "list-context": cmd_list_context, + "start": cmd_start, + "finish": cmd_finish, + "set-branch": cmd_set_branch, + "set-base-branch": cmd_set_base_branch, + "set-scope": cmd_set_scope, + "create-pr": cmd_create_pr, + "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()) diff --git a/.trellis/spec/backend/database-guidelines.md b/.trellis/spec/backend/database-guidelines.md new file mode 100644 index 0000000..a99ba1a --- /dev/null +++ b/.trellis/spec/backend/database-guidelines.md @@ -0,0 +1,221 @@ +# Database Guidelines + +> Database patterns and conventions for this project. + +--- + +## Overview + +This project uses: +- **SQLAlchemy 2.0** with async support (`asyncpg` driver) +- **Alembic** for schema migrations +- **Supabase Auth** as identity source +- **PostgreSQL** as primary database +- **Soft delete** pattern with `deleted_at` column + +--- + +## Query Patterns + +### Base Repository Pattern + +All repositories inherit from `BaseRepository` (`core.db.base_repository.BaseRepository`): + +```python +from core.db.base_repository import BaseRepository +from models.agent_chat_session import AgentChatSession + +class AgentRepository(BaseRepository[AgentChatSession]): + def __init__(self, session: AsyncSession) -> None: + super().__init__(session, AgentChatSession) +``` + +**Provided methods:** +- `get_by_id(entity_id)` - Get single entity by ID +- `get_one(*filters)` - Get single entity with filters +- `update_by_id(entity_id, update_data)` - Update entity +- `soft_delete_by_id(entity_id)` - Soft delete (sets `deleted_at`) + +**Soft delete behavior:** +- Queries **automatically exclude** deleted records +- `deleted_at` is set to `datetime.now(timezone.utc)` +- Repository checks if model has `deleted_at` column before applying filter + +### Query Examples + +```python +# Get by ID (auto-filters deleted) +session = await repository.get_by_id(session_id) + +# Get with custom filters +session = await repository.get_one( + AgentChatSession.owner_id == user_id, + AgentChatSession.id == session_id, +) + +# Update entity +updated = await repository.update_by_id( + session_id, + {"title": "New Title", "updated_at": datetime.now(timezone.utc)}, +) +``` + +### Custom Queries + +For complex queries, add custom methods to repository: + +```python +class AgentRepository(BaseRepository[AgentChatSession]): + async def get_active_sessions_by_user( + self, user_id: UUID + ) -> list[AgentChatSession]: + stmt = ( + select(self._model) + .where(self._model.owner_id == user_id) + .order_by(self._model.created_at.desc()) + ) + stmt = self._apply_soft_delete_filter(stmt) + result = await self._session.execute(stmt) + return list(result.scalars().all()) +``` + +--- + +## Migrations + +### Creating Migrations + +**Use `dev-migrate.sh` script:** + +```bash +# Create new migration +./infra/scripts/dev-migrate.sh revision --autogenerate -m "add user preferences table" +``` + +**Migration file naming:** +- Format: `YYYYMMDD_####_<description>.py` +- Example: `20260411_0001_initial_llm_schema.py` + +**Migration structure:** + +```python +def upgrade() -> None: + op.create_table( + "llm_factory", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("name", sa.String(length=50), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()")), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_index("ix_llm_factory_name", "llm_factory", ["name"], unique=True) + _enable_rls("llm_factory") # RLS for Supabase Auth + +def downgrade() -> None: + op.drop_index("ix_llm_factory_name", table_name="llm_factory") + op.drop_table("llm_factory") +``` + +### Running Migrations + +**Three modes (via `dev-migrate.sh`):** + +```bash +# Migrations only +./infra/scripts/dev-migrate.sh migrate + +# Seed data only +./infra/scripts/dev-migrate.sh init-data + +# Both (migrate + seed) +./infra/scripts/dev-migrate.sh bootstrap +``` + +**Alembic is the ONLY source of truth for schema changes.** + +--- + +## Naming Conventions + +### Tables +- **snake_case**: `agent_chat_session`, `llm_factory`, `points_ledger` +- **Plural or singular**: Based on domain semantics +- **Timestamps**: `created_at`, `updated_at`, `deleted_at` (always timezone-aware) + +### Columns +- **snake_case**: `owner_id`, `model_code`, `request_url` +- **Foreign keys**: `<entity>_id` (e.g., `factory_id`, `owner_id`) +- **Indexes**: `ix_<table>_<column>` (e.g., `ix_llms_model_code`) + +### Models +- **PascalCase**: `AgentChatSession`, `LlmFactory` +- **File location**: `models/<entity>.py` + +--- + +## Transactions + +### Service Layer Responsibility + +**Services own transaction boundaries:** + +```python +class AgentService: + async def enqueue_run(self, ...) -> TaskAccepted: + async with self._repository._session.begin(): # Transaction start + # Multiple repository calls + session = await self._repository.get_by_id(session_id) + await self._repository.update_by_id(session_id, {...}) + # Transaction commit on successful exit +``` + +### Repository Layer + +**Repositories do NOT manage transactions:** + +```python +# Repository uses session, but doesn't commit +result = await self._session.execute(stmt) +await self._session.flush() # Flush to DB, but don't commit +return result.scalar_one_or_none() +``` + +--- + +## Common Mistakes + +### ❌ Using raw SQL without Alembic +- **Wrong**: Creating tables directly in code +- **Right**: All DDL changes via Alembic migrations + +### ❌ Hardcoding database credentials +- **Wrong**: `password = "postgres123"` +- **Right**: Use `core.config.settings.Settings.database.password` + +### ❌ Bypassing soft delete filter +- **Wrong**: `select(Model).where(Model.id == x)` (includes deleted) +- **Right**: Use `BaseRepository` methods or apply `self._apply_soft_delete_filter()` + +### ❌ Using `session.commit()` in repository +- **Wrong**: Repository calling `await session.commit()` +- **Right**: Let service layer manage transactions with `session.begin()` + +### ❌ Not using timezone-aware timestamps +- **Wrong**: `datetime.now()` +- **Right**: `datetime.now(timezone.utc)` + +### ❌ Getting `owner_id` from client payload +- **Wrong**: `owner_id = payload.owner_id` +- **Right**: `owner_id = current_user.id` (from verified JWT sub claim) + +--- + +## Database Access Rules + +**From AGENTS.md:** +1. Use `supabase mcp` tools (`supabase_execute_sql`, `supabase_list_tables`) to **view** data +2. Use **Alembic migrations** to **modify** schema +3. Use **service-role DB access only** in backend +4. Soft delete uses `deleted_at`; reads exclude deleted by default \ No newline at end of file diff --git a/.trellis/spec/backend/directory-structure.md b/.trellis/spec/backend/directory-structure.md new file mode 100644 index 0000000..4b303c6 --- /dev/null +++ b/.trellis/spec/backend/directory-structure.md @@ -0,0 +1,145 @@ +# Directory Structure + +> How backend code is organized in this project. + +--- + +## Overview + +This project follows a **layered architecture** with clear separation of concerns: +- **Schema → Repository → Service** layering pattern +- Router layer for HTTP endpoints +- Core infrastructure modules +- Domain schemas for business models + +--- + +## Directory Layout + +``` +backend/ +├── src/ +│ ├── core/ # Infrastructure and cross-cutting concerns +│ │ ├── agentscope/ # Agent runtime framework +│ │ ├── auth/ # Authentication models and dependencies +│ │ ├── config/ # Settings and configuration +│ │ ├── db/ # Database base classes and session +│ │ ├── http/ # HTTP utilities (errors, middleware) +│ │ └── logging/ # Structured logging setup +│ ├── models/ # SQLAlchemy ORM models +│ ├── schemas/ # Business data models (Pydantic/dataclass) +│ │ ├── agent/ # Agent-related schemas +│ │ ├── domain/ # Domain objects +│ │ └── ... +│ ├── services/ # Shared infrastructure services +│ │ ├── base/ # Base service interfaces +│ │ ├── caches/ # Cache implementations +│ │ └── llm_pricing/ # LLM pricing service +│ └── v1/ # API v1 versioned modules +│ ├── agent/ # Agent module +│ ├── auth/ # Authentication module +│ ├── memories/ # Memories module +│ ├── points/ # Points module +│ └── ... # Other modules +├── tests/ # Test suites +├── alembic/ # Database migrations +│ └── versions/ # Migration files +└── pyproject.toml # Project configuration +``` + +--- + +## Module Organization + +Each module under `v1/` follows consistent structure: + +``` +v1/<module>/ +├── __init__.py # Module exports +├── router.py # FastAPI router (HTTP endpoints) +├── schemas.py # Request/response schemas (Pydantic) +├── repository.py # Data access layer (CRUD + queries) +├── service.py # Business logic layer (authz + transactions) +├── dependencies.py # FastAPI dependencies (DI) +└── utils.py # Module utilities (optional) +``` + +**Layering rules:** +- **Router** → handles HTTP, calls service, returns response +- **Service** → business logic, authz, transaction boundary, raises domain errors +- **Repository** → CRUD + query composition only, no auth decisions + +**Example:** `v1/agent/` module: +- `router.py` defines `/agent` endpoints +- `service.py` enforces authz (`ensure_session_owner`) and coordinates repositories +- `repository.py` handles database queries for agent sessions + +--- + +## Naming Conventions + +### Files +- **snake_case**: Python files (e.g., `agent_service.py`) +- **Module directories**: Singular or plural based on domain (e.g., `agent/`, `memories/`) + +### Classes +- **PascalCase**: Classes (`AgentService`, `AgentRepository`) +- **Suffixes**: + - `*Service` - Business logic layer + - `*Repository` - Data access layer + - `*Model` / `*Schema` - Data models (Pydantic) + - `*Settings` - Configuration classes + +### Functions/Variables +- **snake_case**: Functions and variables (`get_by_id`, `soft_delete_by_id`) +- **Private prefix**: `_` for internal methods (`_apply_soft_delete_filter`) + +### Database Tables +- **snake_case**: Table names (`agent_chat_session`, `llm_factory`) +- **Timestamps**: `created_at`, `updated_at`, `deleted_at` (soft delete) +- **Foreign keys**: `<entity>_id` (e.g., `factory_id`, `owner_id`) + +--- + +## Examples + +### Well-organized module: `v1/agent/` +``` +v1/agent/ +├── router.py # HTTP endpoints, dependencies injection +├── service.py # Business logic, authz checks (ensure_session_owner) +├── repository.py # Database queries +├── schemas.py # Request/response DTOs +├── dependencies.py # Service instantiation (DI) +├── utils.py # Helper functions +└── system_agents_config.py # Module config +``` + +### Core infrastructure: `core/logging/` +``` +core/logging/ +├── logger.py # get_logger() interface +├── config.py # Logging configuration +├── formatters.py # Structured log formatters +├── handlers.py # Log handlers +├── filters.py # Sensitive field filters +└── middleware.py # Request logging middleware +``` + +### ORM models: `models/` +```python +# models/agent_chat_session.py +class AgentChatSession(Base): + __tablename__ = "agent_chat_session" + id: Mapped[uuid.UUID] = ... + owner_id: Mapped[uuid.UUID] = ... +``` + +### Domain schemas: `schemas/domain/` +```python +# schemas/domain/chat_message.py +class AgentChatMessageMetadata(BaseModel): + role: str + content: str + # ... business fields +``` \ No newline at end of file diff --git a/.trellis/spec/backend/error-handling.md b/.trellis/spec/backend/error-handling.md new file mode 100644 index 0000000..13540bf --- /dev/null +++ b/.trellis/spec/backend/error-handling.md @@ -0,0 +1,293 @@ +# Error Handling + +> How errors are handled in this project. + +--- + +## Overview + +This project follows **RFC 7807 Problem Details** for HTTP API errors: + +- **Standard format**: `application/problem+json` +- **Error codes**: Machine-readable `UPPER_SNAKE_CASE` +- **Source of truth**: `docs/protocols/common/http-error-codes.md` +- **Exception class**: `ApiProblemError` for domain errors +- **Layered approach**: Routers handle HTTP, Services raise domain errors + +--- + +## Error Types + +### `ApiProblemError` + +**Custom exception for business logic errors:** + +```python +from core.http.errors import ApiProblemError, problem_payload + +# Raise in service/repository/dependencies +raise ApiProblemError( + status_code=403, + detail=problem_payload(code="AGENT_FORBIDDEN", detail="Forbidden"), +) + +# With params +raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="INVALID_RUNTIME_MODE", + detail="Invalid runtime mode", + params={"mode": mode, "valid_modes": ["standard", "divination"]} + ), +) +``` + +**Properties:** +- `status_code`: HTTP status (int) +- `code`: Machine-readable error code (str, `UPPER_SNAKE_CASE`) +- `detail`: Human-readable message (str) +- `params`: Optional additional context (dict) + +### `HTTPException` (FastAPI) + +**Use ONLY in router layer for HTTP transport errors:** + +```python +from fastapi import HTTPException, status + +# Only in routers, for simple HTTP errors +raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Resource not found" +) +``` + +**For business logic → use `ApiProblemError` instead.** + +--- + +## Error Handling Patterns + +### Service Layer (Business Logic) + +**Services should raise `ApiProblemError`:** + +```python +class AgentService: + async def enqueue_run(self, *, run_input: RunAgentInput, current_user: CurrentUser) -> TaskAccepted: + # Validate business rules + if owner_id != str(current_user.id): + raise ApiProblemError( + status_code=403, + detail=problem_payload( + code="AGENT_FORBIDDEN", + detail="Forbidden" + ), + ) + + # Return success result + return TaskAccepted(run_id=run_id, thread_id=thread_id) +``` + +### Repository Layer (Data Access) + +**Re-raise exceptions as `ApiProblemError` when needed:** + +```python +from sqlalchemy.exc import IntegrityError +from core.http.errors import ApiProblemError, problem_payload + +class AgentRepository(BaseRepository[AgentChatSession]): + async def create_session(self, ...) -> AgentChatSession: + try: + self._session.add(session) + await self._session.flush() + return session + except IntegrityError as exc: + raise ApiProblemError( + status_code=409, + detail=problem_payload( + code="DUPLICATE_SESSION", + detail="Session already exists" + ), + ) from exc +``` + +### Router Layer (HTTP Endpoints) + +**Routers handle `ApiProblemError` and convert to HTTP response:** + +```python +from fastapi import APIRouter, HTTPException +from core.http.errors import ApiProblemError + +router = APIRouter() + +@router.post("/agent/run") +async def create_run( + response: Response, + current_user: CurrentUser = Depends(get_current_user), + service: AgentService = Depends(get_agent_service), +): + try: + result = await service.enqueue_run(run_input=input, current_user=current_user) + return result + except ApiProblemError as exc: + response.status_code = exc.status_code + return { + "code": exc.code, + "detail": exc.detail, + **({"params": exc.params} if exc.params else {}), + } +``` + +--- + +## API Error Responses + +### RFC 7807 Format + +**All API errors return `application/problem+json`:** + +```json +{ + "code": "AGENT_FORBIDDEN", + "detail": "Forbidden", + "params": { + "resource": "agent_session", + "action": "update" + } +} +``` + +**Error code registry:** +- **Single source of truth**: `docs/protocols/common/http-error-codes.md` +- **Update requirement**: Any create/modify/deprecate of codes requires doc update + +### Error Response Construction + +**Use `problem_payload()` helper:** + +```python +from core.http.errors import problem_payload + +# Simple error +detail = problem_payload( + code="INVALID_INPUT", + detail="Invalid input data" +) + +# With params +detail = problem_payload( + code="VALIDATION_ERROR", + detail="Validation failed", + params={"field": "email", "reason": "invalid format"} +) + +# Pass to ApiProblemError +raise ApiProblemError(status_code=422, detail=detail) +``` + +--- + +## Common Mistakes + +### ❌ Catching and ignoring exceptions + +```python +# WRONG: Silent failure destroys debuggability +try: + await service.do_something() +except Exception: + pass # Never do this +``` + +**Right way:** Re-raise or convert to typed error: + +```python +try: + await service.do_something() +except SomeSpecificError as exc: + raise ApiProblemError( + status_code=400, + detail=problem_payload(code="OPERATION_FAILED", detail=str(exc)), + ) from exc +``` + +### ❌ Using `print()` for errors + +```python +# WRONG: Use logging +except Exception as e: + print(f"Error: {e}") # Never use print in runtime code +``` + +**Right way:** Use `core.logging`: + +```python +from core.logging import get_logger + +logger = get_logger(__name__) + +try: + await service.do_something() +except Exception as e: + logger.error("operation_failed", error=str(e), exc_info=True) + raise +``` + +### ❌ Using free-text `detail` as only error contract + +```python +# WRONG: No machine-readable code +raise HTTPException(status_code=400, detail="Something went wrong") +``` + +**Right way:** Use `code` field: + +```python +raise ApiProblemError( + status_code=400, + detail=problem_payload(code="OPERATION_FAILED", detail="Something went wrong"), +) +``` + +### ❌ Using `HTTPException` in service/repository layer + +```python +# WRONG: HTTP-specific error in business logic +class AgentService: + async def enqueue_run(self, ...): + raise HTTPException(status_code=403, detail="Forbidden") # Coupled to HTTP +``` + +**Right way:** Use domain error: + +```python +class AgentService: + async def enqueue_run(self, ...): + raise ApiProblemError( + status_code=403, + detail=problem_payload(code="AGENT_FORBIDDEN", detail="Forbidden"), + ) +``` + +### ❌ Not logging exceptions + +```python +# WRONG: Exception is caught but not logged +try: + await service.do_something() +except Exception: + raise ApiProblemError(...) # Where did it come from? +``` + +**Right way:** Log before re-raising: + +```python +try: + await service.do_something() +except Exception as exc: + logger.error("operation_failed", error=str(exc), exc_info=True) + raise ApiProblemError(...) from exc +``` \ No newline at end of file diff --git a/.trellis/spec/backend/index.md b/.trellis/spec/backend/index.md new file mode 100644 index 0000000..7c4a7c5 --- /dev/null +++ b/.trellis/spec/backend/index.md @@ -0,0 +1,38 @@ +# Backend Development Guidelines + +> Best practices for backend development in this project. + +--- + +## Overview + +This directory contains guidelines for backend development. Fill in each file with your project's specific conventions. + +--- + +## Guidelines Index + +| Guide | Description | Status | +|-------|-------------|--------| +| [Directory Structure](./directory-structure.md) | Module organization and file layout | Filled | +| [Database Guidelines](./database-guidelines.md) | ORM patterns, queries, migrations | Filled | +| [Error Handling](./error-handling.md) | Error types, handling strategies | Filled | +| [Quality Guidelines](./quality-guidelines.md) | Code standards, forbidden patterns | Filled | +| [Logging Guidelines](./logging-guidelines.md) | Structured logging, log levels | Filled | + +--- + +## How to Fill These Guidelines + +For each guideline file: + +1. Document your project's **actual conventions** (not ideals) +2. Include **code examples** from your codebase +3. List **forbidden patterns** and why +4. Add **common mistakes** your team has made + +The goal is to help AI assistants and new team members understand how YOUR project works. + +--- + +**Language**: All documentation should be written in **English**. diff --git a/.trellis/spec/backend/logging-guidelines.md b/.trellis/spec/backend/logging-guidelines.md new file mode 100644 index 0000000..92925dd --- /dev/null +++ b/.trellis/spec/backend/logging-guidelines.md @@ -0,0 +1,340 @@ +# Logging Guidelines + +> How logging is done in this project. + +--- + +## Overview + +This project uses **structlog** for structured, JSON-formatted logging: + +- **Library**: `structlog` with standard library integration +- **Interface**: `get_logger(name)` from `core.logging` +- **Format**: JSON logs in production, readable logs in development +- **Sensitive fields**: Automatically redacted (passwords, tokens, etc.) + +--- + +## Log Levels + +Use appropriate log levels based on event importance: + +| Level | When to Use | Examples | Noise Level | +|-------|-------------|----------|-------------| +| **ERROR** | All exceptions and failures | Database connection failed, unhandled exception | Required, never skip | +| **WARNING** | Degraded behavior, retry, fallback | Cache miss, using fallback data, retry attempt | Minimal, only when action taken | +| **INFO** | Key business events | User login, message sent, run started | Minimal, only milestones | +| **DEBUG** | Detailed flow tracing (dev only) | Function entry/exit, variable values | High, avoid in release | + +--- + +## Structured Logging + +### Logger Setup + +```python +from core.logging import get_logger + +logger = get_logger("v1.agent.service") +``` + +**Module naming convention:** +- Feature modules: `v1.<feature>.<component>` (e.g., `v1.agent.service`) +- Core modules: `core.<module>.<submodule>` (e.g., `core.logging.middleware`) + +### Log Format + +**JSON structure:** + +```json +{ + "event": "user_logged_in", + "user_id": "123e4567-e89b-12d3-a456-426614174000", + "timestamp": "2026-04-10T12:34:56.789Z", + "level": "info", + "logger": "v1.agent.service", + "process": 12345, + "thread": 140123456789024 +} +``` + +### Logging Methods + +```python +# Simple event +logger.info("user_logged_in", user_id=user.id) + +# With extra context +logger.info( + "run_enqueued", + run_id=run_id, + thread_id=thread_id, + user_id=current_user.id, +) + +# Error with exception info +try: + await repository.create_session(...) +except IntegrityError as exc: + logger.error( + "session_creation_failed", + error=str(exc), + exc_info=True, # Include stack trace + user_id=user_id, + ) + raise +``` + +--- + +## What to Log + +### Always Log + +1. **Exceptions**: Every catch block that handles an exception must log it +2. **Business milestones**: Key domain events (login, run started, message sent) +3. **Failed operations**: Any operation that fails or requires retry +4. **Auth events**: Login/logout, token refresh, auth failures +5. **External calls**: LLM API calls, database queries (at DEBUG level) + +### Log with Context + +```python +# Include relevant identifiers +logger.info( + "run_completed", + run_id=run_id, + thread_id=thread_id, + duration_seconds=duration, +) + +# Include error details +logger.error( + "database_error", + operation="create_session", + error=str(exc), + exc_info=True, +) +``` + +### Log Levels by Event Type + +**INFO - Business Milestones:** +```python +logger.info("user_logged_in", user_id=user.id) +logger.info("run_started", run_id=run_id, thread_id=thread_id) +logger.info("run_completed", run_id=run_id, duration_seconds=duration) +logger.info("message_sent", message_id=msg_id, conversation_id=conv_id) +``` + +**WARNING - Degraded Behavior:** +```python +logger.warning("cache_miss", key=cache_key, fallback="reload_from_db") +logger.warning("retry_attempt", operation="llm_call", attempt=2, max_attempts=3) +logger.warning("fallback_data_used", reason="external_api_timeout") +``` + +**ERROR - Failures:** +```python +logger.error("database_connection_failed", error=str(exc), exc_info=True) +logger.error("validation_failed", field="email", reason="invalid_format") +logger.error("auth_failed", user_id=user_id, reason="invalid_token") +try: + await service.do_operation() +except Exception as exc: + logger.error("operation_failed", error=str(exc), exc_info=True) + raise +``` + +--- + +## What NOT to Log + +### Forbidden: Sensitive Data + +**Never log:** +- Passwords (even hashed) +- API keys (`ERYAO__*` environment variables) +- JWT tokens +- Access tokens / refresh tokens +- Session IDs (except for tracing) +- PII (personally identifiable information) - names, emails, phone numbers +- Credit card numbers +- Social security numbers +- Secret keys +- Authorization headers +- Cookies + +### Forbidden: Debug in Production + +```python +# WRONG: Debug logs in release builds +import os +if os.getenv("DEBUG"): + logger.debug("variable_value", var=some_data) # Never do this +``` + +**Right way:** Use runtime log level configuration: + +```python +# Config handles log level based on environment +# runtime: +# log_level: "INFO" # production +# log_level: "DEBUG" # development +logger.debug("variable_value", var=some_data) # Will be filtered in production +``` + +### Forbidden: Iteration Logs + +```python +# WRONG: Log every iteration +for item in items: + logger.debug("processing_item", item_id=item.id) # Too noisy + process(item) +``` + +**Right way:** Log only failures: + +```python +for item in items: + try: + process(item) + except Exception as exc: + logger.error("item_processing_failed", item_id=item.id, error=str(exc)) +``` + +--- + +## Configuration + +### Settings + +```python +# core.config.settings.RuntimeSettings +class RuntimeSettings(BaseModel): + log_level: str = "INFO" # DEBUG, INFO, WARNING, ERROR + log_json: bool = True # JSON format (production) vs readable (dev) + log_rotation: Literal["time", "size", "none"] = "time" + log_rotation_when: str = "midnight" # Daily rotation + log_rotation_interval: int = 1 + log_rotation_backup_count: int = 14 # Keep 14 days + log_file_name: str = "" # Auto-generated: "{service_name}.log" + log_sensitive_fields: list[str] = [ + "password", "secret", "token", "api_key", + "authorization", "cookie", "client_ip", "user_id", + ] +``` + +### Log Files + +**Location:** `logs/{service_name}.log` and `logs/errors/{service_name}.error.log` + +**Example:** +- `logs/app.log` - All logs +- `logs/errors/app.error.log` - Errors only + +--- + +## Common Mistakes + +### ❌ Using `print()` instead of logging + +```python +# WRONG: Never use print in runtime code +print(f"User {user_id} logged in") +``` + +**Right:** Use `logger.info`: + +```python +logger.info("user_logged_in", user_id=user_id) +``` + +### ❌ Logging sensitive data + +```python +# WRONG: Logging password +logger.info("user_login", username=username, password=password) +``` + +**Right:** Exclude sensitive fields: + +```python +logger.info("user_login", username=username) # Password excluded +``` + +### ❌ Skipping error logging + +```python +# WRONG: Exception is suppressed +try: + await service.do_something() +except Exception: + pass # No logging, no re-raise +``` + +**Right:** Log and re-raise: + +```python +try: + await service.do_something() +except Exception as exc: + logger.error("operation_failed", error=str(exc), exc_info=True) + raise +``` + +### ❌ Info flooding + +```python +# WRONG: Log every database query +results = await repository.get_all() +logger.info("query_completed", count=len(results)) # Too noisy +``` + +**Right:** Log only significant operations: + +```python +results = await repository.get_all() +# Only log if something notable happens +if len(results) > MAX_THRESHOLD: + logger.warning("high_result_count", count=len(results)) +``` + +--- + +## Examples from Codebase + +### Service Layer Logging + +```python +# v1/agent/service.py +logger = get_logger(__name__) + +async def enqueue_run(self, ...) -> TaskAccepted: + logger.info( + "run_enqueued", + run_id=run_id, + thread_id=thread_id, + user_id=current_user.id, + ) + # ... +``` + +### Router Layer Logging + +```python +# v1/agent/router.py +logger = get_logger("v1.agent.router") + +async def _acquire_sse_slot(*, user_id: str) -> bool: + try: + redis = await get_or_init_redis_client() + # ... + except Exception as exc: # noqa: BLE001 + logger.warning( + "sse_slot_acquire_failed", + user_id=user_id, + reason=str(exc), + ) + return True # Graceful degradation +``` \ No newline at end of file diff --git a/.trellis/spec/backend/quality-guidelines.md b/.trellis/spec/backend/quality-guidelines.md new file mode 100644 index 0000000..9a33a2b --- /dev/null +++ b/.trellis/spec/backend/quality-guidelines.md @@ -0,0 +1,396 @@ +# Quality Guidelines + +> Code quality standards for backend development. + +--- + +## Overview + +This project enforces quality through: + +- **Linting**: `ruff` (fast Python linter) +- **Type checking**: `basedpyright` (strict type checker) +- **Formatting**: Consistent code style via linter rules +- **Testing**: `pytest` with async support (`pytest-asyncio`) +- **Pre-commit hooks**: Automated checks before commits + +--- + +## Forbidden Patterns + +### ❌ Bypassing Lint/Type Gates + +```python +# WRONG: Suppress linter warnings +result = some_function() # noqa: BLE001, reportImplicitRelativeImport +``` + +**Right way:** Fix the underlying issue: + +```python +# Fix exception handling +except Exception as exc: + logger.error("operation_failed", error=str(exc)) + raise +``` + +### ❌ Using `print()` in Runtime Code + +```python +# WRONG: Never use print in production code +print(f"Processing user {user_id}") +``` + +**Right way:** Use `core.logging`: + +```python +from core.logging import get_logger + +logger = get_logger(__name__) +logger.info("processing_user", user_id=user_id) +``` + +### ❌ Hardcoding Secrets + +```python +# WRONG: Hardcode credentials +api_key = "sk-abcd1234..." +password = "postgres123" +``` + +**Right way:** Use `core.config.settings`: + +```python +from core.config.settings import config + +api_key = config.llm.provider_keys["openai"] +password = config.database.password +``` + +### ❌ Manual Environment Parsing + +```python +# WRONG: Direct os.getenv +import os +db_host = os.getenv("DATABASE_HOST", "localhost") +``` + +**Right way:** Use `Settings`: + +```python +from core.config.settings import config + +db_host = config.database.host +``` + +### ❌ Silent Exception Handling + +```python +# WRONG: Swallow exception +try: + await service.do_something() +except Exception: + pass # Destroys debuggability +``` + +**Right way:** Log and propagate: + +```python +try: + await service.do_something() +except Exception as exc: + logger.error("operation_failed", error=str(exc), exc_info=True) + raise +``` + +--- + +## Required Patterns + +### ✅ Strong Typing at Boundaries + +```python +from pydantic import BaseModel + +# Request/response schemas must use Pydantic +class CreateSessionRequest(BaseModel): + thread_id: str + messages: list[Message] + +class SessionResponse(BaseModel): + id: UUID + owner_id: UUID + created_at: datetime +``` + +### ✅ Schema-First for Data Contracts + +```python +# Define schema first, then implement +# v1/agent/schemas.py +class TaskAccepted(BaseModel): + run_id: str + thread_id: str + accepted_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + +# Then use in router +@router.post("/run", response_model=TaskAccepted) +async def create_run(...): + return TaskAccepted(run_id=run_id, thread_id=thread_id) +``` + +### ✅ Dependency Injection via FastAPI + +```python +# v1/agent/dependencies.py +from fastapi import Depends +from core.db.base_repository import BaseRepository + +async def get_agent_service( + session: AsyncSession = Depends(get_session), +) -> AgentService: + repository = AgentRepository(session) + return AgentService(repository=repository, ...) + +# v1/agent/router.py +@router.post("/run") +async def create_run( + service: AgentService = Depends(get_agent_service), +): + return await service.enqueue_run(...) +``` + +### ✅ Configuration via Settings + +```python +# All config through Settings +from core.config.settings import config + +class SomeService: + def __init__(self): + self.redis_url = config.redis.url + self.db_url = config.database.url + self.log_level = config.runtime.log_level +``` + +### ✅ Logging with Context + +```python +# Always include relevant context +logger.info( + "run_completed", + run_id=run_id, + thread_id=thread_id, + duration_seconds=duration, + user_id=current_user.id, +) +``` + +--- + +## Testing Requirements + +### Test Organization + +``` +backend/tests/ +├── unit/ # Unit tests (no external dependencies) +├── integration/ # Integration tests (DB, Redis, HTTP) +├── conftest.py # Shared fixtures +└── ... +``` + +### Test Requirements + +**From AGENTS.md:** +1. **TDD when practical**: Write tests before/alongside implementation +2. **Regression tests**: Changed logic/contracts must have regression tests +3. **Real DB tests**: Use `settings.test.*` credentials (never hardcode) +4. **Integration tests**: Start backend via `./infra/scripts/app.sh` before running +5. **Restart for integration**: Use `./infra/scripts/app.sh restart` before `uv run pytest` + +### Test Configuration + +```python +# pyproject.toml +[tool.pytest.ini_options] +testpaths = ["backend/tests"] +addopts = "-q --import-mode=importlib" +asyncio_mode = "auto" +pythonpath = ["backend/src"] +``` + +### Example Test + +```python +import pytest +from httpx import AsyncClient +from core.config.settings import Settings + +@pytest.mark.asyncio +async def test_create_session(settings: Settings): + # Use settings.test.* for real DB tests + assert settings.test.phone != "" + + async with AsyncClient() as client: + response = await client.post( + f"{settings.supabase.url}/api/v1/agent/run", + json={"thread_id": "test-thread"}, + ) + assert response.status_code == 200 +``` + +--- + +## Code Review Checklist + +### Architecture + +- [ ] Follows `schema -> repository -> service` layering +- [ ] Router handles HTTP only, service handles business logic +- [ ] Repository does not make auth decisions +- [ ] Transaction boundary is in service layer, not repository + +### Security + +- [ ] `owner_id` comes from `current_user.id` (JWT sub claim), not client payload +- [ ] No hardcoded secrets/passwords/tokens +- [ ] Sensitive fields are not logged (passwords, tokens, PII) +- [ ] Auth checks use `ApiProblemError` with proper error codes + +### Data + +- [ ] Database changes use Alembic migrations +- [ ] Soft delete uses `deleted_at`, queries filter deleted records +- [ ] Timezone-aware timestamps: `datetime.now(timezone.utc)` +- [ ] Foreign keys follow `<entity>_id` naming + +### Error Handling + +- [ ] All exceptions are logged before re-raising +- [ ] Business logic uses `ApiProblemError`, not `HTTPException` +- [ ] Error responses use RFC 7807 format with `code` field +- [ ] New error codes documented in `docs/protocols/common/http-error-codes.md` + +### Logging + +- [ ] Uses `core.logging.get_logger`, not `print()` +- [ ] Log level appropriate for event (ERROR for failures, INFO for milestones) +- [ ] Context includes relevant identifiers (run_id, user_id, thread_id) +- [ ] No sensitive data in logs + +### Testing + +- [ ] Unit tests for service logic +- [ ] Integration tests for changed contracts +- [ ] Test credentials via `settings.test.*` +- [ ] Regression tests for bug fixes + +### Code Style + +- [ ] Passes `ruff` linting +- [ ] Passes `basedpyright` type checking +- [ ] Follows naming conventions (snake_case for functions/variables) +- [ ] No broad `except Exception:` without logging/re-raising + +--- + +## Linting & Type Checking + +### Ruff (Linter) + +**Run:** +```bash +uv run ruff check backend/src/ +``` + +**Common fixes:** +- Remove unused imports +- Fix line length (max 100 chars) +- Use `async def` for async functions +- Add type annotations + +### Basedpyright (Type Checker) + +**Run:** +```bash +uv run basedpyright backend/src/ +``` + +**Common fixes:** +- Add type hints for function parameters +- Use `Optional[T]` for optional values +- Fix `reportImplicitRelativeImport` warnings +- Handle `None` cases explicitly + +### Pre-commit Hooks + +**Setup:** +```bash +uv run pre-commit install +``` + +**Runs automatically on:** +- `ruff check` +- `basedpyright` +- Formatting checks + +--- + +## Common Mistakes + +### ❌ Missing Type Annotations + +```python +# WRONG: No type hints +def get_user(user_id): + return repository.get_by_id(user_id) +``` + +**Right:** +```python +async def get_user(user_id: UUID) -> User | None: + return await repository.get_by_id(user_id) +``` + +### ❌ Catch-All Exception Handling + +```python +# WRONG: Catching all exceptions without logging +except Exception: + return None +``` + +**Right:** +```python +except SomeSpecificError as exc: + logger.error("operation_failed", error=str(exc), exc_info=True) + raise ApiProblemError(...) from exc +``` + +### ❌ Timezone-Naive Datetimes + +```python +# WRONG: No timezone +created_at = datetime.now() +``` + +**Right:** +```python +created_at = datetime.now(timezone.utc) +``` + +### ❌ Testing with Hardcoded Credentials + +```python +# WRONG: Hardcoded test DB +DATABASE_URL = "postgres://user:pass@localhost:5432/test" +``` + +**Right:** +```python +from core.config.settings import config + +# Use settings.test.* +assert config.database.host is not None +``` \ No newline at end of file diff --git a/.trellis/spec/frontend/directory-structure.md b/.trellis/spec/frontend/directory-structure.md new file mode 100644 index 0000000..072c768 --- /dev/null +++ b/.trellis/spec/frontend/directory-structure.md @@ -0,0 +1,297 @@ +# Directory Structure + +> How Flutter app code is organized in this project. + +--- + +## Overview + +This Flutter app follows a **feature-first architecture** with clear separation of concerns: + +- **Feature modules** in `features/` for bounded product capabilities +- **Core infrastructure** in `core/` for cross-feature protocols +- **Shared UI components** in `shared/` for reusable widgets +- **Data layer** in `data/` for infrastructure abstractions + +--- + +## Directory Layout + +``` +apps/lib/ +├── main.dart # Only root entry file +├── app/ # App bootstrap & DI +│ ├── di/ # Dependency injection setup +│ ├── router.dart # Route definitions +│ └── app.dart # App configuration +├── core/ # Cross-feature infrastructure +│ ├── auth/ # Session store, auth state +│ ├── config/ # Env configuration +│ ├── logging/ # Structured logging +│ └── network/ # HTTP client, error mapping +├── data/ # Shared infrastructure ONLY +│ ├── cache/ # Cache implementations +│ ├── network/ # Network adapters +│ └── storage/ # Local storage +├── features/ # Feature modules +│ ├── auth/ # Authentication feature +│ ├── home/ # Home feature +│ ├── divination/ # Divination feature +│ ├── settings/ # Settings feature +│ └── ... # Other features +├── shared/ # Reusable UI components +│ ├── widgets/ # Shared widgets +│ └── theme/ # App theme, design tokens +└── l10n/ # Localization +``` + +--- + +## Module Organization + +### Feature Module Structure + +Each feature follows consistent structure: + +``` +features/<feature>/ +├── data/ # Data layer +│ ├── apis/ # API clients +│ ├── repositories/ # Repository implementations +│ ├── services/ # Feature-specific services +│ └── models/ # Data models/DTOs +└── presentation/ # Presentation layer + ├── bloc/ # State management (BLoC/Cubit) + ├── screens/ # Screen widgets + └── widgets/ # Feature-specific widgets +``` + +**Example: `features/auth/`** + +``` +features/auth/ +├── data/ +│ ├── apis/ +│ │ └── auth_api.dart # HTTP API calls +│ ├── repositories/ +│ │ └── auth_repository.dart # Repository implementation +│ └── models/ +│ ├── auth_user.dart # User model +│ └── session_response.dart # Session DTO +└── presentation/ + ├── bloc/ + │ ├── auth_bloc.dart # AuthBloc (ChangeNotifier) + │ └── auth_state.dart # AuthState + └── screens/ + └── login_screen.dart # Login screen widget +``` + +### Core Module Structure + +**Core contains cross-feature infrastructure:** + +``` +core/ +├── auth/ +│ └── session_store.dart # Global session management +├── config/ +│ └── env.dart # Environment configuration +├── logging/ +│ ├── logger.dart # Logger interface +│ ├── log_service.dart # LogService implementation +│ ├── log_entry.dart # Log entry model +│ └── error_handler.dart # Global error handler +└── network/ + ├── api_problem.dart # RFC7807 error model + └── api_problem_mapper.dart # Error mapping +``` + +### Shared Widget Structure + +**Shared contains reusable UI components:** + +``` +shared/ +├── widgets/ +│ ├── app_banner.dart # App-wide banner +│ ├── app_loading_indicator.dart # Loading indicator +│ ├── bottom_nav_bar.dart # Navigation bar +│ └── divination/ # Divination domain widgets +│ ├── gua_icon.dart # Gua icon widget +│ ├── yao_glyph.dart # Yao glyph widget +│ └── ... +└── theme/ + ├── app_theme.dart # Theme definition + └── design_tokens.dart # Spacing, radius, colors +``` + +--- + +## Placement Rules + +### Where to Put Code + +| Code Type | Location | Reason | +|-----------|----------|--------| +| Feature business logic | `features/<feature>/` | Bounded context | +| Cross-feature protocol | `core/` | Shared by multiple features | +| Reusable UI widget | `shared/widgets/` | Reusable by multiple screens | +| Infrastructure abstraction | `data/` | Cache/network/storage | +| Feature repository/model | `features/<feature>/data/` | Feature-scoped data | + +### Decision Tree + +``` +Is it feature-specific business logic? + → Yes: features/<feature>/ + → No: Is it reusable UI? + → Yes: shared/widgets/ + → No: Is it infrastructure? + → Yes: data/ + → No: Is it cross-feature protocol? + → Yes: core/ + → No: Re-evaluate +``` + +### Forbidden Patterns + +**❌ Do NOT:** + +1. Place feature business repositories in `data/` + - Wrong: `data/repositories/auth_repository.dart` + - Right: `features/auth/data/repositories/auth_repository.dart` + +2. Create directories under `lib/` other than allowed second-level + - Wrong: `lib/utils/`, `lib/helpers/`, `lib/constants/` + - Right: Use `core/`, `shared/`, or feature-specific locations + +3. Put feature-specific UI in `shared/widgets/` + - Wrong: `shared/widgets/auth_login_form.dart` + - Right: `features/auth/presentation/widgets/login_form.dart` + +4. Import feature data layer from other features + - Wrong: `import 'package:app/features/auth/data/repositories/auth_repository.dart'` in `features/home/` + - Right: Access via app-level facade or DI + +--- + +## Naming Conventions + +### Files + +- **snake_case**: `auth_bloc.dart`, `login_screen.dart` +- **Feature prefix for shared**: `app_loading_indicator.dart`, `app_banner.dart` + +### Classes + +- **PascalCase**: `AuthBloc`, `AuthState`, `LoginScreen` +- **Suffixes**: + - `*Bloc` / `*Cubit` - State management + - `*Repository` - Data access + - `*Api` - HTTP clients + - `*Service` - Business services + - `*Screen` - Screen widgets + - `*Widget` - Reusable widgets + +### Directories + +- **Plural for collections**: `screens/`, `widgets/`, `models/` +- **Singular for features**: `auth/`, `home/`, `divination/` + +--- + +## Examples + +### Well-organized Feature: `features/divination/` + +``` +features/divination/ +├── data/ +│ ├── apis/ +│ │ └── divination_api.dart # HTTP API +│ ├── repositories/ +│ │ └── divination_repository.dart # Repository +│ ├── services/ +│ │ └── voice_recorder.dart # Feature service +│ └── models/ +│ ├── divination_result.dart # Domain model +│ ├── divination_params.dart # Request params +│ └── follow_up_message.dart # Message model +└── presentation/ + └── screens/ + ├── divination_screen.dart # Main screen + ├── auto_divination_screen.dart # Auto mode + ├── manual_divination_screen.dart # Manual mode + └── follow_up_chat_screen.dart # Follow-up chat +``` + +### Shared Widget: `shared/widgets/app_loading_indicator.dart` + +```dart +import 'package:flutter/material.dart'; + +class AppLoadingIndicator extends StatelessWidget { + const AppLoadingIndicator({super.key}); + + @override + Widget build(BuildContext context) { + return const CircularProgressIndicator(); + } +} +``` + +### Core Infrastructure: `core/logging/logger.dart` + +```dart +import 'log_service.dart'; + +class Logger { + final String module; + + Logger(this.module, this._service); + + static void setLogService(LogService service) { + _globalLogService = service; + } + + void error({ + required String message, + required Object error, + required StackTrace stackTrace, + }) { + _service!.error( + message: message, + error: error, + stackTrace: stackTrace, + module: module, + ); + } +} + +Logger getLogger(String module) => Logger.get(module); +``` + +--- + +## Key Principles + +### Directory Contract (Must) + +1. **Only allowed second-level directories**: `app/`, `core/`, `data/`, `features/`, `shared/`, `l10n/` +2. **Only one root entry**: `lib/main.dart` +3. **No ad-hoc directories**: No `utils/`, `helpers/`, `constants/` under `lib/` +4. **Feature isolation**: Features should not import each other's data layer + +### Layer Boundaries + +1. **Presentation** → **Data** (via Repository interface) +2. **Data** → **Core/Infrastructure** (via DI) +3. **Core** → **Nothing** (foundation layer) +4. **Shared** → **Core** (for utilities) +5. **Feature** → **Core** + **Shared** (for cross-cutting concerns) + +### Data Layer Boundary (Must) + +- `data/` = infrastructure abstractions (cache/network/storage) +- `features/<feature>/data/` = feature business repositories/models +- **NEVER** mix these boundaries \ No newline at end of file diff --git a/.trellis/spec/frontend/error-handling.md b/.trellis/spec/frontend/error-handling.md new file mode 100644 index 0000000..50bdd6c --- /dev/null +++ b/.trellis/spec/frontend/error-handling.md @@ -0,0 +1,505 @@ +# Error Handling + +> How errors are handled in Flutter app. + +--- + +## Overview + +This app follows **RFC 7807 Problem Details** for error handling: + +- **Error model**: `ApiProblem` class +- **Error parsing**: `api_problem_mapper.dart` maps HTTP errors to `ApiProblem` +- **Error codes**: Machine-readable codes from `docs/protocols/common/http-error-codes.md` +- **User messages**: Safe, localized messages via `l10n` + +--- + +## Error Types + +### `ApiProblem` + +**Custom exception for HTTP API errors:** + +```dart +// core/network/api_problem.dart +class ApiProblem implements Exception { + ApiProblem({ + required this.status, + required this.title, + required this.detail, + this.code, + }); + + final int status; + final String title; + final String detail; + final String? code; + + String toUserMessage() { + return 'Request failed'; + } + + @override + String toString() => toUserMessage(); +} +``` + +**Properties:** +- `status`: HTTP status code (int) +- `title`: Error title (str) +- `detail`: Human-readable detail (str) +- `code`: Machine-readable error code (str?, `UPPER_SNAKE_CASE`) + +--- + +## Error Parsing + +### RFC 7807 Response Format + +**Backend returns `application/problem+json`:** + +```json +{ + "code": "AGENT_FORBIDDEN", + "detail": "Forbidden", + "params": { + "resource": "agent_session", + "action": "update" + } +} +``` + +**Frontend parsing:** + +```dart +// core/network/api_problem_mapper.dart +class ApiProblemMapper { + static ApiProblem? tryParse(Response response) { + if (response.statusCode < 400) return null; + + try { + final json = jsonDecode(response.body); + return ApiProblem( + status: response.statusCode, + title: json['title'] ?? 'Error', + detail: json['detail'] ?? 'Request failed', + code: json['code'], + ); + } catch (e) { + return ApiProblem( + status: response.statusCode, + title: 'Error', + detail: 'Request failed', + ); + } + } +} +``` + +### Error Code Mapping + +**Map error codes to l10n keys:** + +```dart +String getLocalizedErrorMessage(BuildContext context, ApiProblem problem) { + final code = problem.code; + + if (code == null) { + return _getStatusGenericMessage(context, problem.status); + } + + // Map code to l10n key + final l10nKey = _codeToL10nKey[code]; + if (l10nKey != null) { + return AppLocalizations.of(context)!.getString(l10nKey); + } + + // Fallback + return AppLocalizations.of(context)!.genericErrorMessage; +} + +const _codeToL10nKey = { + 'AGENT_FORBIDDEN': 'error_agent_forbidden', + 'INVALID_INPUT': 'error_invalid_input', + 'VALIDATION_ERROR': 'error_validation_failed', + // ... +}; +``` + +### Fallback Order + +**Unknown error code handling:** + +``` +1. code -> l10n key (if code exists) +2. status -> status-generic localized message +3. safe generic localized message +``` + +```dart +String getStatusGenericMessage(BuildContext context, int status) { + switch (status) { + case 401: + return AppLocalizations.of(context)!.errorUnauthorized; + case 403: + return AppLocalizations.of(context)!.errorForbidden; + case 404: + return AppLocalizations.of(context)!.errorNotFound; + case 500: + return AppLocalizations.of(context)!.errorServerError; + default: + return AppLocalizations.of(context)!.genericErrorMessage; + } +} +``` + +--- + +## Error Handling Patterns + +### API Layer + +**Catch network errors and map to ApiProblem:** + +```dart +// features/auth/data/apis/auth_api.dart +class AuthApi { + Future<AuthUser> loginWithEmailOtp({ + required String email, + required String otp, + }) async { + try { + final response = await _httpClient.post( + '/auth/login', + body: {'email': email, 'otp': otp}, + ); + + final problem = ApiProblemMapper.tryParse(response); + if (problem != null) { + throw problem; + } + + return AuthUser.fromJson(jsonDecode(response.body)); + } on SocketException { + throw ApiProblem( + status: 0, + title: 'Network Error', + detail: 'No internet connection', + ); + } on TimeoutException { + throw ApiProblem( + status: 0, + title: 'Timeout', + detail: 'Request timed out', + ); + } + } +} +``` + +### Repository Layer + +**Propagate errors with context:** + +```dart +// features/auth/data/repositories/auth_repository.dart +class AuthRepositoryImpl implements AuthRepository { + final AuthApi _api; + final SessionStore _sessionStore; + + @override + Future<AuthUser> loginWithEmailOtp({ + required String email, + required String otp, + }) async { + try { + final user = await _api.loginWithEmailOtp(email: email, otp: otp); + await _sessionStore.save(user.session); + return user; + } on ApiProblem { + rethrow; + } catch (e, stackTrace) { + _logger.error( + message: 'Login failed', + error: e, + stackTrace: stackTrace, + ); + throw ApiProblem( + status: 500, + title: 'Error', + detail: 'Request failed, please try again', + ); + } + } +} +``` + +### BLoC/State Layer + +**Handle and log errors:** + +```dart +// features/auth/presentation/bloc/auth_bloc.dart +Future<void> start() async { + _state = _state.copyWith(status: AuthStatus.loading); + notifyListeners(); + + try { + final user = await _repository.recoverSession(); + if (user == null) { + _state = const AuthState(status: AuthStatus.unauthenticated); + } else { + _state = AuthState(status: AuthStatus.authenticated, user: user); + } + notifyListeners(); + } on ApiProblem catch (problem) { + _logger.error( + message: 'Session recovery failed', + error: problem, + stackTrace: StackTrace.current, + ); + await _repository.clearLocalSession(); + _state = AuthState( + status: AuthStatus.unauthenticated, + errorMessage: problem.toUserMessage(), + ); + notifyListeners(); + } catch (e, stackTrace) { + _logger.error( + message: 'Session recovery failed: ${e.runtimeType}', + error: e, + stackTrace: stackTrace, + ); + await _repository.clearLocalSession(); + _state = AuthState( + status: AuthStatus.unauthenticated, + errorMessage: 'Request failed, please try again', + ); + notifyListeners(); + } +} +``` + +### UI Layer + +**Display errors with Toast/Banner:** + +```dart +// features/auth/presentation/screens/login_screen.dart +class LoginScreen extends StatelessWidget { + Future<void> _handleLogin() async { + try { + await authBloc.loginWithOtp(email: email, otp: otp); + // Success - navigate or show success + } on ApiProblem catch (problem) { + Toast.show( + context: context, + message: problem.toUserMessage(), + type: ToastType.error, + ); + } catch (e) { + Toast.show( + context: context, + message: 'Request failed, please try again', + type: ToastType.error, + ); + } + } +} +``` + +--- + +## Global Error Handling + +### 401 Session Invalidation + +**AuthBloc handles global 401 callback:** + +```dart +// features/auth/presentation/bloc/auth_bloc.dart +class AuthBloc extends ChangeNotifier { + bool _handlingUnauthorized = false; + + Future<void> handleUnauthorized401() async { + if (_handlingUnauthorized) return; + _handlingUnauthorized = true; + + try { + await _repository.clearLocalSession(); + _logger.warning(message: 'Session invalidated by 401 callback'); + _state = const AuthState(status: AuthStatus.unauthenticated); + notifyListeners(); + } finally { + _handlingUnauthorized = false; + } + } +} +``` + +**HttpClient intercepts 401:** + +```dart +// core/network/http_client.dart +class HttpClient { + Future<Response> get(String path) async { + final response = await _innerClient.get(path); + + if (response.statusCode == 401) { + // Trigger global 401 callback + await ServiceLocator.authBloc.handleUnauthorized401(); + } + + return response; + } +} +``` + +--- + +## Common Mistakes + +### ❌ Ignoring Error Codes + +```dart +// WRONG: Only using detail text +if (response.statusCode == 400) { + showError(json['detail']); // Unstable contract +} +``` + +**Right: Use code field:** + +```dart +final code = json['code']; +final message = getLocalizedErrorMessage(context, code); +showError(message); +``` + +### ❌ Nested Try-Catch Without Logging + +```dart +// WRONG: Silent failure +try { + await operation(); +} catch (e) { + // No logging, no re-raise + return null; +} +``` + +**Right: Log and propagate:** + +```dart +try { + await operation(); +} catch (e, stackTrace) { + _logger.error( + message: 'Operation failed', + error: e, + stackTrace: stackTrace, + ); + rethrow; +} +``` + +### ❌ Feature-Level Token Clearing + +```dart +// WRONG: Feature clearing tokens directly +class SomeBloc { + Future<void> handleError() async { + await tokenStore.clear(); // Wrong! + state = ErrorState(); + } +} +``` + +**Right: Global callback via AuthBloc:** + +```dart +// Only AuthBloc should clear session +// HttpClient triggers AuthBloc.handleUnauthorized401() +``` + +### ❌ Localized Error from Detail + +```dart +// WRONG: Translating free-text detail +final message = localize(json['detail']); +``` + +**Right: Map code to l10n:** + +```dart +final code = json['code']; +final l10nKey = _codeToL10nKey[code]; +final message = l10nKey != null + ? AppLocalizations.of(context)!.getString(l10nKey) + : genericMessage; +``` + +--- + +## Error Response Contract + +**Single source of truth: `docs/protocols/common/http-error-codes.md`** + +**Workflow:** + +1. Backend defines new error code → update protocol doc +2. Frontend updates code-to-l10n mapping +3. Both sides use same `code` field + +**Example protocol update:** + +```markdown +## AGENT_FORBIDDEN + +- **Code**: `AGENT_FORBIDDEN` +- **Status**: 403 +- **Detail**: "Forbidden" +- **L10n Key**: `error_agent_forbidden` +- **Description**: User does not have permission to access agent resource +``` + +--- + +## Testing Error Handling + +### Unit Testing + +```dart +test('ApiProblemMapper parses error response', () { + final response = MockResponse( + statusCode: 403, + body: jsonEncode({ + 'code': 'AGENT_FORBIDDEN', + 'detail': 'Forbidden', + }), + ); + + final problem = ApiProblemMapper.tryParse(response); + + expect(problem, isNotNull); + expect(problem!.status, 403); + expect(problem.code, 'AGENT_FORBIDDEN'); +}); +``` + +### Widget Testing + +```dart +testWidgets('Login screen shows error on failed login', (tester) async { + await tester.pumpWidget(MyApp()); + + // Trigger error + await tester.enterText(find.byKey(Key('email_field')), 'test@example.com'); + await tester.tap(find.byKey(Key('login_button'))); + + await tester.pumpAndSettle(); + + expect(find.text('Request failed, please try again'), findsOneWidget); +}); +``` \ No newline at end of file diff --git a/.trellis/spec/frontend/index.md b/.trellis/spec/frontend/index.md new file mode 100644 index 0000000..6a8c9b9 --- /dev/null +++ b/.trellis/spec/frontend/index.md @@ -0,0 +1,122 @@ +# Flutter App Development Guidelines + +> Best practices for Flutter app development in this project. + +--- + +## Overview + +This directory contains guidelines for Flutter app development. Fill in each file with your project's specific conventions. + +--- + +## Guidelines Index + +| Guide | Description | Status | +|-------|-------------|--------| +| [Directory Structure](./directory-structure.md) | Module organization and file layout | Filled | +| [State Management](./state-management.md) | BLoC/Cubit, repository pattern, DI | Filled | +| [Error Handling](./error-handling.md) | ApiProblem, error parsing, l10n mapping | Filled | +| [Quality Guidelines](./quality-guidelines.md) | Code standards, forbidden patterns | Filled | +| [Logging Guidelines](./logging-guidelines.md) | Structured logging, log levels | Filled | + +--- + +## How These Guidelines Were Filled + +Each guideline file was populated based on: + +1. **Existing codebase patterns** from `apps/lib/` +2. **AGENTS.md rules** from `apps/AGENTS.md` +3. **Real code examples** from `features/auth/`, `core/logging/`, etc. + +--- + +## Quick Start + +### Directory Structure + +- **Feature modules**: `features/<feature>/data/` + `features/<feature>/presentation/` +- **Core infrastructure**: `core/` (cross-feature) +- **Shared widgets**: `shared/widgets/` +- **Data layer**: `data/` (infrastructure only, not feature repositories) + +### State Management + +- **Pattern**: `ChangeNotifier` + immutable state +- **State**: Use `copyWith` pattern +- **DI**: Singleton blocs/repositories via ServiceLocator + +### Error Handling + +- **Format**: RFC 7807 (`ApiProblem`) +- **Mapping**: Error code → l10n key +- **Global**: 401 handled via `AuthBloc.handleUnauthorized401()` + +### Logging + +- **Library**: Custom `Logger` class +- **Module path**: `features.<feature>.<component>` +- **Error**: Always log with `error`, `stackTrace`, `message` +- **Forbidden**: PII, tokens, passwords + +### Quality + +- **Colors**: Semantic colors from `Theme.of(context).colorScheme` +- **Spacing**: Use `AppSpacing` / `AppRadius` tokens +- **Tests**: Unit, widget, integration tests for critical flows + +--- + +## Key Principles + +### Layer Boundaries + +1. **Presentation** → **Repository** (via interface) +2. **Repository** → **Core/Infrastructure** (via DI) +3. **Core** → **Nothing** (foundation layer) +4. **Shared** → **Core** (for utilities) +5. **Feature** → **Core** + **Shared** (for cross-cutting concerns) + +### Data Layer Boundary (Must) + +- `data/` = infrastructure abstractions (cache/network/storage) +- `features/<feature>/data/` = feature business repositories/models +- **NEVER** mix these boundaries + +### Error Code Contract (Must) + +- **Single source of truth**: `docs/protocols/common/http-error-codes.md` +- **Frontend mapping**: Error code → l10n key +- **Backend updates → Frontend mapping updates** (must stay in sync) + +--- + +## Common Anti-Patterns + +### ❌ Do NOT: + +1. Hardcode colors (`Color(0xFF...)`, `Colors.blue`) +2. Hardcode spacing (`Padding(padding: EdgeInsets.all(16.0))`) +3. Place feature repositories in shared `data/` +4. Import feature data layer from other features +5. Create new second-level directories under `lib/` +6. Use `print()` instead of Logger +7. Log PII/tokens/passwords +8. Catch exceptions without logging + +--- + +## Architecture Decision Records + +When making architectural decisions, document: + +1. **Context**: What problem are we solving? +2. **Decision**: What approach did we choose? +3. **Consequences**: What are the trade-offs? + +Add new ADRs to this directory if needed. + +--- + +**Language**: All documentation should be written in **English**. \ No newline at end of file diff --git a/.trellis/spec/frontend/logging-guidelines.md b/.trellis/spec/frontend/logging-guidelines.md new file mode 100644 index 0000000..7e4c900 --- /dev/null +++ b/.trellis/spec/frontend/logging-guidelines.md @@ -0,0 +1,542 @@ +# Logging Guidelines + +> How logging is done in Flutter app. + +--- + +## Overview + +This app uses **structured logging** with custom `Logger` class: + +- **Library**: Custom `Logger` class in `core/logging/` +- **Interface**: `getLogger(module)` from `core/logging/logger.dart` +- **Log levels**: `debug`, `info`, `warning`, `error` +- **Sensitive fields**: Never log passwords, tokens, PII + +--- + +## Logger Setup + +### Import and Initialize + +```dart +import 'core/logging/logger.dart'; + +class SomeBloc extends ChangeNotifier { + final Logger _logger = getLogger('features.auth.bloc'); +} +``` + +### Module Naming Convention + +| Feature | Module Path | +|---------|------------| +| auth | `features.auth` | +| home | `features.home` | +| divination | `features.divination` | +| settings | `features.settings` | + +**Examples:** + +```dart +// features/auth/presentation/bloc/auth_bloc.dart +final Logger _logger = getLogger('features.auth.bloc'); + +// features/home/data/repositories/home_repository.dart +final Logger _logger = getLogger('features.home.repository'); + +// core/network/http_client.dart +final Logger _logger = getLogger('core.network.http_client'); +``` + +--- + +## Log Levels + +| Level | When to Use | Noise Level | Required | +|-------|-------------|-------------|----------| +| **error** | All exceptions and failures | Required | Never skip | +| **warning** | Degraded behavior, retry, fallback | Minimal | Only when action taken | +| **info** | Key business events | Minimal | Only milestones | +| **debug** | Detailed flow tracing (dev only) | High | Avoid in release | + +--- + +## Error Logging Requirements + +### Every try-catch MUST log the exception: + +```dart +try { + await _repository.someOperation(); +} catch (e, stackTrace) { + _logger.error( + message: 'Operation failed: $operationName', + error: e, + stackTrace: stackTrace, + extra: {'context': 'relevant_data'}, + ); + // handle error +} +``` + +### Error Logging Pattern + +```dart +// features/auth/presentation/bloc/auth_bloc.dart +Future<void> start() async { + try { + final user = await _repository.recoverSession(); + // ... + } catch (error, stackTrace) { + _logger.error( + message: 'Session recovery failed: ${error.runtimeType}', + error: error.runtimeType.toString(), + stackTrace: stackTrace, + ); + await _repository.clearLocalSession(); + _state = AuthState( + status: AuthStatus.unauthenticated, + errorMessage: _toSafeMessage(error), + ); + notifyListeners(); + } +} +``` + +--- + +## Info Logging Requirements + +### Only log these milestone events: + +- User login/logout +- Message sent/received +- Data sync completed +- Important state transitions + +### Info Logging Pattern + +```dart +// Login success +_logger.info( + message: 'User logged in', + extra: {'user_id': user.id}, +); + +// Run success +_logger.info( + message: 'Run completed', + extra: { + 'run_id': runId, + 'thread_id': threadId, + 'duration_ms': duration.inMilliseconds, + }, +); +``` + +### ❌ DO NOT log for every operation: + +```dart +// WRONG: Logging every keystroke +onChanged: (value) { + _logger.info('Input changed: $value'); // Too noisy +} +``` + +--- + +## Warning Logging Requirements + +### Only log when taking corrective action: + +- Retrying after failure +- Using fallback data +- Skipping malformed data +- Deprecation warnings + +### Warning Logging Pattern + +```dart +// Cache miss with fallback +_logger.warning( + message: 'Cache miss, loading from remote', + extra: {'key': cacheKey}, +); + +// Retry attempt +_logger.warning( + message: 'Retry attempt', + extra: {'attempt': 2, 'max_attempts': 3}, +); + +// Fallback data +_logger.warning( + message: 'Using fallback data due to network timeout', + extra: {'timeout_ms': 5000}, +); +``` + +--- + +## Debug Logging + +### Use sparingly, only in debug builds: + +```dart +if (kDebugMode) { + _logger.debug( + message: 'Variable value', + extra: {'variable': expensiveObject.toString()}, + ); +} +``` + +**Note:** Debug logs are automatically filtered in release builds. + +--- + +## Prohibited Practices + +### ❌ Never log sensitive data + +```dart +// WRONG: Logging password +_logger.info(message: 'User login', extra: {'password': password}); + +// WRONG: Logging token +_logger.debug(message: 'API call', extra: {'token': accessToken}); + +// WRONG: Logging PII +_logger.info(message: 'User profile', extra: {'email': userEmail}); +``` + +### ❌ Never log at debug level in production + +```dart +// WRONG: Will log in release build +_logger.debug(message: 'Debug info: $sensitiveData'); +``` + +**Right way:** Use `kDebugMode` guard: + +```dart +if (kDebugMode) { + _logger.debug(message: 'Variable value', extra: {'var': value}); +} +``` + +### ❌ Never skip error logging + +```dart +// WRONG: Exception is caught but not logged +try { + await operation(); +} catch (e) { + state = ErrorState(); + // Missing error log! +} +``` + +### ❌ Never log in every iteration + +```dart +// WRONG: Log every iteration +for (item in items) { + _logger.debug('Processing item: ${item.id}'); // Too noisy + process(item); +} +``` + +**Right: Log only failures:** + +```dart +for (item in items) { + try { + process(item); + } catch (e, stackTrace) { + _logger.error( + message: 'Item processing failed', + error: e, + stackTrace: stackTrace, + extra: {'item_id': item.id}, + ); + } +} +``` + +--- + +## Logger Implementation + +### Core Logger Class + +```dart +// core/logging/logger.dart +import 'log_entry.dart'; +import 'log_service.dart'; + +class Logger { + final String module; + final LogService? _service; + final bool _isNoOp; + + Logger(this.module, this._service) : _isNoOp = _service == null; + + factory Logger.get(String module) { + return Logger(module, _globalLogService); + } + + void error({ + required String message, + required Object error, + required StackTrace stackTrace, + Map<String, dynamic>? extra, + }) { + if (_isNoOp) { + debugPrint(LogEntry( + message: message, + module: module, + errorType: error.runtimeType.toString(), + ).toConsoleString()); + return; + } + _service!.error( + message: message, + error: error, + stackTrace: stackTrace, + module: module, + extra: extra ?? {}, + ); + } + + void info({ + required String message, + Map<String, dynamic>? extra, + }) { + if (_isNoOp) return; + _service!.info( + message: message, + module: module, + extra: extra ?? {}, + ); + } + + void warning({ + required String message, + Map<String, dynamic>? extra, + }) { + if (_isNoOp) return; + _service!.warning( + message: message, + module: module, + extra: extra ?? {}, + ); + } + + void debug({ + required String message, + Map<String, dynamic>? extra, + }) { + if (_isNoOp || !kDebugMode) return; + _service!.debug( + message: message, + module: module, + extra: extra ?? {}, + ); + } +} + +Logger getLogger(String module) => Logger.get(module); +``` + +--- + +## Log Entry Structure + +### LogEntry Fields + +```dart +// core/logging/log_entry.dart +class LogEntry { + final DateTime timestamp; + final LogLevel level; + final String message; + final String module; + final String? errorType; + final String? errorMessage; + final String? stackTrace; + final Map<String, dynamic>? extra; +} + +enum LogLevel { debug, info, warning, error } +``` + +### Log Format + +```json +{ + "timestamp": "2026-04-10T12:34:56.789Z", + "level": "error", + "module": "features.auth.bloc", + "message": "Session recovery failed: SocketException", + "error_type": "SocketException", + "error_message": "Connection refused", + "stack_trace": "...", + "extra": { + "user_id": "123e4567-e89b-12d3-a456-426614174000" + } +} +``` + +--- + +## Common Mistakes + +### ❌ Using `print()` instead of Logger + +```dart +// WRONG: Never use print in production code +print('User logged in: $userId'); +``` + +**Right:** Use `Logger`: + +```dart +_logger.info(message: 'User logged in', extra: {'user_id': userId}); +``` + +### ❌ Logging sensitive data + +```dart +// WRONG: Logging PII +_logger.info(message: 'User profile', extra: { + 'email': userEmail, + 'phone': userPhone, +}); +``` + +**Right:** Exclude sensitive fields: + +```dart +_logger.info(message: 'User profile loaded', extra: {'user_id': userId}); +``` + +### ❌ Catching without logging + +```dart +// WRONG: Silent failure +try { + await service.doSomething(); +} catch (e) { + // No logging, no re-raise + return null; +} +``` + +**Right:** Log and re-raise: + +```dart +try { + await service.doSomething(); +} catch (e, stackTrace) { + _logger.error( + message: 'Operation failed', + error: e, + stackTrace: stackTrace, + ); + rethrow; +} +``` + +--- + +## Examples from Codebase + +### AuthBloc Error Handling + +```dart +// features/auth/presentation/bloc/auth_bloc.dart +class AuthBloc extends ChangeNotifier { + final Logger _logger = getLogger('features.auth.bloc'); + + Future<void> start() async { + try { + final user = await _repository.recoverSession(); + if (user == null) { + _state = const AuthState(status: AuthStatus.unauthenticated); + } else { + _state = AuthState(status: AuthStatus.authenticated, user: user); + } + notifyListeners(); + } catch (error, stackTrace) { + _logger.error( + message: 'Session recovery failed: ${error.runtimeType}', + error: error.runtimeType.toString(), + stackTrace: stackTrace, + ); + await _repository.clearLocalSession(); + _state = AuthState( + status: AuthStatus.unauthenticated, + errorMessage: _toSafeMessage(error), + ); + notifyListeners(); + } + } + + Future<void> logout() async { + _logger.info(message: 'User logged out'); + _state = const AuthState(status: AuthStatus.unauthenticated); + notifyListeners(); + + unawaited( + _repository.logout().catchError((Object error, StackTrace stackTrace) { + _logger.error( + message: 'User logout failed: ${error.runtimeType}', + error: error.runtimeType.toString(), + stackTrace: stackTrace, + ); + }), + ); + } +} +``` + +### Network Error Handling + +```dart +// core/network/http_client.dart +final Logger _logger = getLogger('core.network.http_client'); + +Future<Response> get(String path) async { + try { + final response = await _innerClient.get(path); + final problem = ApiProblemMapper.tryParse(response); + + if (problem != null && response.statusCode != 401) { + _logger.warning( + message: 'HTTP error response', + extra: { + 'status': response.statusCode, + 'path': path, + 'code': problem.code, + }, + ); + } + + return response; + } on SocketException catch (e, stackTrace) { + _logger.error( + message: 'Network error', + error: e, + stackTrace: stackTrace, + extra: {'path': path}, + ); + throw ApiProblem( + status: 0, + title: 'Network Error', + detail: 'No internet connection', + ); + } +} +``` \ No newline at end of file diff --git a/.trellis/spec/frontend/quality-guidelines.md b/.trellis/spec/frontend/quality-guidelines.md new file mode 100644 index 0000000..66d1b87 --- /dev/null +++ b/.trellis/spec/frontend/quality-guidelines.md @@ -0,0 +1,533 @@ +# Quality Guidelines + +> Code quality standards for Flutter app development. + +--- + +## Overview + +This app enforces quality through: + +- **Linting**: Flutter/Dart analysis +- **Architecture**: Feature-first with clear boundaries +- **Testing**: Widget tests, unit tests, integration tests +- **Code review**: Checklist-based reviews + +--- + +## Forbidden Patterns + +### ❌ Hardcoded Colors + +```dart +// WRONG: Hardcoded hex color +Container( + color: Color(0xFF2196F3), +) + +// WRONG: Using Colors.* +Container( + color: Colors.blue, +) +``` + +**Right: Use semantic colors:** + +```dart +// Semantic colors from theme +Container( + color: Theme.of(context).colorScheme.primary, +) + +// Brand palette colors +Container( + color: Theme.of(context).extension<AppColorPalette>()!.brandPrimary, +) +``` + +### ❌ Hardcoded Spacing + +```dart +// WRONG: Magic numbers +Padding( + padding: EdgeInsets.all(16.0), +) + +// WRONG: Hardcoded values +SizedBox(height: 24.0) +``` + +**Right: Use `AppSpacing` / `AppRadius`:** + +```dart +import 'shared/theme/design_tokens.dart'; + +Padding( + padding: AppSpacing.allMedium, +) + +SizedBox(height: AppSpacing.large) +``` + +### ❌ Feature Data Import from Other Features + +```dart +// WRONG: Direct import from another feature +import 'package:app/features/auth/data/repositories/auth_repository.dart'; +``` + +**Right: Access via app-level facade or DI:** + +```dart +final authRepository = ServiceLocator.authRepository; +``` + +### ❌ Creating Per-Widget State Instances + +```dart +// WRONG: New instance per build +class MyWidget extends StatelessWidget { + final authBloc = AuthBloc(); // New instance every time +} +``` + +**Right: Use singleton from DI:** + +```dart +class MyWidget extends StatelessWidget { + @override + Widget build(BuildContext context) { + final authBloc = ServiceLocator.authBloc; // Singleton + } +} +``` + +### ❌ Place Feature Repositories in Shared Data Layer + +```dart +// WRONG: Feature repository in shared data/ +// data/repositories/auth_repository.dart +class AuthRepositoryImpl implements AuthRepository {} +``` + +**Right: Keep in feature's data layer:** + +```dart +// features/auth/data/repositories/auth_repository.dart +class AuthRepositoryImpl implements AuthRepository {} +``` + +### ❌ New Second-Level Directories Under `lib/` + +```dart +// WRONG: Ad-hoc directories +lib/ + utils/ + helpers/ + constants/ +``` + +**Right: Use allowed directories only:** + +```dart +lib/ + app/ # Bootstrap, DI, router + core/ # Cross-feature infrastructure + data/ # Shared infrastructure + features/ # Feature modules + shared/ # Reusable widgets + l10n/ # Localization +``` + +--- + +## Required Patterns + +### ✅ Semantic Color System + +```dart +import 'package:flutter/material.dart'; + +// Use semantic colors +ThemeData lightTheme = ThemeData( + colorScheme: ColorScheme.light( + primary: Color(0xFF2196F3), + secondary: Color(0xFF03DAC6), + surface: Color(0xFFFFFFFF), + error: Color(0xFFB00020), + ), +); + +// Access via theme +Container( + color: Theme.of(context).colorScheme.primary, +) +``` + +### ✅ Design Tokens for Spacing + +```dart +// shared/theme/design_tokens.dart +class AppSpacing { + static const double xsmall = 4.0; + static const double small = 8.0; + static const double medium = 16.0; + static const double large = 24.0; + static const double xlarge = 32.0; + + static EdgeInsets get allMedium => EdgeInsets.all(medium); + static EdgeInsets get horizontalMedium => EdgeInsets.symmetric(horizontal: medium); +} + +class AppRadius { + static const double small = 4.0; + static const double medium = 8.0; + static const double large = 12.0; +} +``` + +### ✅ Repository Pattern with DI + +```dart +// app/di/di.dart +class ServiceLocator { + static late AuthRepository authRepository; + static late AuthBloc authBloc; + + static void setup() { + authRepository = AuthRepositoryImpl( + api: AuthApiImpl(), + sessionStore: SessionStore(), + ); + + authBloc = AuthBloc(repository: authRepository); + } +} +``` + +### ✅ Immutable State with copyWith + +```dart +class AuthState { + const AuthState({ + required this.status, + this.user, + this.errorMessage, + }); + + final AuthStatus status; + final AuthUser? user; + final String? errorMessage; + + AuthState copyWith({ + AuthStatus? status, + AuthUser? user, + String? errorMessage, + }) { + return AuthState( + status: status ?? this.status, + user: user ?? this.user, + errorMessage: errorMessage ?? this.errorMessage, + ); + } +} +``` + +### ✅ Error Logging in Try-Catch + +```dart +try { + await _repository.operation(); +} catch (e, stackTrace) { + _logger.error( + message: 'Operation failed', + error: e, + stackTrace: stackTrace, + ); + rethrow; +} +``` + +### ✅ Logger Module Naming + +```dart +class AuthBloc extends ChangeNotifier { + final Logger _logger = getLogger('features.auth.bloc'); +} + +class HomeRepository { + final Logger _logger = getLogger('features.home.repository'); +} + +class HttpClient { + final Logger _logger = getLogger('core.network.http_client'); +} +``` + +--- + +## Testing Requirements + +### Test Organization + +``` +apps/test/ +├── unit/ # Unit tests +├── widgets/ # Widget tests +├── integration/ # Integration tests +└── test_utils/ # Test utilities +``` + +### Test Requirements from AGENTS.md + +1. **Prioritize tests for**: model parsing, service logic, high-regression flows +2. **Auth/Home/Cache changes**: must include targeted regression tests +3. **Simple static UI changes**: may skip tests +4. **Test credentials**: use environment config, never hardcode + +### Unit Testing + +```dart +test('AuthBloc login success', () async { + final mockRepo = MockAuthRepository(); + final bloc = AuthBloc(repository: mockRepo); + + when(mockRepo.loginWithEmailOtp( + email: 'test@example.com', + otp: '123456', + )).thenAnswer((_) async => AuthUser(id: '123', email: 'test@example.com')); + + await bloc.loginWithOtp(email: 'test@example.com', otp: '123456'); + + expect(bloc.state.status, AuthStatus.authenticated); + expect(bloc.state.user?.id, '123'); +}); +``` + +### Widget Testing + +```dart +testWidgets('Login screen shows error on failed login', (tester) async { + await tester.pumpWidget(MyApp()); + + await tester.enterText(find.byKey(Key('email_field')), 'test@example.com'); + await tester.enterText(find.byKey(Key('otp_field')), 'wrong'); + await tester.tap(find.byKey(Key('login_button'))); + + await tester.pumpAndSettle(); + + expect(find.text('Request failed, please try again'), findsOneWidget); +}); +``` + +### Integration Testing + +```dart +testWidgets('User can login with OTP', (tester) async { + await tester.pumpWidget(MyApp()); + + // Navigate to login + await tester.tap(find.text('Login')); + await tester.pumpAndSettle(); + + // Enter credentials + await tester.enterText(find.byKey(Key('email_field')), 'user@example.com'); + await tester.tap(find.byKey(Key('send_otp_button'))); + await tester.pumpAndSettle(); + + // Enter OTP + await tester.enterText(find.byKey(Key('otp_field')), '123456'); + await tester.tap(find.byKey(Key('login_button'))); + await tester.pumpAndSettle(); + + // Verify success + expect(find.text('Welcome'), findsOneWidget); +}); +``` + +--- + +## Code Review Checklist + +### Architecture + +- [ ] Follows `features/<feature>/data/` and `features/<feature>/presentation/` structure +- [ ] Shared infrastructure in `data/` (not feature repositories/models) +- [ ] Reusable widgets in `shared/widgets/` (not feature-specific) +- [ ] Cross-feature code in `core/` +- [ ] No new second-level directories under `lib/` + +### State Management + +- [ ] Uses `ChangeNotifier` + immutable state +- [ ] State classes have `copyWith` method +- [ ] Singleton blocs via DI (not per-widget instances) +- [ ] Errors are logged before state changes + +### UI + +- [ ] Uses semantic colors from `Theme.of(context).colorScheme` +- [ ] Uses `AppSpacing` / `AppRadius` (no hardcoded values) +- [ ] Follows design system tokens +- [ ] Toast/Banner for user feedback (no `print()`) + +### Data + +- [ ] Repository pattern for data access +- [ ] `ApiProblem` for error handling +- [ ] Error codes mapped to l10n keys +- [ ] 401 handled via global callback + +### Logging + +- [ ] Logger initialized with module path (`features.<feature>.<component>`) +- [ ] All exceptions logged with `error`, `stackTrace`, and `message` +- [ ] No PII/tokens/passwords in logs +- [ ] Info logs only for milestones (login, logout, run completed) + +### Testing + +- [ ] Unit tests for bloc logic +- [ ] Widget tests for UI +- [ ] Integration tests for critical flows +- [ ] Mocks use `when/thenAnswer/thenReturn` + +--- + +## High-Risk Modules Checklist + +From AGENTS.md, these modules require extra attention: + +### Auth + +- [ ] `AuthBloc` is single source of truth +- [ ] 401 invalidation goes through global callback +- [ ] No feature-level token clearing or direct login navigation + +### Home Message Viewport + +- [ ] Auto-scroll/anchor restore is event-driven +- [ ] Viewport preserved during history prepend +- [ ] User reading position maintained + +### Cache / Repository + +- [ ] Reads/writes go through repository layer +- [ ] Cache keys/invalidation in repository (not UI/Bloc) +- [ ] Feature-scoped TTL policy defined in repository +- [ ] No per-screen/per-widget cache store instances +- [ ] Cross-feature access via app-level facade + +--- + +## Common Mistakes + +### ❌ Mutable State + +```dart +// WRONG: Direct mutation +class AuthBloc extends ChangeNotifier { + AuthUser? user; // Mutable + + void login() { + user = fetchUser(); // Direct mutation + notifyListeners(); + } +} +``` + +**Right: Immutable state:** + +```dart +class AuthBloc extends ChangeNotifier { + AuthState _state = AuthState.initial; + + AuthState get state => _state; + + void login() async { + final user = await fetchUser(); + _state = _state.copyWith(user: user, status: AuthStatus.authenticated); + notifyListeners(); + } +} +``` + +### ❌ Missing Error Logging + +```dart +// WRONG: No logging +try { + await operation(); +} catch (e) { + state = ErrorState(); +} +``` + +**Right: Log before state change:** + +```dart +try { + await operation(); +} catch (e, stackTrace) { + _logger.error(message: 'Operation failed', error: e, stackTrace: stackTrace); + state = ErrorState(); +} +``` + +### ❌ Hardcoded Strings + +```dart +// WRONG: User-facing string +Text('Login Failed') +``` + +**Right: Use l10n:** + +```dart +Text(AppLocalizations.of(context)!.loginFailed) +``` + +### ❌ Bypassing Design System + +```dart +// WRONG: Magic numbers +Padding( + padding: EdgeInsets.fromLTRB(16, 8, 16, 8), +) +``` + +**Right: Use tokens:** + +```dart +Padding( + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.medium, + vertical: AppSpacing.small, + ), +) +``` + +--- + +## Flutter-Specific Quality + +### Widget Best Practices + +- Use `const` constructors for immutable widgets +- Prefer `StatelessWidget` over `StatefulWidget` when possible +- Use `const` when creating widgets in build methods +- Avoid `print()` - use Logger instead + +### Performance + +- Use `ListView.builder` for long lists +- Avoid rebuilding expensive widgets unnecessarily +- Use `const` widgets to prevent rebuilds +- Use `RepaintBoundary` for complex animations + +### Code Style + +- Follow Dart style guide +- Use `flutter analyze` to catch issues +- Run `dart format .` before commits +- Follow naming conventions: `lowerCamelCase` for variables, `UpperCamelCase` for types \ No newline at end of file diff --git a/.trellis/spec/frontend/state-management.md b/.trellis/spec/frontend/state-management.md new file mode 100644 index 0000000..f6dfe23 --- /dev/null +++ b/.trellis/spec/frontend/state-management.md @@ -0,0 +1,398 @@ +# State Management + +> State management patterns in Flutter app. + +--- + +## Overview + +This app uses **ChangeNotifier + Provider** for state management: + +- **AuthBloc** uses `ChangeNotifier` for auth state +- **State classes** are immutable value objects +- **Repository pattern** separates data access from business logic +- **DI** via provider/factory pattern + +--- + +## State Management Pattern + +### Bloc/Cubit Pattern + +**Use `ChangeNotifier` for complex state:** + +```dart +// features/auth/presentation/bloc/auth_bloc.dart +class AuthBloc extends ChangeNotifier { + AuthBloc({required AuthRepository repository}) : _repository = repository; + + final AuthRepository _repository; + final Logger _logger = getLogger('features.auth.bloc'); + AuthState _state = AuthState.initial; + + AuthState get state => _state; + + Future<void> loginWithOtp({ + required String email, + required String otp, + }) async { + final user = await _repository.loginWithEmailOtp(email: email, otp: otp); + _logger.info(message: 'User logged in', extra: {'user_id': user.id}); + _state = AuthState(status: AuthStatus.authenticated, user: user); + notifyListeners(); + } +} +``` + +### Immutable State + +**State classes should be immutable:** + +```dart +// features/auth/presentation/bloc/auth_state.dart +class AuthState { + const AuthState({ + required this.status, + this.user, + this.errorMessage, + }); + + final AuthStatus status; + final AuthUser? user; + final String? errorMessage; + + factory AuthState.initial() => const AuthState(status: AuthStatus.initial); + + AuthState copyWith({ + AuthStatus? status, + AuthUser? user, + String? errorMessage, + }) { + return AuthState( + status: status ?? this.status, + user: user ?? this.user, + errorMessage: errorMessage ?? this.errorMessage, + ); + } +} + +enum AuthStatus { + initial, + loading, + authenticated, + unauthenticated, +} +``` + +--- + +## Repository Pattern + +### Repository Interface + +**Define interface in presentation layer:** + +```dart +// features/auth/data/repositories/auth_repository.dart +abstract class AuthRepository { + Future<AuthUser?> recoverSession(); + Future<void> sendOtp(String email); + Future<AuthUser> loginWithEmailOtp({ + required String email, + required String otp, + }); + Future<void> logout(); + Future<void> clearLocalSession(); +} +``` + +### Repository Implementation + +**Implement with data sources:** + +```dart +class AuthRepositoryImpl implements AuthRepository { + AuthRepositoryImpl({ + required AuthApi api, + required SessionStore sessionStore, + }) : _api = api, + _sessionStore = sessionStore; + + final AuthApi _api; + final SessionStore _sessionStore; + + @override + Future<AuthUser?> recoverSession() async { + final session = await _sessionStore.load(); + if (session == null) return null; + + try { + return await _api.getCurrentUser(session.accessToken); + } catch (e) { + await clearLocalSession(); + return null; + } + } +} +``` + +--- + +## Error Handling in State + +### Try-Catch with Logging + +**Every async operation should handle errors:** + +```dart +Future<void> start() async { + _state = _state.copyWith(status: AuthStatus.loading); + notifyListeners(); + + try { + final user = await _repository.recoverSession(); + if (user == null) { + _state = const AuthState(status: AuthStatus.unauthenticated); + } else { + _state = AuthState(status: AuthStatus.authenticated, user: user); + } + notifyListeners(); + } catch (error, stackTrace) { + _logger.error( + message: 'Session recovery failed: ${error.runtimeType}', + error: error, + stackTrace: stackTrace, + ); + await _repository.clearLocalSession(); + _state = AuthState( + status: AuthStatus.unauthenticated, + errorMessage: _toSafeMessage(error), + ); + notifyListeners(); + } +} +``` + +### Global Error Handling + +**401 Session Invalidation:** + +```dart +// Global callback for 401 errors +Future<void> handleUnauthorized401() async { + if (_handlingUnauthorized) return; + _handlingUnauthorized = true; + + try { + await _repository.clearLocalSession(); + _logger.warning(message: 'Session invalidated by 401 callback'); + _state = const AuthState(status: AuthStatus.unauthenticated); + notifyListeners(); + } finally { + _handlingUnauthorized = false; + } +} +``` + +--- + +## DI Pattern + +### Factory Registration + +**Register repositories and blocs:** + +```dart +// app/di/di.dart +class ServiceLocator { + static late AuthRepository authRepository; + static late AuthBloc authBloc; + + static void setup() { + authRepository = AuthRepositoryImpl( + api: AuthApiImpl(), + sessionStore: SessionStore(), + ); + + authBloc = AuthBloc(repository: authRepository); + } +} +``` + +### Widget Access + +**Access via Provider or global:** + +```dart +// Using global reference +final authBloc = ServiceLocator.authBloc; + +// In widget +class LoginScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: authBloc, + builder: (context, child) { + if (authBloc.state.status == AuthStatus.loading) { + return AppLoadingIndicator(); + } + return LoginForm(); + }, + ); + } +} +``` + +--- + +## Common Mistakes + +### ❌ Mutable State + +```dart +// WRONG: Mutating state directly +class AuthBloc extends ChangeNotifier { + AuthUser? user; + + void login() { + user = fetchUser(); // Direct mutation + notifyListeners(); + } +} +``` + +**Right: Immutable state with copyWith:** + +```dart +class AuthBloc extends ChangeNotifier { + AuthState _state = AuthState.initial; + + AuthState get state => _state; + + void login() async { + final user = await fetchUser(); + _state = _state.copyWith(user: user, status: AuthStatus.authenticated); + notifyListeners(); + } +} +``` + +### ❌ Importing Feature Data Layer from Other Features + +```dart +// WRONG: Direct import from another feature +import 'package:app/features/auth/data/repositories/auth_repository.dart'; +``` + +**Right: Access via app-level facade or DI:** + +```dart +final authRepository = ServiceLocator.authRepository; +``` + +### ❌ Skipping Error Logging + +```dart +// WRONG: No logging +try { + await repository.operation(); +} catch (e) { + state = AuthState(status: AuthStatus.error); +} +``` + +**Right: Log before state change:** + +```dart +try { + await repository.operation(); +} catch (e, stackTrace) { + _logger.error( + message: 'Operation failed', + error: e, + stackTrace: stackTrace, + ); + state = AuthState(status: AuthStatus.error); +} +``` + +### ❌ Creating Per-Widget State Instances + +```dart +// WRONG: New instance per widget +class MyWidget extends StatelessWidget { + final authBloc = AuthBloc(); // New instance every build +} +``` + +**Right: Use DI singleton:** + +```dart +class MyWidget extends StatelessWidget { + @override + Widget build(BuildContext context) { + final authBloc = ServiceLocator.authBloc; // Singleton + // ... + } +} +``` + +--- + +## Module Naming Convention + +**Logger module path:** + +| Feature | Module Path | +|---------|------------| +| auth | `features.auth` | +| home | `features.home` | +| divination | `features.divination` | +| settings | `features.settings` | + +```dart +class AuthBloc extends ChangeNotifier { + final Logger _logger = getLogger('features.auth.bloc'); + // ... +} +``` + +--- + +## Testing State + +### Unit Testing Bloc + +```dart +test('AuthBloc login success', () async { + final mockRepo = MockAuthRepository(); + final bloc = AuthBloc(repository: mockRepo); + + when(mockRepo.loginWithEmailOtp( + email: 'test@example.com', + otp: '123456', + )).thenAnswer((_) async => AuthUser(id: '123', email: 'test@example.com')); + + await bloc.loginWithOtp(email: 'test@example.com', otp: '123456'); + + expect(bloc.state.status, AuthStatus.authenticated); + expect(bloc.state.user?.id, '123'); +}); +``` + +### Integration Testing + +```dart +testWidgets('Login screen shows error on failed login', (tester) async { + await tester.pumpWidget(MyApp()); + + await tester.enterText(find.byKey(Key('email_field')), 'test@example.com'); + await tester.enterText(find.byKey(Key('otp_field')), 'wrong'); + await tester.tap(find.byKey(Key('login_button'))); + + await tester.pumpAndSettle(); + + expect(find.text('Request failed, please try again'), findsOneWidget); +}); +``` \ No newline at end of file diff --git a/.trellis/spec/guides/code-reuse-thinking-guide.md b/.trellis/spec/guides/code-reuse-thinking-guide.md new file mode 100644 index 0000000..f9d5f99 --- /dev/null +++ b/.trellis/spec/guides/code-reuse-thinking-guide.md @@ -0,0 +1,105 @@ +# Code Reuse Thinking Guide + +> **Purpose**: Stop and think before creating new code - does it already exist? + +--- + +## The Problem + +**Duplicated code is the #1 source of inconsistency bugs.** + +When you copy-paste or rewrite existing logic: +- Bug fixes don't propagate +- Behavior diverges over time +- Codebase becomes harder to understand + +--- + +## Before Writing New Code + +### Step 1: Search First + +```bash +# Search for similar function names +grep -r "functionName" . + +# Search for similar logic +grep -r "keyword" . +``` + +### Step 2: Ask These Questions + +| Question | If Yes... | +|----------|-----------| +| Does a similar function exist? | Use or extend it | +| Is this pattern used elsewhere? | Follow the existing pattern | +| Could this be a shared utility? | Create it in the right place | +| Am I copying code from another file? | **STOP** - extract to shared | + +--- + +## Common Duplication Patterns + +### Pattern 1: Copy-Paste Functions + +**Bad**: Copying a validation function to another file + +**Good**: Extract to shared utilities, import where needed + +### Pattern 2: Similar Components + +**Bad**: Creating a new component that's 80% similar to existing + +**Good**: Extend existing component with props/variants + +### Pattern 3: Repeated Constants + +**Bad**: Defining the same constant in multiple files + +**Good**: Single source of truth, import everywhere + +--- + +## When to Abstract + +**Abstract when**: +- Same code appears 3+ times +- Logic is complex enough to have bugs +- Multiple people might need this + +**Don't abstract when**: +- Only used once +- Trivial one-liner +- Abstraction would be more complex than duplication + +--- + +## After Batch Modifications + +When you've made similar changes to multiple files: + +1. **Review**: Did you catch all instances? +2. **Search**: Run grep to find any missed +3. **Consider**: Should this be abstracted? + +--- + +## Gotcha: Asymmetric Mechanisms Producing Same Output + +**Problem**: When two different mechanisms must produce the same file set (e.g., recursive directory copy for init vs. manual `files.set()` for update), structural changes (renaming, moving, adding subdirectories) only propagate through the automatic mechanism. The manual one silently drifts. + +**Symptom**: Init works perfectly, but update creates files at wrong paths or misses files entirely. + +**Prevention checklist**: +- [ ] When migrating directory structures, search for ALL code paths that reference the old structure +- [ ] If one path is auto-derived (glob/copy) and another is manually listed, the manual one needs updating +- [ ] Add a regression test that compares outputs from both mechanisms + +--- + +## Checklist Before Commit + +- [ ] Searched for existing similar code +- [ ] No copy-pasted logic that should be shared +- [ ] Constants defined in one place +- [ ] Similar patterns follow same structure diff --git a/.trellis/spec/guides/cross-layer-thinking-guide.md b/.trellis/spec/guides/cross-layer-thinking-guide.md new file mode 100644 index 0000000..2d1dee3 --- /dev/null +++ b/.trellis/spec/guides/cross-layer-thinking-guide.md @@ -0,0 +1,94 @@ +# Cross-Layer Thinking Guide + +> **Purpose**: Think through data flow across layers before implementing. + +--- + +## The Problem + +**Most bugs happen at layer boundaries**, not within layers. + +Common cross-layer bugs: +- API returns format A, frontend expects format B +- Database stores X, service transforms to Y, but loses data +- Multiple layers implement the same logic differently + +--- + +## Before Implementing Cross-Layer Features + +### Step 1: Map the Data Flow + +Draw out how data moves: + +``` +Source → Transform → Store → Retrieve → Transform → Display +``` + +For each arrow, ask: +- What format is the data in? +- What could go wrong? +- Who is responsible for validation? + +### Step 2: Identify Boundaries + +| Boundary | Common Issues | +|----------|---------------| +| API ↔ Service | Type mismatches, missing fields | +| Service ↔ Database | Format conversions, null handling | +| Backend ↔ Frontend | Serialization, date formats | +| Component ↔ Component | Props shape changes | + +### Step 3: Define Contracts + +For each boundary: +- What is the exact input format? +- What is the exact output format? +- What errors can occur? + +--- + +## Common Cross-Layer Mistakes + +### Mistake 1: Implicit Format Assumptions + +**Bad**: Assuming date format without checking + +**Good**: Explicit format conversion at boundaries + +### Mistake 2: Scattered Validation + +**Bad**: Validating the same thing in multiple layers + +**Good**: Validate once at the entry point + +### Mistake 3: Leaky Abstractions + +**Bad**: Component knows about database schema + +**Good**: Each layer only knows its neighbors + +--- + +## Checklist for Cross-Layer Features + +Before implementation: +- [ ] Mapped the complete data flow +- [ ] Identified all layer boundaries +- [ ] Defined format at each boundary +- [ ] Decided where validation happens + +After implementation: +- [ ] Tested with edge cases (null, empty, invalid) +- [ ] Verified error handling at each boundary +- [ ] Checked data survives round-trip + +--- + +## When to Create Flow Documentation + +Create detailed flow docs when: +- Feature spans 3+ layers +- Multiple teams are involved +- Data format is complex +- Feature has caused bugs before diff --git a/.trellis/spec/guides/index.md b/.trellis/spec/guides/index.md new file mode 100644 index 0000000..147c79b --- /dev/null +++ b/.trellis/spec/guides/index.md @@ -0,0 +1,79 @@ +# Thinking Guides + +> **Purpose**: Expand your thinking to catch things you might not have considered. + +--- + +## Why Thinking Guides? + +**Most bugs and tech debt come from "didn't think of that"**, not from lack of skill: + +- Didn't think about what happens at layer boundaries → cross-layer bugs +- Didn't think about code patterns repeating → duplicated code everywhere +- Didn't think about edge cases → runtime errors +- Didn't think about future maintainers → unreadable code + +These guides help you **ask the right questions before coding**. + +--- + +## Available Guides + +| Guide | Purpose | When to Use | +|-------|---------|-------------| +| [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md) | Identify patterns and reduce duplication | When you notice repeated patterns | +| [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) | Think through data flow across layers | Features spanning multiple layers | + +--- + +## Quick Reference: Thinking Triggers + +### When to Think About Cross-Layer Issues + +- [ ] Feature touches 3+ layers (API, Service, Component, Database) +- [ ] Data format changes between layers +- [ ] Multiple consumers need the same data +- [ ] You're not sure where to put some logic + +→ Read [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) + +### When to Think About Code Reuse + +- [ ] You're writing similar code to something that exists +- [ ] You see the same pattern repeated 3+ times +- [ ] You're adding a new field to multiple places +- [ ] **You're modifying any constant or config** +- [ ] **You're creating a new utility/helper function** ← Search first! + +→ Read [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md) + +--- + +## Pre-Modification Rule (CRITICAL) + +> **Before changing ANY value, ALWAYS search first!** + +```bash +# Search for the value you're about to change +grep -r "value_to_change" . +``` + +This single habit prevents most "forgot to update X" bugs. + +--- + +## How to Use This Directory + +1. **Before coding**: Skim the relevant thinking guide +2. **During coding**: If something feels repetitive or complex, check the guides +3. **After bugs**: Add new insights to the relevant guide (learn from mistakes) + +--- + +## Contributing + +Found a new "didn't think of that" moment? Add it to the relevant guide. + +--- + +**Core Principle**: 30 minutes of thinking saves 3 hours of debugging. diff --git a/.trellis/workflow.md b/.trellis/workflow.md new file mode 100644 index 0000000..d1fe61e --- /dev/null +++ b/.trellis/workflow.md @@ -0,0 +1,416 @@ +# Development Workflow + +> Based on [Effective Harnesses for Long-Running Agents](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents) + +--- + +## Table of Contents + +1. [Quick Start (Do This First)](#quick-start-do-this-first) +2. [Workflow Overview](#workflow-overview) +3. [Session Start Process](#session-start-process) +4. [Development Process](#development-process) +5. [Session End](#session-end) +6. [File Descriptions](#file-descriptions) +7. [Best Practices](#best-practices) + +--- + +## Quick Start (Do This First) + +### Step 0: Initialize Developer Identity (First Time Only) + +> **Multi-developer support**: Each developer/Agent needs to initialize their identity first + +```bash +# Check if already initialized +python3 ./.trellis/scripts/get_developer.py + +# If not initialized, run: +python3 ./.trellis/scripts/init_developer.py <your-name> +# Example: python3 ./.trellis/scripts/init_developer.py cursor-agent +``` + +This creates: +- `.trellis/.developer` - Your identity file (gitignored, not committed) +- `.trellis/workspace/<your-name>/` - Your personal workspace directory + +**Naming suggestions**: +- Human developers: Use your name, e.g., `john-doe` +- Cursor AI: `cursor-agent` or `cursor-<task>` +- Claude Code: `claude-agent` or `claude-<task>` +- iFlow cli: `iflow-agent` or `iflow-<task>` + +### Step 1: Understand Current Context + +```bash +# Get full context in one command +python3 ./.trellis/scripts/get_context.py + +# Or check manually: +python3 ./.trellis/scripts/get_developer.py # Your identity +python3 ./.trellis/scripts/task.py list # Active tasks +git status && git log --oneline -10 # Git state +``` + +### Step 2: Read Project Guidelines [MANDATORY] + +**CRITICAL**: Read guidelines before writing any code: + +```bash +# Read frontend guidelines index (if applicable) +cat .trellis/spec/frontend/index.md + +# Read backend guidelines index (if applicable) +cat .trellis/spec/backend/index.md +``` + +**Why read both?** +- Understand the full project architecture +- Know coding standards for the entire codebase +- See how frontend and backend interact +- Learn the overall code quality requirements + +### Step 3: Before Coding - Read Specific Guidelines (Required) + +Based on your task, read the **detailed** guidelines: + +**Frontend Task**: +```bash +cat .trellis/spec/frontend/hook-guidelines.md # For hooks +cat .trellis/spec/frontend/component-guidelines.md # For components +cat .trellis/spec/frontend/type-safety.md # For types +``` + +**Backend Task**: +```bash +cat .trellis/spec/backend/database-guidelines.md # For DB operations +cat .trellis/spec/backend/type-safety.md # For types +cat .trellis/spec/backend/logging-guidelines.md # For logging +``` + +--- + +## Workflow Overview + +### Core Principles + +1. **Read Before Write** - Understand context before starting +2. **Follow Standards** - [!] **MUST read `.trellis/spec/` guidelines before coding** +3. **Incremental Development** - Complete one task at a time +4. **Record Promptly** - Update tracking files immediately after completion +5. **Document Limits** - [!] **Max 2000 lines per journal document** + +### File System + +``` +.trellis/ +|-- .developer # Developer identity (gitignored) +|-- scripts/ +| |-- __init__.py # Python package init +| |-- common/ # Shared utilities (Python) +| | |-- __init__.py +| | |-- paths.py # Path utilities +| | |-- developer.py # Developer management +| | +-- git_context.py # Git context implementation +| |-- multi_agent/ # Multi-agent pipeline scripts +| | |-- __init__.py +| | |-- start.py # Start worktree agent +| | |-- status.py # Monitor agent status +| | |-- create_pr.py # Create PR +| | +-- cleanup.py # Cleanup worktree +| |-- init_developer.py # Initialize developer identity +| |-- get_developer.py # Get current developer name +| |-- task.py # Manage tasks +| |-- get_context.py # Get session context +| +-- add_session.py # One-click session recording +|-- workspace/ # Developer workspaces +| |-- index.md # Workspace index + Session template +| +-- {developer}/ # Per-developer directories +| |-- index.md # Personal index (with @@@auto markers) +| +-- journal-N.md # Journal files (sequential numbering) +|-- tasks/ # Task tracking +| +-- {MM}-{DD}-{name}/ +| +-- task.json +|-- spec/ # [!] MUST READ before coding +| |-- frontend/ # Frontend guidelines (if applicable) +| | |-- index.md # Start here - guidelines index +| | +-- *.md # Topic-specific docs +| |-- backend/ # Backend guidelines (if applicable) +| | |-- index.md # Start here - guidelines index +| | +-- *.md # Topic-specific docs +| +-- guides/ # Thinking guides +| |-- index.md # Guides index +| |-- cross-layer-thinking-guide.md # Pre-implementation checklist +| +-- *.md # Other guides ++-- workflow.md # This document +``` + +--- + +## Session Start Process + +### Step 1: Get Session Context + +Use the unified context script: + +```bash +# Get all context in one command +python3 ./.trellis/scripts/get_context.py + +# Or get JSON format +python3 ./.trellis/scripts/get_context.py --json +``` + +### Step 2: Read Development Guidelines [!] REQUIRED + +**[!] CRITICAL: MUST read guidelines before writing any code** + +Based on what you'll develop, read the corresponding guidelines: + +**Frontend Development** (if applicable): +```bash +# Read index first, then specific docs based on task +cat .trellis/spec/frontend/index.md +``` + +**Backend Development** (if applicable): +```bash +# Read index first, then specific docs based on task +cat .trellis/spec/backend/index.md +``` + +**Cross-Layer Features**: +```bash +# For features spanning multiple layers +cat .trellis/spec/guides/cross-layer-thinking-guide.md +``` + +### Step 3: Select Task to Develop + +Use the task management script: + +```bash +# List active tasks +python3 ./.trellis/scripts/task.py list + +# Create new task (creates directory with task.json) +python3 ./.trellis/scripts/task.py create "<title>" --slug <task-name> +``` + +--- + +## Development Process + +### Task Development Flow + +``` +1. Create or select task + --> python3 ./.trellis/scripts/task.py create "<title>" --slug <name> or list + +2. Write code according to guidelines + --> Read .trellis/spec/ docs relevant to your task + --> For cross-layer: read .trellis/spec/guides/ + +3. Self-test + --> Run project's lint/test commands (see spec docs) + --> Manual feature testing + +4. Commit code + --> git add <files> + --> git commit -m "type(scope): description" + Format: feat/fix/docs/refactor/test/chore + +5. Record session (one command) + --> python3 ./.trellis/scripts/add_session.py --title "Title" --commit "hash" +``` + +### Code Quality Checklist + +**Must pass before commit**: +- [OK] Lint checks pass (project-specific command) +- [OK] Type checks pass (if applicable) +- [OK] Manual feature testing passes + +**Project-specific checks**: +- See `.trellis/spec/frontend/quality-guidelines.md` for frontend +- See `.trellis/spec/backend/quality-guidelines.md` for backend + +--- + +## Session End + +### One-Click Session Recording + +After code is committed, use: + +```bash +python3 ./.trellis/scripts/add_session.py \ + --title "Session Title" \ + --commit "abc1234" \ + --summary "Brief summary" +``` + +This automatically: +1. Detects current journal file +2. Creates new file if 2000-line limit exceeded +3. Appends session content +4. Updates index.md (sessions count, history table) + +### Pre-end Checklist + +Use `/trellis:finish-work` command to run through: +1. [OK] All code committed, commit message follows convention +2. [OK] Session recorded via `add_session.py` +3. [OK] No lint/test errors +4. [OK] Working directory clean (or WIP noted) +5. [OK] Spec docs updated if needed + +--- + +## File Descriptions + +### 1. workspace/ - Developer Workspaces + +**Purpose**: Record each AI Agent session's work content + +**Structure** (Multi-developer support): +``` +workspace/ +|-- index.md # Main index (Active Developers table) ++-- {developer}/ # Per-developer directory + |-- index.md # Personal index (with @@@auto markers) + +-- journal-N.md # Journal files (sequential: 1, 2, 3...) +``` + +**When to update**: +- [OK] End of each session +- [OK] Complete important task +- [OK] Fix important bug + +### 2. spec/ - Development Guidelines + +**Purpose**: Documented standards for consistent development + +**Structure** (Multi-doc format): +``` +spec/ +|-- frontend/ # Frontend docs (if applicable) +| |-- index.md # Start here +| +-- *.md # Topic-specific docs +|-- backend/ # Backend docs (if applicable) +| |-- index.md # Start here +| +-- *.md # Topic-specific docs ++-- guides/ # Thinking guides + |-- index.md # Start here + +-- *.md # Guide-specific docs +``` + +**When to update**: +- [OK] New pattern discovered +- [OK] Bug fixed that reveals missing guidance +- [OK] New convention established + +### 3. Tasks - Task Tracking + +Each task is a directory containing `task.json`: + +``` +tasks/ +|-- 01-21-my-task/ +| +-- task.json ++-- archive/ + +-- 2026-01/ + +-- 01-15-old-task/ + +-- task.json +``` + +**Commands**: +```bash +python3 ./.trellis/scripts/task.py create "<title>" [--slug <name>] # Create task directory +python3 ./.trellis/scripts/task.py archive <name> # Archive to archive/{year-month}/ +python3 ./.trellis/scripts/task.py list # List active tasks +python3 ./.trellis/scripts/task.py list-archive # List archived tasks +``` + +--- + +## Best Practices + +### [OK] DO - Should Do + +1. **Before session start**: + - Run `python3 ./.trellis/scripts/get_context.py` for full context + - [!] **MUST read** relevant `.trellis/spec/` docs + +2. **During development**: + - [!] **Follow** `.trellis/spec/` guidelines + - For cross-layer features, use `/trellis:check-cross-layer` + - Develop only one task at a time + - Run lint and tests frequently + +3. **After development complete**: + - Use `/trellis:finish-work` for completion checklist + - After fix bug, use `/trellis:break-loop` for deep analysis + - Human commits after testing passes + - Use `add_session.py` to record progress + +### [X] DON'T - Should Not Do + +1. [!] **Don't** skip reading `.trellis/spec/` guidelines +2. [!] **Don't** let journal single file exceed 2000 lines +3. **Don't** develop multiple unrelated tasks simultaneously +4. **Don't** commit code with lint/test errors +5. **Don't** forget to update spec docs after learning something +6. [!] **Don't** execute `git commit` - AI should not commit code + +--- + +## Quick Reference + +### Must-read Before Development + +| Task Type | Must-read Document | +|-----------|-------------------| +| Frontend work | `frontend/index.md` → relevant docs | +| Backend work | `backend/index.md` → relevant docs | +| Cross-Layer Feature | `guides/cross-layer-thinking-guide.md` | + +### Commit Convention + +```bash +git commit -m "type(scope): description" +``` + +**Type**: feat, fix, docs, refactor, test, chore +**Scope**: Module name (e.g., auth, api, ui) + +### Common Commands + +```bash +# Session management +python3 ./.trellis/scripts/get_context.py # Get full context +python3 ./.trellis/scripts/add_session.py # Record session + +# Task management +python3 ./.trellis/scripts/task.py list # List tasks +python3 ./.trellis/scripts/task.py create "<title>" # Create task + +# Slash commands +/trellis:finish-work # Pre-commit checklist +/trellis:break-loop # Post-debug analysis +/trellis:check-cross-layer # Cross-layer verification +``` + +--- + +## Summary + +Following this workflow ensures: +- [OK] Continuity across multiple sessions +- [OK] Consistent code quality +- [OK] Trackable progress +- [OK] Knowledge accumulation in spec docs +- [OK] Transparent team collaboration + +**Core Philosophy**: Read before write, follow standards, record promptly, capture learnings diff --git a/.trellis/workspace/index.md b/.trellis/workspace/index.md new file mode 100644 index 0000000..427947f --- /dev/null +++ b/.trellis/workspace/index.md @@ -0,0 +1,123 @@ +# 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 +- 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} + +### 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**. diff --git a/.trellis/workspace/zl-q/index.md b/.trellis/workspace/zl-q/index.md new file mode 100644 index 0000000..3ec2969 --- /dev/null +++ b/.trellis/workspace/zl-q/index.md @@ -0,0 +1,40 @@ +# Workspace Index - zl-q + +> 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 | +|---|------|-------|---------| +<!-- @@@/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 diff --git a/.trellis/workspace/zl-q/journal-1.md b/.trellis/workspace/zl-q/journal-1.md new file mode 100644 index 0000000..7530958 --- /dev/null +++ b/.trellis/workspace/zl-q/journal-1.md @@ -0,0 +1,7 @@ +# Journal - zl-q (Part 1) + +> AI development session journal +> Started: 2026-04-10 + +--- + diff --git a/.trellis/worktree.yaml b/.trellis/worktree.yaml new file mode 100644 index 0000000..2648560 --- /dev/null +++ b/.trellis/worktree.yaml @@ -0,0 +1,47 @@ +# Worktree Configuration for Multi-Agent Pipeline +# Used for worktree initialization in multi-agent workflows +# +# All paths are relative to project root + +#------------------------------------------------------------------------------- +# Paths +#------------------------------------------------------------------------------- + +# Worktree storage directory (relative to project root) +worktree_dir: ../trellis-worktrees + +#------------------------------------------------------------------------------- +# Files to Copy +#------------------------------------------------------------------------------- + +# Files to copy to each worktree (each worktree needs independent copy) +# These files contain sensitive info or need worktree-independent config +copy: + # Environment variables (uncomment and customize as needed) + # - .env + # - .env.local + # Workflow config + - .trellis/.developer + +#------------------------------------------------------------------------------- +# Post-Create Hooks +#------------------------------------------------------------------------------- + +# Commands to run after creating worktree +# Executed in worktree directory, in order, abort on failure +post_create: + # Install dependencies (uncomment based on your package manager) + # - npm install + # - pnpm install --frozen-lockfile + # - yarn install --frozen-lockfile + +#------------------------------------------------------------------------------- +# Check Agent Verification (Ralph Loop) +#------------------------------------------------------------------------------- + +# Commands to verify code quality before allowing check agent to finish +# If configured, Ralph Loop will run these commands - all must pass to allow completion +# If not configured or empty, trusts agent's completion markers +verify: + # - pnpm lint + # - pnpm typecheck diff --git a/AGENTS.md b/AGENTS.md index 5f0c1fa..eb9ed55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,3 +77,21 @@ Return: - success or failure - first failing step (if any) - key observation +<!-- TRELLIS:START --> +# Trellis Instructions + +These instructions are for AI assistants working in this project. + +Use the `/trellis:start` command when starting a new session to: +- Initialize your developer identity +- Understand current project context +- Read relevant guidelines + +Use `@/.trellis/` to learn: +- Development workflow (`workflow.md`) +- Project structure guidelines (`spec/`) +- Developer workspace (`workspace/`) + +Keep this managed block so 'trellis update' can refresh the instructions. + +<!-- TRELLIS:END --> diff --git a/docs/protocols/common/http-error-codes.md b/docs/protocols/common/http-error-codes.md index 4035c2a..cb16108 100644 --- a/docs/protocols/common/http-error-codes.md +++ b/docs/protocols/common/http-error-codes.md @@ -12,6 +12,7 @@ This document is the source of truth for backend RFC7807 `code` values consumed | `AUTH_REFRESH_TOKEN_INVALID` | 401 | Invalid/expired refresh token | Clear local session and return login | | `AUTH_REFRESH_TOKEN_MISSING` | 401 | Refresh token missing on logout | Treat as local logout and clear session | | `AUTH_USER_NOT_FOUND` | 404 | User not found | Show not-found message where applicable | +| `AUTH_UNAUTHORIZED` | 401 | Missing or invalid auth credentials | Force logout and redirect to login | ## Agent Points @@ -23,16 +24,41 @@ This document is the source of truth for backend RFC7807 `code` values consumed | code | status | meaning | frontend handling | |---|---:|---|---| -| `AGENT_SESSION_RUN_LIMIT_EXCEEDED` | 409 | Session already reached max run count (start + 1 follow-up) | Show run-limit message and require starting a new session | +| `AGENT_SESSION_RUN_LIMIT_EXCEEDED` | 409 | Session already reached max message count (2 user messages per session: initial divination + 1 follow-up) | Show run-limit message and require starting a new session | | `AGENT_RUNTIME_MODE_INVALID` | 422 | Missing or invalid `forwardedProps.runtime_mode` in run request | Show invalid-request message and retry from current page | | `AGENT_DIVINATION_PAYLOAD_REQUIRED` | 422 | Missing required `forwardedProps.divinationPayload` in run request | Prompt user to restart casting flow and resubmit | -| `AGENT_OUTPUT_DIVINATION_INVALID` | 422 | Worker output contains invalid `divination_derived` payload shape | Show generic history parse error and suggest retrying latest run | | `AGENT_SESSION_ID_INVALID` | 422 | Invalid session/thread id format | Show invalid-session message and force refresh history | | `AGENT_SESSION_NOT_FOUND` | 404 | Session does not exist (including follow-up on non-existing thread) | Show session-not-found message and refresh history | +| `AGENT_USER_ID_INVALID` | 422 | Invalid user id in request context | Show invalid-request message | | `AGENT_AUDIO_UNSUPPORTED_FORMAT` | 400 | Audio format is not accepted by transcribe endpoint | Show format hint and ask user to retry with wav audio | | `AGENT_AUDIO_TOO_LARGE` | 400 | Audio file exceeds transcribe size limit | Show size-limit message and ask user to shorten audio | | `AGENT_AUDIO_EMPTY` | 400 | Uploaded audio payload is empty | Show retry hint and keep input unchanged | | `AGENT_ASR_UNAVAILABLE` | 502 | Upstream ASR service unavailable | Show retry message and allow fallback to text input | +| `AGENT_PAYLOAD_INVALID` | 422 | Parsed run input payload is invalid | Show invalid-request message and retry from current page | +| `AGENT_RUN_INPUT_INVALID` | 422 | Run request body fails validation | Show invalid-request message and retry | +| `AGENT_RUN_MESSAGES_INVALID` | 422 | Run request messages contract violation | Show invalid-request message and retry | +| `AGENT_INVALID_RUN_ID` | 422 | Invalid or missing `runId` query parameter | Show invalid-request message and retry | +| `AGENT_INVALID_LAST_EVENT_ID` | 422 | Invalid `Last-Event-ID` header format | Retry without the header | +| `AGENT_SSE_CONNECTION_LIMIT` | 429 | Too many concurrent SSE connections per user | Show connection-limit message and reduce concurrent streams | +| `AGENT_FORBIDDEN` | 403 | User does not own the session resource | Force refresh and show unauthorized message | +| `AGENT_ATTACHMENT_EMPTY` | 422 | Uploaded attachment payload is empty | Show retry hint and keep input unchanged | +| `AGENT_ATTACHMENT_TOO_LARGE` | 413 | Attachment file exceeds size limit | Show size-limit message and ask user to use smaller file | +| `AGENT_ATTACHMENTS_TOO_MANY` | 422 | Too many attachments in single message | Show attachment limit message | +| `AGENT_ATTACHMENT_UNSUPPORTED_TYPE` | 422 | Attachment mime type is not supported | Show unsupported type message | +| `AGENT_ATTACHMENT_UPLOAD_FAILED` | 502 | Backend failed to upload attachment to storage | Show retry toast | +| `AGENT_ATTACHMENT_STORAGE_UNAVAILABLE` | 503 | Attachment storage service unavailable | Show retry message | +| `AGENT_ATTACHMENT_BUCKET_INVALID` | 422 | Attachment bucket name is invalid | Show generic error and force refresh | +| `AGENT_ATTACHMENT_PATH_SCOPE_INVALID` | 422 | Attachment path does not belong to user scope | Show generic security error and force refresh | +| `AGENT_SIGNED_URL_GENERATION_FAILED` | 502 | Backend failed to generate attachment signed URL | Show retry toast | +| `AGENT_SIGNED_IMAGE_URL_INVALID` | 422 | Signed image URL is invalid or expired | Show error and retry image load | + +## Agent Internal (Binary URL Validation) + +| code | status | meaning | frontend handling | +|---|---:|---|---| +| `INVALID_BINARY_URL_HOST` | 422 | Binary URL host is not allowed | Show error and retry | +| `INVALID_BINARY_URL_BUCKET` | 422 | Binary URL bucket name is invalid | Show error and retry | +| `INVALID_BINARY_URL_PATH_SCOPE` | 422 | Binary URL path does not belong to allowed scope | Show error and retry | ## Profile @@ -51,8 +77,17 @@ This document is the source of truth for backend RFC7807 `code` values consumed | `AVATAR_SIGNED_URL_FAILED` | 502 | Backend failed to generate avatar signed upload URL | Show retry toast and keep previous avatar | | `AVATAR_UPLOAD_FAILED` | 502 | Backend failed to upload avatar bytes to storage | Show retry toast and keep previous avatar | -Compatibility strategy: +## Global -- Additive changes only for new codes. -- Existing codes must keep semantic meaning. -- Frontend must map by `code`, not by `detail` text. +| code | status | meaning | frontend handling | +|---|---:|---|---| +| `REQUEST_VALIDATION_ERROR` | 422 | Request validation failed | Show invalid-request message | +| `INTERNAL_ERROR` | 500 | Unexpected internal server error | Show generic error and allow retry | + +## Tool (Internal Agent Runtime) + +| code | status | meaning | frontend handling | +|---|---:|---|---| +| `MISSING_RUNTIME_ARGS` | 500 | Tool call missing required runtime arguments | Show error and retry | +| `TOOL_REJECTED` | 403 | Tool execution was rejected | Show tool rejection message | +| `TOOL_PENDING_APPROVAL` | 202 | Tool execution awaiting approval | Show pending approval state | diff --git a/docs/protocols/common/user-points-chat-data-protocol.md b/docs/protocols/common/user-points-chat-data-protocol.md index 797ff75..90cd557 100644 --- a/docs/protocols/common/user-points-chat-data-protocol.md +++ b/docs/protocols/common/user-points-chat-data-protocol.md @@ -4,9 +4,9 @@ This protocol defines the canonical data contract for user profile, points accou Protocol verification status: -- Last audited migration: `backend/alembic/versions/20260410_0005_add_points_audit_and_register_bonus_claims.py` +- Last audited migration: `backend/alembic/versions/20260411_0003_points_audit_and_register_bonus_claims.py` - Last audited models: `backend/src/models/profile.py`, `backend/src/models/user_points.py`, `backend/src/models/points_ledger.py`, `backend/src/models/points_audit_ledger.py`, `backend/src/models/register_bonus_claims.py`, `backend/src/models/agent_chat_session.py`, `backend/src/models/agent_chat_message.py` -- Current status: partially aligned (register bonus still runs in DB trigger, audit ledger tables are additive and ready) +- Current status: aligned with register bonus moved to application service ## Scope @@ -139,16 +139,14 @@ JSON constraints: - `grant`: no extra metadata shape requirement - `adjust`: requires `ext.ticket_id` non-empty -## Signup initialization contract (current + target) +## Signup initialization contract -- Current trigger: - - Trigger: `auth.users` after insert +- DB trigger (`auth.users` after insert): - Function: `public.initialize_profile_and_invite_code_on_signup()` - - Side effects include profile init + invite code init + register points (currently fixed to 60) -- Target migration: - - remove register points grant from DB trigger - - grant register bonus in application service with eligibility ledger `register_bonus_claims` - - keep trigger focused on profile/invite initialization only + - Side effects: profile init + invite code init +- Application service (in `POST /auth/email-session`): + - `grant_register_bonus_if_eligible()` grants register bonus via `register_bonus_claims` ledger + - Bonus amount from `config.points_policy.register_bonus_points` ### sessions diff --git a/docs/protocols/divination/divination-run-protocol.md b/docs/protocols/divination/divination-run-protocol.md index b15d16c..ee09ac6 100644 --- a/docs/protocols/divination/divination-run-protocol.md +++ b/docs/protocols/divination/divination-run-protocol.md @@ -18,9 +18,12 @@ Protocol verification status: ## Route overview - Submit run: `POST /api/v1/agent/runs` +- Cancel run: `POST /api/v1/agent/runs/{threadId}/cancel?runId=...` - Stream events: `GET /api/v1/agent/runs/{threadId}/events?runId=...` - History snapshot: `GET /api/v1/agent/history` - Delete session: `DELETE /api/v1/agent/sessions/{threadId}` +- Upload attachment: `POST /api/v1/agent/attachments` +- Get attachment signed URL: `GET /api/v1/agent/attachments/signed-url?bucket=...&path=...` - Audio transcribe: `POST /api/v1/agent/transcribe` ## Run request contract @@ -81,8 +84,8 @@ Protocol verification status: ### `divinationPayload` strict rules - `divinationMethod`: enum, allowed values `手动起卦 | 自动起卦` -- `questionType`: non-empty string, recommended Chinese category labels -- `question`: non-empty string +- `questionType`: non-empty string, max 32 chars, recommended Chinese category labels +- `question`: non-empty string, max 300 chars - `divinationTimeIso`: RFC3339 datetime with timezone offset - `yaoLines`: exactly 6 items, order is `初爻 -> 上爻` - `yaoLines` item enum: `少阳 | 少阴 | 老阳 | 老阴` @@ -301,6 +304,82 @@ Rules: - Success: `204 No Content`. - Idempotent: already deleted or not found also returns `204 No Content`. +## Run cancel contract + +### `POST /api/v1/agent/runs/{threadId}/cancel?runId=...` + +- Authorization: current user must own the session. +- Request: `runId` query parameter required. +- Success response: + +```json +{ + "threadId": "uuid", + "runId": "run_xxx", + "accepted": true +} +``` + +Error codes (see common registry): + +- `AGENT_SESSION_NOT_FOUND` +- `AGENT_FORBIDDEN` + +## Attachment upload contract + +### `POST /api/v1/agent/attachments` + +- Authorization: authenticated user. +- Request: `multipart/form-data` with fields: + - `threadId` (string, required) + - `file` (binary, required) +- Max file size: 5MB +- Success response: + +```json +{ + "attachment": { + "bucket": "agent-inputs", + "path": "agent-inputs/{userId}/{filename}", + "mimeType": "image/png", + "url": "https://...signed..." + } +} +``` + +Error codes (see common registry): + +- `AGENT_ATTACHMENT_EMPTY` +- `AGENT_ATTACHMENT_TOO_LARGE` +- `AGENT_ATTACHMENT_STORAGE_UNAVAILABLE` +- `AGENT_SESSION_NOT_FOUND` +- `AGENT_FORBIDDEN` + +## Attachment signed URL contract + +### `GET /api/v1/agent/attachments/signed-url?bucket=...&path=...` + +- Authorization: authenticated user. +- Query params: + - `bucket` (string, required) + - `path` (string, required) +- Path must be under `agent-inputs/{userId}/` scope. +- Success response: + +```json +{ + "bucket": "agent-inputs", + "path": "agent-inputs/{userId}/{filename}", + "url": "https://...signed..." +} +``` + +Error codes (see common registry): + +- `AGENT_ATTACHMENT_BUCKET_INVALID` +- `AGENT_ATTACHMENT_PATH_SCOPE_INVALID` +- `AGENT_SIGNED_URL_GENERATION_FAILED` + ## Transcribe contract ### `POST /api/v1/agent/transcribe` diff --git a/docs/protocols/profile/profile-protocol.md b/docs/protocols/profile/profile-protocol.md index 89f94de..cddf9f4 100644 --- a/docs/protocols/profile/profile-protocol.md +++ b/docs/protocols/profile/profile-protocol.md @@ -9,7 +9,7 @@ Protocol verification status: - Backend service source: `backend/src/v1/users/service.py` - Frontend mapping source: `apps/lib/features/settings/data/apis/profile_api.dart` - Storage config source: `backend/src/core/config/settings.py` -- Current status: profile/avatar aligned; account deletion backend implemented (frontend wiring pending) +- Current status: aligned (profile/avatar/account deletion all implemented) ## Compatibility strategy @@ -82,7 +82,7 @@ Rules: - At least one field must be provided. - `display_name` must be non-empty after trim. -- `bio` can be empty string and should be normalized to `null` only if agreed by API implementation. +- `bio`: empty string after trim is normalized to `null`. - `avatar_path` must stay in current user prefix: `avatars/{current_user.id}/`. Response: @@ -208,12 +208,12 @@ Behavior contract: ### Deletion scope (current product contract) -The delete operation must remove data owned by the authenticated user in the following domains: +The delete operation removes data owned by the authenticated user in the following domains: -- Identity: `auth.users` row for current user. -- Profile: `profiles` row. -- Points: `user_points`, `points_ledger` rows linked to user. -- Chat: `sessions`, `messages` rows linked to user/session ownership. +- Identity: `auth.users` row for current user (cascade delete). +- Profile: `profiles` row (FK cascade via `auth.users.id`). +- Points: `user_points`, `points_ledger` rows (FK cascade via `auth.users.id`). +- Chat: `sessions` rows are soft-deleted (`deleted_at` set); `messages` cascade via `sessions.id FK`. After deletion, sessions are hidden from history but not physically removed. - Avatar storage objects under prefix `avatars/{user_id}/`. Notes: