commit 834a240fe32bcbd079e4cc08d4229454258ee4f6 Author: apale7 <1092377056@qq.com> Date: Thu Apr 16 23:46:01 2026 +0800 init with trellis 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..94ed594 --- /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. +> +> **OpenCode dispatch rule**: Call subagents synchronously (`run_in_background: false`). +> Do NOT use `TaskOutput` or background polling as the completion signal for child phases. +> The background wrapper can finish before the real subagent session is actually done. + +### action: "implement" + +``` +Task( + subagent_type: "implement", + prompt: "Implement the feature described in prd.md in the task directory", + model: "opus", + run_in_background: false +) +``` + +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: false +) +``` + +Hook will auto-inject: + +- finish-work.md +- check-cross-layer.md +- check.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: false +) +``` + +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: false +) +``` + +**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 + +``` +result = Task( + subagent_type: "implement", // or "check", "debug" + prompt: "Simple task description", + model: "opus", + run_in_background: false +) + +// Wait for the Task call to return before starting the next phase. +// Do NOT call TaskOutput or use background polling inside OpenCode dispatch. +``` + +### Execution Rule + +- Run one phase at a time +- Start the next phase only after the current `Task(...)` call returns +- If a phase returns a clear timeout or failure, handle that result explicitly +- Do **not** simulate completion by polling a background task wrapper + +--- + +## Error Handling + +### Timeout + +If a synchronous subagent call 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 synchronous subagent call reports failure, read the output and decide: + +- If recoverable: call debug agent to fix +- If not recoverable: notify user and ask for guidance +- Do not switch back to `TaskOutput` polling for the same phase + +--- + +## 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..bb4e024 --- /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: + +- Spec layers: `.trellis/spec///` +- Shared guides: `.trellis/spec/guides/` +- 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..452ee6a --- /dev/null +++ b/.opencode/agents/research.md @@ -0,0 +1,146 @@ +--- +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///` - Package-specific 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-dev.md b/.opencode/commands/trellis/before-dev.md new file mode 100644 index 0000000..d080765 --- /dev/null +++ b/.opencode/commands/trellis/before-dev.md @@ -0,0 +1,29 @@ +Read the relevant development guidelines before starting your task. + +Execute these steps: + +1. **Discover packages and their spec layers**: + ```bash + python3 ./.trellis/scripts/get_context.py --mode packages + ``` + +2. **Identify which specs apply** to your task based on: + - Which package you're modifying (e.g., `cli/`, `docs-site/`) + - What type of work (backend, frontend, unit-test, docs, etc.) + +3. **Read the spec index** for each relevant module: + ```bash + cat .trellis/spec///index.md + ``` + Follow the **"Pre-Development Checklist"** section in the index. + +4. **Read the specific guideline files** listed in the Pre-Development Checklist that are relevant to your task. The index is NOT the goal — it points you to the actual guideline files (e.g., `error-handling.md`, `conventions.md`, `mock-strategies.md`). Read those files to understand the coding standards and patterns. + +5. **Always read shared guides**: + ```bash + cat .trellis/spec/guides/index.md + ``` + +6. Understand the coding standards and patterns you need to follow, then proceed with your development plan. + +This step is **mandatory** before writing any 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-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.md b/.opencode/commands/trellis/check.md new file mode 100644 index 0000000..8e3e272 --- /dev/null +++ b/.opencode/commands/trellis/check.md @@ -0,0 +1,25 @@ +Check if the code you just wrote follows the development guidelines. + +Execute these steps: + +1. **Identify changed files**: + ```bash + git diff --name-only HEAD + ``` + +2. **Determine which spec modules apply** based on the changed file paths: + ```bash + python3 ./.trellis/scripts/get_context.py --mode packages + ``` + +3. **Read the spec index** for each relevant module: + ```bash + cat .trellis/spec///index.md + ``` + Follow the **"Quality Check"** section in the index. + +4. **Read the specific guideline files** referenced in the Quality Check section (e.g., `quality-guidelines.md`, `conventions.md`). The index is NOT the goal — it points you to the actual guideline files. Read those files and review your code against them. + +5. **Run lint and typecheck** for the affected package. + +6. **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..c47e4b6 --- /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-dev` | +| Check | `check-` | `check` | +| 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..2d4dabf --- /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-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. Discovers spec layers via `get_context.py --mode packages` and reads relevant guidelines +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 - 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-dev** - Inject project-specific development guidelines +**[4/8] Investigate and fix the bug** - Actual development work +**[5/8] /trellis:check** - 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-dev** - Re-inject guidelines before fixes +**[3/6] Fix each CR issue** - Address feedback with guidelines in context +**[4/6] /trellis:check** - Verify fixes did not 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 /trellis: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-dev** - Guidelines might document known gotchas +**[3/6] Investigation** - Actual debugging work +**[4/6] /trellis:check** - Verify debug changes do not 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 command injects 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..5be27ef --- /dev/null +++ b/.opencode/commands/trellis/parallel.md @@ -0,0 +1,193 @@ +# 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 +python3 ./.trellis/scripts/get_context.py --mode packages # Discover available spec layers +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..8bea4f9 --- /dev/null +++ b/.opencode/commands/trellis/record-session.md @@ -0,0 +1,62 @@ +[!] **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 --stdin --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] Auto-detects Branch context (`--branch` override; otherwise Branch = task.json -> current git branch; missing values are omitted gracefully) +- [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, branch auto-complete)** | +| `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..a39ce1a --- /dev/null +++ b/.opencode/commands/trellis/start.md @@ -0,0 +1,351 @@ +# 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 +python3 ./.trellis/scripts/get_context.py --mode packages +``` + +This shows available packages and their spec layers. Read the relevant spec indexes: + +```bash +cat .trellis/spec/<package>/<layer>/index.md # Package-specific guidelines +cat .trellis/spec/guides/index.md # Thinking guides (always read) +``` + +> **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..51a10a7 --- /dev/null +++ b/.opencode/lib/trellis-context.js @@ -0,0 +1,232 @@ +/** + * Trellis Context Manager + * + * Utility class for OpenCode plugins providing file reading, + * JSONL parsing, and context building capabilities. + */ + +import { existsSync, readFileSync, appendFileSync, readdirSync } from "fs" +import { isAbsolute, join } from "path" +import { platform } from "os" +import { execSync } from "child_process" + +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 + */ +export class TrellisContext { + constructor(directory) { + this.directory = directory + debugLog("context", "TrellisContext initialized", { directory }) + } + + // ============================================================ + // Trellis Project Detection + // ============================================================ + + 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 + } + const taskRef = readFileSync(currentTaskPath, "utf-8").trim() + const normalized = this.normalizeTaskRef(taskRef) + return normalized || null + } catch { + return null + } + } + + normalizeTaskRef(taskRef) { + if (!taskRef) { + return "" + } + + if (isAbsolute(taskRef)) { + return taskRef.trim() + } + + let normalized = taskRef.trim().replace(/\\/g, "/") + while (normalized.startsWith("./")) { + normalized = normalized.slice(2) + } + + if (normalized.startsWith("tasks/")) { + return `.trellis/${normalized}` + } + + return normalized + } + + resolveTaskDir(taskRef) { + const normalized = this.normalizeTaskRef(taskRef) + if (!normalized) { + return null + } + + if (isAbsolute(normalized)) { + return normalized + } + + if (normalized.startsWith(".trellis/")) { + return join(this.directory, normalized) + } + + return join(this.directory, ".trellis", "tasks", normalized) + } + + // ============================================================ + // File Reading Utilities + // ============================================================ + + readFile(filePath) { + try { + if (existsSync(filePath)) { + return readFileSync(filePath, "utf-8") + } + } catch { + // Ignore read errors + } + return null + } + + readProjectFile(relativePath) { + return this.readFile(join(this.directory, relativePath)) + } + + 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 + // ============================================================ + + 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") { + const dirEntries = this.readDirectoryMdFiles(file) + results.push(...dirEntries) + } else { + 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 + } + + buildContextFromEntries(entries) { + return entries.map(e => `=== ${e.path} ===\n${e.content}`).join("\n\n") + } +} + +// ============================================================ +// Context Collector (for session deduplication) +// ============================================================ + +class ContextCollector { + constructor() { + this.processed = new Set() + } + + markProcessed(sessionID) { + this.processed.add(sessionID) + } + + isProcessed(sessionID) { + return this.processed.has(sessionID) + } + + clear(sessionID) { + this.processed.delete(sessionID) + } +} + +// Singleton instance +export const contextCollector = new ContextCollector() + +// Export debug log for plugins +export { debugLog } diff --git a/.opencode/package.json b/.opencode/package.json new file mode 100644 index 0000000..4f6bdbe --- /dev/null +++ b/.opencode/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "@opencode-ai/plugin": "1.1.40" + } +} \ No newline at end of file diff --git a/.opencode/plugins/inject-subagent-context.js b/.opencode/plugins/inject-subagent-context.js new file mode 100644 index 0000000..b73a003 --- /dev/null +++ b/.opencode/plugins/inject-subagent-context.js @@ -0,0 +1,515 @@ +/** + * Trellis Context Injection Plugin + * + * Injects context when Task tool is called with supported subagent types. + * Uses OpenCode's tool.execute.before hook. + */ + +import { existsSync, writeFileSync, readdirSync } 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 || [] + + const actionToAgent = { + "implement": "implement", + "check": "check", + "finish": "check" + } + + let newPhase = null + for (const action of nextActions) { + const phaseNum = action.phase || 0 + const actionName = action.action || "" + const expectedAgent = actionToAgent[actionName] + + 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 = [] + + let jsonlPath = join(ctx.directory, taskDir, "implement.jsonl") + let entries = ctx.readJsonlWithFiles(jsonlPath) + + if (entries.length === 0) { + jsonlPath = join(ctx.directory, taskDir, "spec.jsonl") + entries = ctx.readJsonlWithFiles(jsonlPath) + } + + if (entries.length > 0) { + parts.push(ctx.buildContextFromEntries(entries)) + } + + const prd = ctx.readProjectFile(join(taskDir, "prd.md")) + if (prd) { + parts.push(`=== ${taskDir}/prd.md (Requirements) ===\n${prd}`) + } + + 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 = [] + + const jsonlPath = join(ctx.directory, taskDir, "check.jsonl") + const entries = ctx.readJsonlWithFiles(jsonlPath) + + if (entries.length > 0) { + parts.push(ctx.buildContextFromEntries(entries)) + } else { + 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.md", "Check spec"], + ] + for (const [f, description] of checkFiles) { + const content = ctx.readProjectFile(f) + if (content) { + parts.push(`=== ${f} (${description}) ===\n${content}`) + } + } + + 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 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 = [] + + const jsonlPath = join(ctx.directory, taskDir, "finish.jsonl") + const entries = ctx.readJsonlWithFiles(jsonlPath) + + if (entries.length > 0) { + parts.push(ctx.buildContextFromEntries(entries)) + } else { + const finishWork = ctx.readProjectFile(".opencode/commands/trellis/finish-work.md") + if (finishWork) { + parts.push(`=== .opencode/commands/trellis/finish-work.md (Finish checklist) ===\n${finishWork}`) + } + } + + 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}`) + } + + 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 = [] + + const jsonlPath = join(ctx.directory, taskDir, "debug.jsonl") + const entries = ctx.readJsonlWithFiles(jsonlPath) + + if (entries.length > 0) { + parts.push(ctx.buildContextFromEntries(entries)) + } else { + 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.md", "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}`) + } + } + } + + 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 = [] + + // Dynamic project structure (scan actual spec directory) + const specPath = ".trellis/spec" + const specFull = join(ctx.directory, specPath) + + const structureLines = [`## Project Spec Directory Structure\n\n\`\`\`\n${specPath}/`] + if (existsSync(specFull)) { + try { + const entries = readdirSync(specFull, { withFileTypes: true }) + .filter(d => d.isDirectory() && !d.name.startsWith(".")) + .sort((a, b) => a.name.localeCompare(b.name)) + + for (const entry of entries) { + const entryPath = join(specFull, entry.name) + if (existsSync(join(entryPath, "index.md"))) { + structureLines.push(`├── ${entry.name}/`) + } else { + try { + const nested = readdirSync(entryPath, { withFileTypes: true }) + .filter(d => d.isDirectory() && existsSync(join(entryPath, d.name, "index.md"))) + .sort((a, b) => a.name.localeCompare(b.name)) + if (nested.length > 0) { + structureLines.push(`├── ${entry.name}/`) + for (const n of nested) { + structureLines.push(`│ ├── ${n.name}/`) + } + } + } catch { + // Ignore nested read errors + } + } + } + } catch { + // Ignore read errors + } + } + structureLines.push("```") + + parts.push(structureLines.join("\n") + ` + +## 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 researchEntries = ctx.readJsonlWithFiles(jsonlPath) + if (researchEntries.length > 0) { + parts.push("\n## Additional Search Context\n") + parts.push(ctx.buildContextFromEntries(researchEntries)) + } + } + + 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 { + id: "trellis.inject-subagent-context", + server: async ({ directory }) => { + const ctx = new TrellisContext(directory) + debugLog("inject", "Plugin loaded, directory:", directory) + + return { + "tool.execute.before": async (input, output) => { + try { + debugLog("inject", "tool.execute.before called, tool:", input?.tool) + + const toolName = input?.tool?.toLowerCase() + if (toolName !== "task") { + return + } + + const args = output?.args + if (!args) return + + const subagentType = args.subagent_type + const originalPrompt = args.prompt || "" + + debugLog("inject", "Task tool called, subagent_type:", subagentType) + + if (!AGENTS_ALL.includes(subagentType)) { + debugLog("inject", "Skipping - unsupported subagent_type") + 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 + } + + 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": + 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 + } + + const newPrompt = buildPrompt(subagentType, originalPrompt, context, isFinish) + + // Mutate args in-place — whole-object replacement does NOT work for the task tool + // because the runtime holds a local reference to the same args object. + 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/plugins/session-start.js b/.opencode/plugins/session-start.js new file mode 100644 index 0000000..85377f3 --- /dev/null +++ b/.opencode/plugins/session-start.js @@ -0,0 +1,477 @@ +/* global process */ +/** + * Trellis Session Start Plugin + * + * Injects context when user sends the first message in a session. + * Uses OpenCode's chat.message hook directly so the context persists in history. + */ + +import { existsSync, readFileSync, readdirSync, statSync } from "fs" +import { basename, join } from "path" +import { execFileSync } from "child_process" +import { platform } from "os" +import { TrellisContext, contextCollector, debugLog } from "../lib/trellis-context.js" + +const PYTHON_CMD = platform() === "win32" ? "python" : "python3" + + +/** + * Check current task status and return structured status string. + * JavaScript equivalent of _get_task_status in Claude's session-start.py. + */ +function getTaskStatus(ctx) { + const taskRef = ctx.getCurrentTask() + if (!taskRef) { + return "Status: NO ACTIVE TASK\nNext: Describe what you want to work on" + } + + const taskDir = ctx.resolveTaskDir(taskRef) + + if (!taskDir || !existsSync(taskDir)) { + return `Status: STALE POINTER\nTask: ${taskRef}\nNext: Task directory not found. Run: python3 ./.trellis/scripts/task.py finish` + } + + 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 = basename(taskDir) + return `Status: COMPLETED\nTask: ${taskTitle}\nNext: Archive with \`python3 ./.trellis/scripts/task.py archive ${dirName}\` or start a new task` + } + + 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` +} + +/** + * Load Trellis config for session-start decisions. + * Calls get_context.py --mode packages --json for reliable config data. + */ +function loadTrellisConfig(directory) { + const scriptPath = join(directory, ".trellis", "scripts", "get_context.py") + if (!existsSync(scriptPath)) { + return { isMonorepo: false, packages: {}, specScope: null, activeTaskPackage: null, defaultPackage: null } + } + try { + const output = execFileSync(PYTHON_CMD, [scriptPath, "--mode", "packages", "--json"], { + cwd: directory, + timeout: 5000, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }) + const data = JSON.parse(output) + if (data.mode !== "monorepo") { + return { isMonorepo: false, packages: {}, specScope: null, activeTaskPackage: null, defaultPackage: null } + } + const pkgDict = {} + for (const pkg of (data.packages || [])) { + pkgDict[pkg.name] = pkg + } + return { + isMonorepo: true, + packages: pkgDict, + specScope: data.specScope || null, + activeTaskPackage: data.activeTaskPackage || null, + defaultPackage: data.defaultPackage || null, + } + } catch (e) { + debugLog("session", "loadTrellisConfig error:", e.message) + return { isMonorepo: false, packages: {}, specScope: null, activeTaskPackage: null, defaultPackage: null } + } +} + + +/** + * Check for legacy spec directory structure in monorepo. + */ +function checkLegacySpec(directory, config) { + if (!config.isMonorepo || Object.keys(config.packages).length === 0) { + return null + } + + const specDir = join(directory, ".trellis", "spec") + if (!existsSync(specDir)) return null + + let hasLegacy = false + for (const name of ["backend", "frontend"]) { + if (existsSync(join(specDir, name, "index.md"))) { + hasLegacy = true + break + } + } + if (!hasLegacy) return null + + const pkgNames = Object.keys(config.packages).sort() + const missing = pkgNames.filter(name => !existsSync(join(specDir, name))) + + if (missing.length === 0) return null + + if (missing.length === pkgNames.length) { + return ( + `[!] Legacy spec structure detected: found \`spec/backend/\` or \`spec/frontend/\` ` + + `but no package-scoped \`spec/<package>/\` directories.\n` + + `Monorepo packages: ${pkgNames.join(", ")}\n` + + `Please reorganize: \`spec/backend/\` -> \`spec/<package>/backend/\`` + ) + } + return ( + `[!] Partial spec migration detected: packages ${missing.join(", ")} ` + + `still missing \`spec/<pkg>/\` directory.\n` + + `Please complete migration for all packages.` + ) +} + + +/** + * Resolve which packages should have their specs injected. + * Returns a Set of allowed package names, or null for full scan. + */ +function resolveSpecScope(config) { + if (!config.isMonorepo || Object.keys(config.packages).length === 0) { + return null + } + + const { specScope, activeTaskPackage, defaultPackage, packages } = config + if (specScope == null) return null + + if (specScope === "active_task") { + if (activeTaskPackage && activeTaskPackage in packages) return new Set([activeTaskPackage]) + if (defaultPackage && defaultPackage in packages) return new Set([defaultPackage]) + return null + } + + if (Array.isArray(specScope)) { + const valid = new Set() + for (const entry of specScope) { + if (entry in packages) { + valid.add(entry) + } + } + if (valid.size > 0) return valid + if (activeTaskPackage && activeTaskPackage in packages) return new Set([activeTaskPackage]) + if (defaultPackage && defaultPackage in packages) return new Set([defaultPackage]) + return null + } + + return null +} + + +/** + * Build session context for injection + */ +function buildSessionContext(ctx) { + const directory = ctx.directory + const trellisDir = join(directory, ".trellis") + + const config = loadTrellisConfig(directory) + const allowedPkgs = resolveSpecScope(config) + + 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>`) + + // Legacy migration warning + const legacyWarning = checkLegacySpec(directory, config) + if (legacyWarning) { + parts.push(`<migration-warning>\n${legacyWarning}\n</migration-warning>`) + } + + // 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 (ToC only — lazy-load the full file on demand) + const workflowContent = ctx.readProjectFile(".trellis/workflow.md") + if (workflowContent) { + const tocLines = [ + "# Development Workflow — Section Index", + "Full guide: .trellis/workflow.md (read on demand)", + "", + ] + for (const line of workflowContent.split("\n")) { + if (line.startsWith("## ")) tocLines.push(line) + } + tocLines.push("", "To read a section: use the Read tool on .trellis/workflow.md") + parts.push("<workflow>") + parts.push(tocLines.join("\n")) + 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) { + if (sub === "guides") { + const indexFile = join(specDir, sub, "index.md") + if (existsSync(indexFile)) { + const content = ctx.readFile(indexFile) + if (content) { + parts.push(`## ${sub}\n${content}\n`) + } + } + continue + } + + const indexFile = join(specDir, sub, "index.md") + if (existsSync(indexFile)) { + const content = ctx.readFile(indexFile) + if (content) { + parts.push(`## ${sub}\n${content}\n`) + } + } else { + if (allowedPkgs !== null && !allowedPkgs.has(sub)) { + continue + } + 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>") + + // 6. Task status + const taskStatus = getTaskStatus(ctx) + parts.push(`<task-status>\n${taskStatus}\n</task-status>`) + + // 7. Final directive + parts.push(`<ready> +Context loaded. Workflow index, project state, and guidelines are already injected above — do NOT re-read them. +Wait for the user's first message, then handle it following the workflow guide. +If there is an active task, ask whether to continue it. +</ready>`) + + return parts.join("\n\n") +} + +function getTrellisMetadata(metadata) { + if (!metadata || typeof metadata !== "object") { + return {} + } + + const trellis = metadata.trellis + if (!trellis || typeof trellis !== "object") { + return {} + } + + return trellis +} + +function markPartAsSessionStart(part) { + const metadata = part.metadata && typeof part.metadata === "object" + ? part.metadata + : {} + part.metadata = { + ...metadata, + trellis: { + ...getTrellisMetadata(metadata), + sessionStart: true, + }, + } +} + +function hasSessionStartMarker(part) { + if (!part || part.type !== "text" || typeof part.text !== "string") { + return false + } + + return getTrellisMetadata(part.metadata).sessionStart === true +} + +export function hasInjectedTrellisContext(messages) { + if (!Array.isArray(messages)) { + return false + } + + return messages.some(message => { + if (!message?.info || message.info.role !== "user" || !Array.isArray(message.parts)) { + return false + } + + return message.parts.some(hasSessionStartMarker) + }) +} + +async function hasPersistedInjectedContext(client, directory, sessionID) { + try { + const response = await client.session.messages({ + path: { id: sessionID }, + query: { directory }, + throwOnError: true, + }) + return hasInjectedTrellisContext(response.data || []) + } catch (error) { + debugLog( + "session", + "Failed to read session history for dedupe:", + error instanceof Error ? error.message : String(error), + ) + return false + } +} + +export default { + id: "trellis.session-start", + server: async ({ directory, client }) => { + const ctx = new TrellisContext(directory) + debugLog("session", "Plugin loaded, directory:", directory) + + return { + // Clear in-memory dedupe after compaction so context can be re-injected. + event: ({ event }) => { + try { + if (event?.type === "session.compacted" && event?.properties?.sessionID) { + const sessionID = event.properties.sessionID + contextCollector.clear(sessionID) + debugLog("session", "Cleared processed flag after compaction for session:", sessionID) + } + } catch (error) { + debugLog( + "session", + "Error in event hook:", + error instanceof Error ? error.message : String(error), + ) + } + }, + + // chat.message - triggered when user sends a message. + // Modify the message in-place so the context is persisted with updateMessage/updatePart. + "chat.message": async (input, output) => { + 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 + } + + // Only inject on first message + if (contextCollector.isProcessed(sessionID)) { + debugLog("session", "Skipping - session already processed") + return + } + + if (await hasPersistedInjectedContext(client, ctx.directory, sessionID)) { + contextCollector.markProcessed(sessionID) + debugLog("session", "Skipping - session already contains persisted Trellis context") + return + } + + // Build context + const context = buildSessionContext(ctx) + debugLog("session", "Built context, length:", context.length) + + // Inject context directly into output.parts so it gets persisted by updatePart + const parts = output?.parts || [] + const textPartIndex = parts.findIndex( + p => p.type === "text" && p.text !== undefined + ) + + if (textPartIndex !== -1) { + const originalText = parts[textPartIndex].text || "" + parts[textPartIndex].text = `${context}\n\n---\n\n${originalText}` + markPartAsSessionStart(parts[textPartIndex]) + debugLog("session", "Injected context into chat.message text part, length:", context.length) + } else { + // No existing text part: prepend a new one + const injectedPart = { type: "text", text: context } + markPartAsSessionStart(injectedPart) + parts.unshift(injectedPart) + debugLog("session", "Prepended new text part with context, length:", context.length) + } + + contextCollector.markProcessed(sessionID) + + } catch (error) { + debugLog("session", "Error in chat.message:", 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..ab257c8 --- /dev/null +++ b/.trellis/.template-hashes.json @@ -0,0 +1,66 @@ +{ + ".trellis/config.yaml": "cb5216826e1091bfa0d4ebae257d6f43fa60ca2bafbf3d9a6489b2c1a51a99df", + ".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c", + ".trellis/scripts/add_session.py": "a97a6c88ff7def8045a5dffa5c698a823392d7f73c1641e8a0c08db0168bd913", + ".trellis/scripts/common/__init__.py": "a8afa14ebe662723f96e4f5757c15359d76adf4cb5c52327c94dbe854bd1ab01", + ".trellis/scripts/common/cli_adapter.py": "d9bc50508918c5a2e17ce48384de5e1d9f7c2e32b0d57019d1bec973728d445f", + ".trellis/scripts/common/config.py": "1244bccc9d3e1e1c304f914ea913f2f4a0b6d54571e6845633d586c2139a07bf", + ".trellis/scripts/common/developer.py": "f5f833123abe68890171b4da825a324216d24913f6b5ad9245afc556424ffd7b", + ".trellis/scripts/common/git.py": "e14817be7de122d3a106f509c2825aeb9669d962ba73ba241642d2931cfdf1d6", + ".trellis/scripts/common/git_context.py": "5a01f7209c4a85ec768c88e2cb2a02a40ff279ea26139a390d16bf7576efce9f", + ".trellis/scripts/common/io.py": "6480b181f2bc505323b28ed7a66963d7b7edc96251e83b4c8e7a45907cc721c8", + ".trellis/scripts/common/log.py": "471df6895cfac80f995edebbf9974f6b7440634b7a688f28b8331c868bc0f3cf", + ".trellis/scripts/common/packages_context.py": "efe158d7c99c2268851d0216fbb08de22836e418a8dbeb73575b8cc249eed7b7", + ".trellis/scripts/common/paths.py": "36f72bdc09e4f0db53250346a4744ff3699c634ea71380eed5b467095f3d946b", + ".trellis/scripts/common/phase.py": "412b7096ef0e48b8a95a79060121a586e0d9d44f1b350d6ed818c6f84330bb01", + ".trellis/scripts/common/registry.py": "e1f4480ec264734eb4ef6867edc42fce2e2535045e5cff835fb81e0443d673e6", + ".trellis/scripts/common/session_context.py": "2389eff1a66b172783fcb714a79385114d9b29746133a3e0db732c3b5cb23898", + ".trellis/scripts/common/task_context.py": "8aad7107a7f86e7ddc381a7358364a6176c61d619ee2094cb698c24a0d5ab214", + ".trellis/scripts/common/task_queue.py": "0be61f713462b1fe4574927c82fc4704e678afe72dcb9813543aedf2f9e9e0c5", + ".trellis/scripts/common/task_store.py": "79e6257a0bedfd854fbfb5bdb3898a81181bcd700bc03ae3a970a032f7793610", + ".trellis/scripts/common/task_utils.py": "f5ef4af87ba3e11d8b19630c0c96d009de1811fc9be56c2027a9c96e21ed103e", + ".trellis/scripts/common/tasks.py": "eeefae693dadec54c8945394e288e90ed1e8f79545dfb2d4934a431496f5229d", + ".trellis/scripts/common/types.py": "d623ada858d3cb55093c5c95c9ce182b99b5865b7441742297542fad3b21cc06", + ".trellis/scripts/common/worktree.py": "434880e02dfa2e92f0c717ed2a28e4cdee681ea10c329a2438d533bdbc612408", + ".trellis/scripts/create_bootstrap.py": "33b40df671ba7828fd8d3ba8c019823a8b03e938797b1cae218c55d6c7ebe57a", + ".trellis/scripts/get_context.py": "ca5bf9e90bdb1d75d3de182b95f820f9d108ab28793d29097b24fd71315adcf5", + ".trellis/scripts/get_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f", + ".trellis/scripts/hooks/linear_sync.py": "e09cc4ce4699aada908808718698f33f705a3edf55c4dcf8f777ad892f80ca79", + ".trellis/scripts/init_developer.py": "f9e6c0d882406e81c8cd6b1c5abb204b0befc0069ff89cf650cd536a80f8c60e", + ".trellis/scripts/multi_agent/__init__.py": "af6fceb4d9a64da04be03ba0f5a6daf71066503eca832b8b58d8a7d4b2844fa4", + ".trellis/scripts/multi_agent/_bootstrap.py": "4d0c06e41ba17e33172974783719731551607400de0c751c13414fed9d0c8c30", + ".trellis/scripts/multi_agent/cleanup.py": "046ad29aa533e41d8952bf02c2dcfcdab2755002222d92455d194ef97a6e82e2", + ".trellis/scripts/multi_agent/create_pr.py": "03018c0c50a45758c28da5751afea1822be0acffe9053587cdf9d514a83ae27e", + ".trellis/scripts/multi_agent/plan.py": "011481ad0024a91f6a9e16535c6f5c4c7ba8eb311c46428104f1aeea7fc934e7", + ".trellis/scripts/multi_agent/start.py": "01e7f81fac53078a24a9fda50dc3000c2cee49179e05b8f7284747eebec1a543", + ".trellis/scripts/multi_agent/status.py": "06b3d2c5a9f7bea884962ace3b25113a3b01bf50dc12ad2f473e4f0c914fff7e", + ".trellis/scripts/multi_agent/status_display.py": "d432446644b07dcbea7fd3aeba1d31ae42a9c664e91eebbdab503fbb698bccac", + ".trellis/scripts/multi_agent/status_monitor.py": "11ba35180a568aa4d14ccaee81cc213ff3d5ab83025f264ac57ca70385f11f4c", + ".trellis/scripts/task.py": "77cd33a8c58803cd7dbe64bb9c045d6c93c1fa1453bed1e286d6f62bc329607e", + ".trellis/workflow.md": "892242e83f53194f49f7326ed9b50a7fc72ff810cd316c4d03ea8e7f11f5bbbd", + ".trellis/worktree.yaml": "c57de79e40d5f748f099625ed4a17d5f0afbf25cac598aced0b3c964e7b7c226", + ".opencode/agents/check.md": "39763ef458f95a2b38c0fbc9cd79df8c66909086e0ace4e4e80c536f58d09aed", + ".opencode/agents/debug.md": "0bac1d723fb3634ea95c471a22245eff2b4c9d6bd98bc66cafacf6a0092609bb", + ".opencode/agents/dispatch.md": "f1e90f5967c7c4d61fce58df2a1e8592d1bfd4e76f2d1761916b8dc3b0b435b9", + ".opencode/agents/implement.md": "6dcf015cec12f004dd97d13ee3c904f9c6af9fb30f57f30a33b1db38a0c37725", + ".opencode/agents/research.md": "6238919a015391970f30f806b9fb6f3b34f3a211733b728104652e717c35576e", + ".opencode/agents/trellis-plan.md": "36de06c7eddbff290acb3c200f30af96291048e492ce2f2d8b7038662eeb572b", + ".opencode/commands/trellis/before-dev.md": "dd926596f3139c12d42469fb5147ac90724e3a7baca5591384f4f4bbdd530b54", + ".opencode/commands/trellis/brainstorm.md": "7c7731eda092275a5d87f2569a69584f3c39b544a126a76e727a1e9d250c4a65", + ".opencode/commands/trellis/break-loop.md": "ba4dd4022dde1e4bbcfc1cc99e6a118e51b9db95bd962d88f1c29d0c9c433112", + ".opencode/commands/trellis/check-cross-layer.md": "b9ab24515ead84330d6634f6ad912ca3547db3a36139d62c5688161824097d60", + ".opencode/commands/trellis/check.md": "8b0d20b425b6030d13ac5aa0c876c5ec97cf7aca9b050f574f07f281ad25bd06", + ".opencode/commands/trellis/create-command.md": "b3c3ad4e34113cf67af8a94ac78d7d32078e93d318c23a1c27596944b4cb2c1d", + ".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": "cf9591fcddc412ff80772bf441c8d94d7724e6713fdf38a04a3348ab8949e64e", + ".opencode/commands/trellis/parallel.md": "9c383b9622c6bedc45f4184cc05f73a0e5087a1d88072e31ad84703d07a14c70", + ".opencode/commands/trellis/record-session.md": "33b5626fcf03a57578f46133b2a14c6bbe19c4ef29652af3f828f24f448f5926", + ".opencode/commands/trellis/start.md": "84e76dea69542515e234f8b551491ca6bb31fe441ef702a43ff12c3b8397d5fe", + ".opencode/commands/trellis/update-spec.md": "ff4d5a0405a763e61936f5b9df175fd25ea20ec5c20fa999855020ab78a919b6", + ".opencode/lib/trellis-context.js": "a752ed614d31ba15fd445aa61b1751d45bfd583b3d7a096d250e84222901e317", + ".opencode/package.json": "6867538b2f6025286a70c943d15db4191d2412daa8bf7431a846b00c863f26b8", + ".opencode/plugins/inject-subagent-context.js": "01a25c036bc4340b66fefce47c87b7ddb0f54ca27557edaae1e769e5e82bf4ea", + ".opencode/plugins/session-start.js": "c5fa1b619ff3b28c3a014001913530216436501d84008b0464d65ff30b17925c" +} \ No newline at end of file diff --git a/.trellis/.version b/.trellis/.version new file mode 100644 index 0000000..60a2d3e --- /dev/null +++ b/.trellis/.version @@ -0,0 +1 @@ +0.4.0 \ No newline at end of file diff --git a/.trellis/config.yaml b/.trellis/config.yaml new file mode 100644 index 0000000..17c7d59 --- /dev/null +++ b/.trellis/config.yaml @@ -0,0 +1,53 @@ +# 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'" + +#------------------------------------------------------------------------------- +# Monorepo / Packages +#------------------------------------------------------------------------------- + +# Declare packages for monorepo projects. +# Trellis auto-detects workspaces during `trellis init`, but you can also +# configure them manually here. +# +# packages: +# frontend: +# path: packages/frontend +# backend: +# path: packages/backend +# docs: +# path: docs-site +# type: submodule + +# Default package used when --package is not specified. +# default_package: frontend 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..be2c005 --- /dev/null +++ b/.trellis/scripts/add_session.py @@ -0,0 +1,521 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Add a new session to journal file and update index.md. + +Usage: + python3 add_session.py --title "Title" --commit "hash" --summary "Summary" [--package cli] + python3 add_session.py --title "Title" --branch "feat/my-branch" + + # Pipe detailed content via stdin (use --stdin to opt in): + cat << 'EOF' | python3 add_session.py --stdin --title "Title" --summary "Summary" + <session content here> + EOF + +Branch resolution order: + 1. --branch CLI arg (explicit) + 2. task.json branch field (from active task) + 3. git branch --show-current (auto-detect) + 4. None (omitted gracefully) +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +from common.paths import ( + FILE_JOURNAL_PREFIX, + get_repo_root, + get_current_task, + get_developer, + get_workspace_dir, +) +from common.developer import ensure_developer +from common.git import run_git +from common.tasks import load_task +from common.config import ( + get_packages, + get_session_commit_message, + get_max_journal_lines, + is_monorepo, + resolve_package, + validate_package, +) + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def get_latest_journal_info(dev_dir: Path) -> tuple[Path | None, int, int]: + """Get latest journal file info. + + Returns: + Tuple of (file_path, file_number, line_count). + """ + latest_file: Path | None = None + latest_num = -1 + + for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"): + if not f.is_file(): + continue + + match = re.search(r"(\d+)$", f.stem) + if match: + num = int(match.group(1)) + if num > latest_num: + latest_num = num + latest_file = f + + if latest_file: + lines = len(latest_file.read_text(encoding="utf-8").splitlines()) + return latest_file, latest_num, lines + + return None, 0, 0 + + +def get_current_session(index_file: Path) -> int: + """Get current session number from index.md.""" + if not index_file.is_file(): + return 0 + + content = index_file.read_text(encoding="utf-8") + for line in content.splitlines(): + if "Total Sessions" in line: + match = re.search(r":\s*(\d+)", line) + if match: + return int(match.group(1)) + return 0 + + +def _extract_journal_num(filename: str) -> int: + """Extract journal number from filename for sorting.""" + match = re.search(r"(\d+)", filename) + return int(match.group(1)) if match else 0 + + +def count_journal_files(dev_dir: Path, active_num: int) -> str: + """Count journal files and return table rows.""" + active_file = f"{FILE_JOURNAL_PREFIX}{active_num}.md" + result_lines = [] + + files = sorted( + [f for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md") if f.is_file()], + key=lambda f: _extract_journal_num(f.stem), + reverse=True + ) + + for f in files: + filename = f.name + lines = len(f.read_text(encoding="utf-8").splitlines()) + status = "Active" if filename == active_file else "Archived" + result_lines.append(f"| `{filename}` | ~{lines} | {status} |") + + return "\n".join(result_lines) + + +def create_new_journal_file( + dev_dir: Path, num: int, developer: str, today: str, max_lines: int = 2000, +) -> Path: + """Create a new journal file.""" + prev_num = num - 1 + new_file = dev_dir / f"{FILE_JOURNAL_PREFIX}{num}.md" + + content = f"""# Journal - {developer} (Part {num}) + +> Continuation from `{FILE_JOURNAL_PREFIX}{prev_num}.md` (archived at ~{max_lines} lines) +> Started: {today} + +--- + +""" + new_file.write_text(content, encoding="utf-8") + return new_file + + +def generate_session_content( + session_num: int, + title: str, + commit: str, + summary: str, + extra_content: str, + today: str, + package: str | None = None, + branch: str | None = None, +) -> str: + """Generate session content.""" + if commit and commit != "-": + commit_table = """| Hash | Message | +|------|---------|""" + for c in commit.split(","): + c = c.strip() + commit_table += f"\n| `{c}` | (see git log) |" + else: + commit_table = "(No commits - planning session)" + + package_line = f"\n**Package**: {package}" if package else "" + branch_line = f"\n**Branch**: `{branch}`" if branch else "" + + return f""" + +## Session {session_num}: {title} + +**Date**: {today} +**Task**: {title}{package_line}{branch_line} + +### Summary + +{summary} + +### Main Changes + +{extra_content} + +### Git Commits + +{commit_table} + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete +""" + + +def update_index( + index_file: Path, + dev_dir: Path, + title: str, + commit: str, + new_session: int, + active_file: str, + today: str, + branch: str | None = None, +) -> bool: + """Update index.md with new session info.""" + # Format commit for display + commit_display = "-" + if commit and commit != "-": + commit_display = re.sub(r"([a-f0-9]{7,})", r"`\1`", commit.replace(",", ", ")) + + # Get file number from active_file name + match = re.search(r"(\d+)", active_file) + active_num = int(match.group(1)) if match else 0 + files_table = count_journal_files(dev_dir, active_num) + + print(f"Updating index.md for session {new_session}...") + print(f" Title: {title}") + print(f" Commit: {commit_display}") + print(f" Active File: {active_file}") + print() + + content = index_file.read_text(encoding="utf-8") + + if "@@@auto:current-status" not in content: + print("Error: Markers not found in index.md. Please ensure markers exist.", file=sys.stderr) + return False + + # Process sections + lines = content.splitlines() + new_lines = [] + + in_current_status = False + in_active_documents = False + in_session_history = False + header_written = False + + for line in lines: + if "@@@auto:current-status" in line: + new_lines.append(line) + in_current_status = True + new_lines.append(f"- **Active File**: `{active_file}`") + new_lines.append(f"- **Total Sessions**: {new_session}") + new_lines.append(f"- **Last Active**: {today}") + continue + + if "@@@/auto:current-status" in line: + in_current_status = False + new_lines.append(line) + continue + + if "@@@auto:active-documents" in line: + new_lines.append(line) + in_active_documents = True + new_lines.append("| File | Lines | Status |") + new_lines.append("|------|-------|--------|") + new_lines.append(files_table) + continue + + if "@@@/auto:active-documents" in line: + in_active_documents = False + new_lines.append(line) + continue + + if "@@@auto:session-history" in line: + new_lines.append(line) + in_session_history = True + header_written = False + continue + + if "@@@/auto:session-history" in line: + in_session_history = False + new_lines.append(line) + continue + + if in_current_status: + continue + + if in_active_documents: + continue + + if in_session_history: + # Migrate old 4/6-column headers to 5-column Branch-only history. + if re.match( + r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*Base Branch\s*\|\s*$", + line, + ): + new_lines.append("| # | Date | Title | Commits | Branch |") + continue + if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*$", line): + new_lines.append("| # | Date | Title | Commits | Branch |") + continue + if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*$", line): + new_lines.append("| # | Date | Title | Commits | Branch |") + continue + if re.match(r"^\|[-| ]+\|\s*$", line) and not header_written: + new_lines.append("|---|------|-------|---------|--------|") + new_lines.append(f"| {new_session} | {today} | {title} | {commit_display} | `{branch or '-'}` |") + header_written = True + continue + new_lines.append(line) + continue + + new_lines.append(line) + + index_file.write_text("\n".join(new_lines), encoding="utf-8") + print("[OK] Updated index.md successfully!") + return True + + +# ============================================================================= +# Main Function +# ============================================================================= + +def _auto_commit_workspace(repo_root: Path) -> None: + """Stage .trellis/workspace and .trellis/tasks, then commit with a configured message.""" + commit_msg = get_session_commit_message(repo_root) + add_result = subprocess.run( + ["git", "add", "-A", ".trellis/workspace", ".trellis/tasks"], + cwd=repo_root, + capture_output=True, + text=True, + ) + if add_result.returncode != 0: + print(f"[WARN] git add failed (exit {add_result.returncode}): {add_result.stderr.strip()}", file=sys.stderr) + print("[WARN] Please commit .trellis/ changes manually: git add .trellis && git commit", file=sys.stderr) + return + # 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, + package: str | None = None, + branch: str | None = None, +) -> int: + """Add a new session.""" + repo_root = get_repo_root() + ensure_developer(repo_root) + + developer = get_developer(repo_root) + if not developer: + print("Error: Developer not initialized", file=sys.stderr) + return 1 + + dev_dir = get_workspace_dir(repo_root) + if not dev_dir: + print("Error: Workspace directory not found", file=sys.stderr) + return 1 + + max_lines = get_max_journal_lines(repo_root) + + index_file = dev_dir / "index.md" + today = datetime.now().strftime("%Y-%m-%d") + + journal_file, current_num, current_lines = get_latest_journal_info(dev_dir) + current_session = get_current_session(index_file) + new_session = current_session + 1 + + session_content = generate_session_content( + new_session, title, commit, summary, extra_content, today, package, + branch, + ) + content_lines = len(session_content.splitlines()) + + print("========================================", file=sys.stderr) + print("ADD SESSION", file=sys.stderr) + print("========================================", file=sys.stderr) + print("", file=sys.stderr) + print(f"Session: {new_session}", file=sys.stderr) + print(f"Title: {title}", file=sys.stderr) + print(f"Commit: {commit}", file=sys.stderr) + print("", file=sys.stderr) + print(f"Current journal file: {FILE_JOURNAL_PREFIX}{current_num}.md", file=sys.stderr) + print(f"Current lines: {current_lines}", file=sys.stderr) + print(f"New content lines: {content_lines}", file=sys.stderr) + print(f"Total after append: {current_lines + content_lines}", file=sys.stderr) + print("", file=sys.stderr) + + target_file = journal_file + target_num = current_num + + if current_lines + content_lines > max_lines: + target_num = current_num + 1 + print(f"[!] Exceeds {max_lines} lines, creating {FILE_JOURNAL_PREFIX}{target_num}.md", file=sys.stderr) + target_file = create_new_journal_file(dev_dir, target_num, developer, today, max_lines) + print(f"Created: {target_file}", file=sys.stderr) + + # Append session content + if target_file: + with target_file.open("a", encoding="utf-8") as f: + f.write(session_content) + print(f"[OK] Appended session to {target_file.name}", file=sys.stderr) + + print("", file=sys.stderr) + + # Update index.md + active_file = f"{FILE_JOURNAL_PREFIX}{target_num}.md" + if not update_index( + index_file, + dev_dir, + title, + commit, + new_session, + active_file, + today, + branch, + ): + return 1 + + print("", file=sys.stderr) + print("========================================", file=sys.stderr) + print(f"[OK] Session {new_session} added successfully!", file=sys.stderr) + print("========================================", file=sys.stderr) + print("", file=sys.stderr) + print("Files updated:", file=sys.stderr) + print(f" - {target_file.name if target_file else 'journal'}", file=sys.stderr) + print(" - index.md", file=sys.stderr) + + # Auto-commit workspace changes + if auto_commit: + print("", file=sys.stderr) + _auto_commit_workspace(repo_root) + + return 0 + + +# ============================================================================= +# Main Entry +# ============================================================================= + +def main() -> int: + """CLI entry point.""" + parser = argparse.ArgumentParser( + description="Add a new session to journal file and update index.md" + ) + parser.add_argument("--title", required=True, help="Session title") + parser.add_argument("--commit", default="-", help="Comma-separated commit hashes") + parser.add_argument("--summary", default="(Add summary)", help="Brief summary") + parser.add_argument("--content-file", help="Path to file with detailed content") + parser.add_argument("--package", help="Package name tag (e.g., cli, docs-site)") + parser.add_argument("--branch", help="Branch name (auto-detected if omitted)") + parser.add_argument("--no-commit", action="store_true", + help="Skip auto-commit of workspace changes") + parser.add_argument("--stdin", action="store_true", + help="Read extra content from stdin (explicit opt-in)") + + args = parser.parse_args() + + extra_content = "(Add details)" + if args.content_file: + content_path = Path(args.content_file) + if content_path.is_file(): + extra_content = content_path.read_text(encoding="utf-8") + elif args.stdin: + extra_content = sys.stdin.read() + + # Load active task once — shared by package and branch resolution + repo_root = get_repo_root() + current = get_current_task(repo_root) + task_data = load_task(repo_root / current) if current else None + + package = args.package + if package: + # CLI source: fail-fast in monorepo, ignore in single-repo + if not is_monorepo(repo_root): + print("Warning: --package ignored in single-repo project", file=sys.stderr) + package = None + elif not validate_package(package, repo_root): + packages = get_packages(repo_root) + available = ", ".join(sorted(packages.keys())) if packages else "(none)" + print(f"Error: unknown package '{package}'. Available: {available}", file=sys.stderr) + return 1 + else: + # Inferred: active task's task.json.package → default_package → None + task_package = task_data.package if task_data else None + package = resolve_package(task_package, repo_root) + + # Resolve branch: CLI → task.json → git auto-detect → None + branch = args.branch + + if not branch: + if task_data and task_data.raw.get("branch"): + branch = task_data.raw["branch"] + else: + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) + detected = branch_out.strip() + if detected: + branch = detected + + return add_session( + args.title, args.commit, args.summary, extra_content, + auto_commit=not args.no_commit, + package=package, + branch=branch, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.trellis/scripts/common/__init__.py b/.trellis/scripts/common/__init__.py new file mode 100755 index 0000000..6c3431e --- /dev/null +++ b/.trellis/scripts/common/__init__.py @@ -0,0 +1,84 @@ +""" +Common utilities for Trellis workflow scripts. + +This module provides shared functionality used by other Trellis scripts. +""" + +import io +import sys + +# ============================================================================= +# Windows Encoding Fix (MUST be at top, before any other output) +# ============================================================================= +# On Windows, stdout defaults to the system code page (often GBK/CP936). +# This causes UnicodeEncodeError when printing non-ASCII characters. +# +# Any script that imports from common will automatically get this fix. +# ============================================================================= + + +def _configure_stream(stream: object) -> object: + """Configure a stream for UTF-8 encoding on Windows.""" + # Try reconfigure() first (Python 3.7+, more reliable) + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + return stream + # Fallback: detach and rewrap with TextIOWrapper + elif hasattr(stream, "detach"): + return io.TextIOWrapper( + stream.detach(), # type: ignore[union-attr] + encoding="utf-8", + errors="replace", + ) + return stream + + +if sys.platform == "win32": + sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment] + sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment] + sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment] + + +def configure_encoding() -> None: + """ + Configure stdout/stderr/stdin for UTF-8 encoding on Windows. + + This is automatically called when importing from common, + but can be called manually for scripts that don't import common. + + Safe to call multiple times. + """ + global sys + if sys.platform == "win32": + sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment] + sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment] + sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment] + + +from .paths import ( + DIR_WORKFLOW, + DIR_WORKSPACE, + DIR_TASKS, + DIR_ARCHIVE, + DIR_SPEC, + DIR_SCRIPTS, + FILE_DEVELOPER, + FILE_CURRENT_TASK, + FILE_TASK_JSON, + FILE_JOURNAL_PREFIX, + get_repo_root, + get_developer, + check_developer, + get_tasks_dir, + get_workspace_dir, + get_active_journal_file, + count_lines, + get_current_task, + get_current_task_abs, + normalize_task_ref, + resolve_task_ref, + set_current_task, + clear_current_task, + has_current_task, + generate_task_date_prefix, +) diff --git a/.trellis/scripts/common/cli_adapter.py b/.trellis/scripts/common/cli_adapter.py new file mode 100755 index 0000000..12b2930 --- /dev/null +++ b/.trellis/scripts/common/cli_adapter.py @@ -0,0 +1,753 @@ +""" +CLI Adapter for Multi-Platform Support. + +Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, Windsurf, Qoder, CodeBuddy, GitHub Copilot, and Factory Droid 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) +- windsurf: Windsurf (workflow-based) +- qoder: Qoder +- codebuddy: CodeBuddy +- copilot: GitHub Copilot (VS Code) +- droid: Factory Droid (commands-based) + +Usage: + from common.cli_adapter import CLIAdapter + + adapter = CLIAdapter("opencode") + cmd = adapter.build_run_command( + agent="dispatch", + session_id="abc123", + prompt="Start the pipeline" + ) +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import ClassVar, Literal + +Platform = Literal[ + "claude", + "opencode", + "cursor", + "iflow", + "codex", + "kilo", + "kiro", + "gemini", + "antigravity", + "windsurf", + "qoder", + "codebuddy", + "copilot", + "droid", +] + + +@dataclass +class CLIAdapter: + """Adapter for different AI coding CLI tools.""" + + platform: Platform + + # ========================================================================= + # Agent Name Mapping + # ========================================================================= + + # OpenCode has built-in agents that cannot be overridden + # See: https://github.com/sst/opencode/issues/4271 + # Note: Class-level constant, not a dataclass field + _AGENT_NAME_MAP: ClassVar[dict[Platform, dict[str, str]]] = { + "claude": {}, # No mapping needed + "opencode": { + "plan": "trellis-plan", # 'plan' is built-in in OpenCode + }, + } + + def get_agent_name(self, agent: str) -> str: + """Get platform-specific agent name. + + Args: + agent: Original agent name (e.g., 'plan', 'dispatch') + + Returns: + Platform-specific agent name (e.g., 'trellis-plan' for OpenCode) + """ + mapping = self._AGENT_NAME_MAP.get(self.platform, {}) + return mapping.get(agent, agent) + + # ========================================================================= + # Agent Path + # ========================================================================= + + @property + def config_dir_name(self) -> str: + """Get platform-specific config directory name. + + Returns: + Directory name ('.claude', '.opencode', '.cursor', '.iflow', '.codex', '.kilocode', '.kiro', '.gemini', '.agent', '.windsurf', '.qoder', or '.codebuddy') + """ + if self.platform == "opencode": + return ".opencode" + elif self.platform == "cursor": + return ".cursor" + elif self.platform == "iflow": + return ".iflow" + elif self.platform == "codex": + return ".codex" + elif self.platform == "kilo": + return ".kilocode" + elif self.platform == "kiro": + return ".kiro" + elif self.platform == "gemini": + return ".gemini" + elif self.platform == "antigravity": + return ".agent" + elif self.platform == "windsurf": + return ".windsurf" + elif self.platform == "qoder": + return ".qoder" + elif self.platform == "codebuddy": + return ".codebuddy" + elif self.platform == "copilot": + return ".github/copilot" + elif self.platform == "droid": + return ".factory" + else: + return ".claude" + + def get_config_dir(self, project_root: Path) -> Path: + """Get platform-specific config directory. + + Args: + project_root: Project root directory + + Returns: + Path to config directory (.claude, .opencode, .cursor, .iflow, .codex, .kilocode, .kiro, .gemini, .agent, .windsurf, .qoder, or .codebuddy) + """ + return project_root / self.config_dir_name + + def get_agent_path(self, agent: str, project_root: Path) -> Path: + """Get path to agent definition file. + + Args: + agent: Agent name (original, before mapping) + project_root: Project root directory + + Returns: + Path to agent definition file (.md for most platforms, .toml for Codex) + """ + mapped_name = self.get_agent_name(agent) + if self.platform == "codex": + return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.toml" + return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.md" + + def get_commands_path(self, project_root: Path, *parts: str) -> Path: + """Get path to commands directory or specific command file. + + Args: + project_root: Project root directory + *parts: Additional path parts (e.g., 'trellis', 'finish-work.md') + + Returns: + Path to commands directory or file + + Note: + Cursor uses prefix naming: .cursor/commands/trellis-<name>.md + Antigravity uses workflow directory: .agent/workflows/<name>.md + Windsurf uses workflow directory: .windsurf/workflows/trellis-<name>.md + Copilot uses prompt files: .github/prompts/<name>.prompt.md + Claude/OpenCode use subdirectory: .claude/commands/trellis/<name>.md + """ + if self.platform == "windsurf": + workflow_dir = self.get_config_dir(project_root) / "workflows" + if not parts: + return workflow_dir + if len(parts) >= 2 and parts[0] == "trellis": + filename = parts[-1] + return workflow_dir / f"trellis-{filename}" + return workflow_dir / Path(*parts) + + if self.platform in ("antigravity", "kilo"): + workflow_dir = self.get_config_dir(project_root) / "workflows" + if not parts: + return workflow_dir + if len(parts) >= 2 and parts[0] == "trellis": + filename = parts[-1] + return workflow_dir / filename + return workflow_dir / Path(*parts) + + if self.platform == "copilot": + prompts_dir = project_root / ".github" / "prompts" + if not parts: + return prompts_dir + if len(parts) >= 2 and parts[0] == "trellis": + filename = parts[-1] + if filename.endswith(".md"): + filename = filename[:-3] + return prompts_dir / f"{filename}.prompt.md" + return prompts_dir / Path(*parts) + + if not parts: + return self.get_config_dir(project_root) / "commands" + + # Cursor uses prefix naming instead of subdirectory + if self.platform == "cursor" and len(parts) >= 2 and parts[0] == "trellis": + # Convert trellis/<name>.md to trellis-<name>.md + filename = parts[-1] + return ( + self.get_config_dir(project_root) / "commands" / f"trellis-{filename}" + ) + + return self.get_config_dir(project_root) / "commands" / Path(*parts) + + def get_trellis_command_path(self, name: str) -> str: + """Get relative path to a trellis command file. + + Args: + name: Command name without extension (e.g., 'finish-work', 'check') + + Returns: + Relative path string for use in JSONL entries + + Note: + Cursor: .cursor/commands/trellis-<name>.md + Codex: .agents/skills/<name>/SKILL.md + Kiro: .kiro/skills/<name>/SKILL.md + Gemini: .gemini/commands/trellis/<name>.toml + Antigravity: .agent/workflows/<name>.md + Windsurf: .windsurf/workflows/trellis-<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 == "windsurf": + return f".windsurf/workflows/trellis-{name}.md" + elif self.platform == "kilo": + return f".kilocode/workflows/{name}.md" + elif self.platform == "copilot": + return f".github/prompts/{name}.prompt.md" + elif self.platform == "droid": + return f".factory/commands/trellis/{name}.md" + 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 == "windsurf": + return {} + elif self.platform == "qoder": + return {} + elif self.platform == "codebuddy": + return {} + elif self.platform == "copilot": + return {} + elif self.platform == "droid": + return {} + else: + return {"CLAUDE_NON_INTERACTIVE": "1"} + + # ========================================================================= + # CLI Command Building + # ========================================================================= + + def build_run_command( + self, + agent: str, + prompt: str, + session_id: str | None = None, + skip_permissions: bool = True, + verbose: bool = True, + json_output: bool = True, + ) -> list[str]: + """Build CLI command for running an agent. + + Args: + agent: Agent name (will be mapped if needed) + prompt: Prompt to send to the agent + session_id: Optional session ID (Claude Code only for creation) + skip_permissions: Whether to skip permission prompts + verbose: Whether to enable verbose output + json_output: Whether to use JSON output format + + Returns: + List of command arguments + """ + mapped_agent = self.get_agent_name(agent) + + if self.platform == "opencode": + cmd = ["opencode", "run"] + cmd.extend(["--agent", mapped_agent]) + + # Note: OpenCode 'run' mode is non-interactive by default + # No equivalent to Claude Code's --dangerously-skip-permissions + # See: https://github.com/anomalyco/opencode/issues/9070 + + if json_output: + cmd.extend(["--format", "json"]) + + if verbose: + cmd.extend(["--log-level", "DEBUG", "--print-logs"]) + + # Note: OpenCode doesn't support --session-id on creation + # Session ID must be extracted from logs after startup + + cmd.append(prompt) + + elif self.platform == "iflow": + cmd = ["iflow", "-y", "-p"] + cmd.append(f"${mapped_agent} {prompt}") + elif self.platform == "codex": + cmd = ["codex", "exec"] + cmd.append(prompt) + elif self.platform == "kiro": + cmd = ["kiro", "run", prompt] + elif self.platform == "gemini": + cmd = ["gemini"] + cmd.append(prompt) + elif self.platform == "antigravity": + raise ValueError( + "Antigravity workflows are UI slash commands; CLI agent run is not supported." + ) + elif self.platform == "windsurf": + raise ValueError( + "Windsurf workflows are UI slash commands; CLI agent run is not supported." + ) + elif self.platform == "qoder": + cmd = ["qodercli", "-p", prompt] + elif self.platform == "codebuddy": + raise ValueError( + "CodeBuddy does not support non-interactive mode (no CLI agent)" + ) + elif self.platform == "copilot": + raise ValueError( + "GitHub Copilot is IDE-only; CLI agent run is not supported." + ) + elif self.platform == "droid": + raise ValueError( + "Factory Droid CLI agent run is not yet integrated with Trellis multi-agent." + ) + + 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 == "windsurf": + raise ValueError( + "Windsurf workflows are UI slash commands; CLI resume is not supported." + ) + elif self.platform == "qoder": + return ["qodercli", "--resume", session_id] + elif self.platform == "codebuddy": + raise ValueError( + "CodeBuddy does not support non-interactive mode (no CLI agent)" + ) + elif self.platform == "copilot": + raise ValueError( + "GitHub Copilot is IDE-only; CLI resume is not supported." + ) + elif self.platform == "droid": + raise ValueError( + "Factory Droid CLI resume is not yet integrated with Trellis multi-agent." + ) + 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 == "windsurf": + return "windsurf" + elif self.platform == "qoder": + return "qodercli" + elif self.platform == "codebuddy": + return "codebuddy" + elif self.platform == "copilot": + return "copilot" + elif self.platform == "droid": + return "droid" + else: + return "claude" + + @property + def supports_cli_agents(self) -> bool: + """Check if platform supports running agents via CLI. + + Claude Code, OpenCode, iFlow, and Codex support CLI agent execution. + Cursor is IDE-only and doesn't support CLI agents. + """ + return self.platform in ("claude", "opencode", "iflow", "codex") + + @property + def requires_agent_definition_file(self) -> bool: + """Check if platform requires an agent definition file (.md/.toml) to run. + + Claude Code, OpenCode, iFlow: require agent .md files (--agent flag). + Codex: auto-discovers agents from .codex/agents/*.toml, no --agent flag. + """ + return self.platform in ("claude", "opencode", "iflow") + + # ========================================================================= + # Session ID Handling + # ========================================================================= + + @property + def supports_session_id_on_create(self) -> bool: + """Check if platform supports specifying session ID on creation. + + Claude Code: Yes (--session-id) + OpenCode: No (auto-generated, extract from logs) + iFlow: No (no session ID support) + """ + return self.platform == "claude" + + def extract_session_id_from_log(self, log_content: str) -> str | None: + """Extract session ID from log output (OpenCode only). + + OpenCode generates session IDs in format: ses_xxx + + Args: + log_content: Log file content + + Returns: + Session ID if found, None otherwise + """ + import re + + # OpenCode session ID pattern + match = re.search(r"ses_[a-zA-Z0-9]+", log_content) + if match: + return match.group(0) + return None + + +# ============================================================================= +# Factory Function +# ============================================================================= + + +def get_cli_adapter(platform: str = "claude") -> CLIAdapter: + """Get CLI adapter for the specified platform. + + Args: + platform: Platform name ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'windsurf', 'qoder', or 'codebuddy') + + Returns: + CLIAdapter instance + + Raises: + ValueError: If platform is not supported + """ + if platform not in ( + "claude", + "opencode", + "cursor", + "iflow", + "codex", + "kilo", + "kiro", + "gemini", + "antigravity", + "windsurf", + "qoder", + "codebuddy", + "copilot", + "droid", + ): + raise ValueError( + f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'windsurf', 'qoder', 'codebuddy', 'copilot', or 'droid')" + ) + + return CLIAdapter(platform=platform) # type: ignore + + +_ALL_PLATFORM_CONFIG_DIRS = ( + ".claude", + ".cursor", + ".iflow", + ".opencode", + ".agents", + ".codex", + ".kilocode", + ".kiro", + ".gemini", + ".agent", + ".windsurf", + ".qoder", + ".codebuddy", + ".github/copilot", + ".factory", +) +"""All platform config directory names (used by detect_platform exclusion checks).""" + + +def _has_other_platform_dir(project_root: Path, exclude: set[str]) -> bool: + """Check if any platform config dir exists besides those in *exclude*.""" + return any( + (project_root / d).is_dir() + for d in _ALL_PLATFORM_CONFIG_DIRS + if d not in exclude + ) + + +def detect_platform(project_root: Path) -> Platform: + """Auto-detect platform based on existing config directories. + + Detection order: + 1. TRELLIS_PLATFORM environment variable (if set) + 2. .opencode directory exists → opencode + 3. .iflow directory exists → iflow + 4. .cursor directory exists (without .claude) → cursor + 5. .codex exists and no other platform dirs → codex + 6. .kilocode directory exists → kilo + 7. .kiro/skills exists and no other platform dirs → kiro + 8. .gemini directory exists → gemini + 9. .agent/workflows exists and no other platform dirs → antigravity + 10. .windsurf/workflows exists and no other platform dirs → windsurf + 11. .codebuddy directory exists → codebuddy + 12. .qoder directory exists → qoder + 13. Default → claude + + Args: + project_root: Project root directory + + Returns: + Detected platform ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'windsurf', 'qoder', 'codebuddy', or default 'claude') + """ + 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", + "windsurf", + "qoder", + "codebuddy", + "copilot", + "droid", + ): + return env_platform # type: ignore + + # Check for .opencode directory (OpenCode-specific) + if (project_root / ".opencode").is_dir(): + return "opencode" + + # Check for .iflow directory (iFlow-specific) + if (project_root / ".iflow").is_dir(): + return "iflow" + + # Check for .cursor directory (Cursor-specific) + # Only detect as cursor if .claude doesn't exist (to avoid confusion) + if (project_root / ".cursor").is_dir() and not (project_root / ".claude").is_dir(): + return "cursor" + + # Check for .gemini directory (Gemini CLI-specific) + if (project_root / ".gemini").is_dir(): + return "gemini" + + # Check for .codex directory (Codex-specific) + # .agents/skills/ alone does NOT trigger codex detection (it's a shared standard) + if (project_root / ".codex").is_dir() and not _has_other_platform_dir( + project_root, {".codex", ".agents"} + ): + return "codex" + + # Check for .kilocode directory (Kilo-specific) + if (project_root / ".kilocode").is_dir(): + return "kilo" + + # Check for Kiro skills directory only when no other platform config exists + if (project_root / ".kiro" / "skills").is_dir() and not _has_other_platform_dir( + project_root, {".kiro"} + ): + return "kiro" + + # Check for Antigravity workflow directory only when no other platform config exists + if ( + project_root / ".agent" / "workflows" + ).is_dir() and not _has_other_platform_dir( + project_root, {".agent", ".gemini"} + ): + return "antigravity" + + # Check for Windsurf workflow directory only when no other platform config exists + if ( + project_root / ".windsurf" / "workflows" + ).is_dir() and not _has_other_platform_dir( + project_root, {".windsurf"} + ): + return "windsurf" + + # Check for .codebuddy directory (CodeBuddy-specific) + if (project_root / ".codebuddy").is_dir(): + return "codebuddy" + + # Check for .qoder directory (Qoder-specific) + if (project_root / ".qoder").is_dir(): + return "qoder" + + # Check for .github/copilot directory (GitHub Copilot-specific) + if (project_root / ".github" / "copilot").is_dir(): + return "copilot" + + # Check for .factory directory (Factory Droid-specific) + if (project_root / ".factory").is_dir(): + return "droid" + + 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..ea1bdc3 --- /dev/null +++ b/.trellis/scripts/common/config.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +""" +Trellis configuration reader. + +Reads settings from .trellis/config.yaml with sensible defaults. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from .paths import DIR_WORKFLOW, get_repo_root +from .worktree import parse_simple_yaml + + +# Defaults +DEFAULT_SESSION_COMMIT_MESSAGE = "chore: record journal" +DEFAULT_MAX_JOURNAL_LINES = 2000 + +CONFIG_FILE = "config.yaml" + + +def _is_true_config_value(value: object) -> bool: + """Return True when a config value represents an enabled flag.""" + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() == "true" + return False + + +def _get_config_path(repo_root: Path | None = None) -> Path: + """Get path to config.yaml.""" + root = repo_root or get_repo_root() + return root / DIR_WORKFLOW / CONFIG_FILE + + +def _load_config(repo_root: Path | None = None) -> dict: + """Load and parse config.yaml. Returns empty dict on any error.""" + config_file = _get_config_path(repo_root) + try: + content = config_file.read_text(encoding="utf-8") + return parse_simple_yaml(content) + except (OSError, IOError): + return {} + + +def get_session_commit_message(repo_root: Path | None = None) -> str: + """Get the commit message for auto-committing session records.""" + config = _load_config(repo_root) + return config.get("session_commit_message", DEFAULT_SESSION_COMMIT_MESSAGE) + + +def get_max_journal_lines(repo_root: Path | None = None) -> int: + """Get the maximum lines per journal file.""" + config = _load_config(repo_root) + value = config.get("max_journal_lines", DEFAULT_MAX_JOURNAL_LINES) + try: + return int(value) + except (ValueError, TypeError): + return DEFAULT_MAX_JOURNAL_LINES + + +def get_hooks(event: str, repo_root: Path | None = None) -> list[str]: + """Get hook commands for a lifecycle event. + + Args: + event: Event name (e.g. "after_create", "after_archive"). + repo_root: Repository root path. + + Returns: + List of shell commands to execute, empty if none configured. + """ + config = _load_config(repo_root) + hooks = config.get("hooks") + if not isinstance(hooks, dict): + return [] + commands = hooks.get(event) + if isinstance(commands, list): + return [str(c) for c in commands] + return [] + + +# ============================================================================= +# Monorepo / Packages +# ============================================================================= + + +def get_packages(repo_root: Path | None = None) -> dict[str, dict] | None: + """Get monorepo package declarations. + + Returns: + Dict mapping package name to its config (path, type, etc.), + or None if not configured (single-repo mode). + + Example return: + {"cli": {"path": "packages/cli"}, "docs-site": {"path": "docs-site", "type": "submodule"}} + """ + config = _load_config(repo_root) + packages = config.get("packages") + if not isinstance(packages, dict): + return None + # Ensure each value is a dict (filter out scalar entries) + filtered = {k: v for k, v in packages.items() if isinstance(v, dict)} + if not filtered: + return None + return filtered + + +def get_default_package(repo_root: Path | None = None) -> str | None: + """Get the default package name from config. + + Returns: + Package name string, or None if not configured. + """ + config = _load_config(repo_root) + value = config.get("default_package") + return str(value) if value else None + + +def get_submodule_packages(repo_root: Path | None = None) -> dict[str, str]: + """Get packages that are git submodules. + + Returns: + Dict mapping package name to its path for submodule-type packages. + Empty dict if none configured. + + Example return: + {"docs-site": "docs-site"} + """ + packages = get_packages(repo_root) + if packages is None: + return {} + return { + name: cfg.get("path", name) + for name, cfg in packages.items() + if cfg.get("type") == "submodule" + } + + +def get_git_packages(repo_root: Path | None = None) -> dict[str, str]: + """Get packages that have their own independent git repository. + + These are sub-directories with their own .git (not submodules), + marked with ``git: true`` in config.yaml. + + Returns: + Dict mapping package name to its path for git-repo packages. + Empty dict if none configured. + + Example config:: + + packages: + backend: + path: iqs + git: true + + Example return:: + + {"backend": "iqs"} + """ + packages = get_packages(repo_root) + if packages is None: + return {} + return { + name: cfg.get("path", name) + for name, cfg in packages.items() + if _is_true_config_value(cfg.get("git")) + } + + +def is_monorepo(repo_root: Path | None = None) -> bool: + """Check if the project is configured as a monorepo (has packages in config).""" + return get_packages(repo_root) is not None + + +def get_spec_base(package: str | None = None, repo_root: Path | None = None) -> str: + """Get the spec directory base path relative to .trellis/. + + Single-repo: returns "spec" + Monorepo with package: returns "spec/<package>" + Monorepo without package: returns "spec" (caller should specify package) + """ + if package and is_monorepo(repo_root): + return f"spec/{package}" + return "spec" + + +def validate_package(package: str, repo_root: Path | None = None) -> bool: + """Check if a package name is valid in this project. + + Single-repo (no packages configured): always returns True. + Monorepo: returns True only if package exists in config.yaml packages. + """ + packages = get_packages(repo_root) + if packages is None: + return True # Single-repo, no validation needed + return package in packages + + +def resolve_package( + task_package: str | None = None, + repo_root: Path | None = None, +) -> str | None: + """Resolve package from inferred sources with validation. + + Checks in order: task_package → default_package. + Invalid inferred values print a warning to stderr and are skipped. + + Returns: + Resolved package name, or None if no valid package found. + + Note: + CLI --package should be validated separately by the caller + (fail-fast with available packages list on error). + """ + packages = get_packages(repo_root) + if packages is None: + return None # Single-repo, no package needed + + # Try task_package (guard against non-string values from malformed JSON) + if task_package and isinstance(task_package, str): + if task_package in packages: + return task_package + print( + f"Warning: task.json package '{task_package}' not found in config, skipping", + file=sys.stderr, + ) + + # Try default_package + default = get_default_package(repo_root) + if default: + if default in packages: + return default + print( + f"Warning: default_package '{default}' not found in config, skipping", + file=sys.stderr, + ) + + return None + + +def get_spec_scope(repo_root: Path | None = None) -> list[str] | str | None: + """Get session.spec_scope configuration. + + Returns: + list[str]: Package names to include in spec scanning. + str: "active_task" to use current task's package. + None: No scope configured (scan all packages). + """ + config = _load_config(repo_root) + session = config.get("session") + if not isinstance(session, dict): + return None + + scope = session.get("spec_scope") + if scope is None: + return None + if isinstance(scope, str): + return scope # e.g. "active_task" + if isinstance(scope, list): + return [str(s) for s in scope] + return None diff --git a/.trellis/scripts/common/developer.py b/.trellis/scripts/common/developer.py new file mode 100755 index 0000000..c203a31 --- /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 | Branch | +|---|------|-------|---------|--------| +<!-- @@@/auto:session-history --> + +--- + +## Notes + +- Sessions are appended to journal files +- New journal file created when current exceeds 2000 lines +- Use `add_session.py` to record sessions +""" + try: + index_file.write_text(index_content, encoding="utf-8") + except (OSError, IOError) as e: + print(f"Error: Failed to create index.md: {e}", file=sys.stderr) + return False + + print(f"Developer initialized: {name}") + print(f" .developer file: {dev_file}") + print(f" Workspace dir: {workspace_dir}") + + return True + + +def ensure_developer(repo_root: Path | None = None) -> None: + """Ensure developer is initialized, exit if not. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + """ + if repo_root is None: + repo_root = get_repo_root() + + if not check_developer(repo_root): + print("Error: Developer not initialized.", file=sys.stderr) + print(f"Run: python3 ./{DIR_WORKFLOW}/scripts/init_developer.py <your-name>", file=sys.stderr) + sys.exit(1) + + +def show_developer_info(repo_root: Path | None = None) -> None: + """Show developer information. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + """ + if repo_root is None: + repo_root = get_repo_root() + + developer = get_developer(repo_root) + + if not developer: + print("Developer: (not initialized)") + else: + print(f"Developer: {developer}") + print(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/") + print(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/") + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + show_developer_info() diff --git a/.trellis/scripts/common/git.py b/.trellis/scripts/common/git.py new file mode 100755 index 0000000..c4bf29f --- /dev/null +++ b/.trellis/scripts/common/git.py @@ -0,0 +1,31 @@ +""" +Git command execution utility. + +Single source of truth for running git commands across all Trellis scripts. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def run_git(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]: + """Run a git command and return (returncode, stdout, stderr). + + Uses UTF-8 encoding with -c i18n.logOutputEncoding=UTF-8 to ensure + consistent output across all platforms (Windows, macOS, Linux). + """ + try: + git_args = ["git", "-c", "i18n.logOutputEncoding=UTF-8"] + args + result = subprocess.run( + git_args, + cwd=cwd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + return result.returncode, result.stdout, result.stderr + except Exception as e: + return 1, "", str(e) diff --git a/.trellis/scripts/common/git_context.py b/.trellis/scripts/common/git_context.py new file mode 100755 index 0000000..397c6d2 --- /dev/null +++ b/.trellis/scripts/common/git_context.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Git and Session Context utilities. + +Entry shim — delegates to session_context and packages_context. + +Provides: + output_json - Output context in JSON format + output_text - Output context in text format +""" + +from __future__ import annotations + +import json + +from .git import run_git +from .session_context import ( + get_context_json, + get_context_text, + get_context_record_json, + get_context_text_record, + output_json, + output_text, +) +from .packages_context import ( + get_context_packages_text, + get_context_packages_json, +) + +# Backward-compatible alias — external modules import this name +_run_git_command = run_git + + +# ============================================================================= +# Main Entry +# ============================================================================= + +def main() -> None: + """CLI entry point.""" + import argparse + + parser = argparse.ArgumentParser(description="Get Session Context for AI Agent") + parser.add_argument( + "--json", + "-j", + action="store_true", + help="Output in JSON format (works with any --mode)", + ) + parser.add_argument( + "--mode", + "-m", + choices=["default", "record", "packages"], + default="default", + help="Output mode: default (full context), record (for record-session), packages (package info only)", + ) + + args = parser.parse_args() + + if args.mode == "record": + if args.json: + print(json.dumps(get_context_record_json(), indent=2, ensure_ascii=False)) + else: + print(get_context_text_record()) + elif args.mode == "packages": + if args.json: + print(json.dumps(get_context_packages_json(), indent=2, ensure_ascii=False)) + else: + print(get_context_packages_text()) + else: + if args.json: + output_json() + else: + output_text() + + +if __name__ == "__main__": + main() diff --git a/.trellis/scripts/common/io.py b/.trellis/scripts/common/io.py new file mode 100755 index 0000000..44288f4 --- /dev/null +++ b/.trellis/scripts/common/io.py @@ -0,0 +1,37 @@ +""" +JSON file I/O utilities. + +Provides read_json and write_json as the single source of truth +for JSON file operations across all Trellis scripts. +""" + +from __future__ import annotations + +import json +from pathlib import Path + + +def read_json(path: Path) -> dict | None: + """Read and parse a JSON file. + + Returns None if the file doesn't exist, is invalid JSON, or can't be read. + """ + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + + +def write_json(path: Path, data: dict) -> bool: + """Write dict to JSON file with pretty formatting. + + Returns True on success, False on error. + """ + try: + path.write_text( + json.dumps(data, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + return True + except (OSError, IOError): + return False diff --git a/.trellis/scripts/common/log.py b/.trellis/scripts/common/log.py new file mode 100755 index 0000000..839c643 --- /dev/null +++ b/.trellis/scripts/common/log.py @@ -0,0 +1,45 @@ +""" +Terminal output utilities: colors and structured logging. + +Single source of truth for Colors and log_* functions +used across all Trellis scripts. +""" + +from __future__ import annotations + + +class Colors: + """ANSI color codes for terminal output.""" + + RED = "\033[0;31m" + GREEN = "\033[0;32m" + YELLOW = "\033[1;33m" + BLUE = "\033[0;34m" + CYAN = "\033[0;36m" + DIM = "\033[2m" + NC = "\033[0m" # No Color / Reset + + +def colored(text: str, color: str) -> str: + """Apply ANSI color to text.""" + return f"{color}{text}{Colors.NC}" + + +def log_info(msg: str) -> None: + """Print info-level message with [INFO] prefix.""" + print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}") + + +def log_success(msg: str) -> None: + """Print success message with [SUCCESS] prefix.""" + print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}") + + +def log_warn(msg: str) -> None: + """Print warning message with [WARN] prefix.""" + print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}") + + +def log_error(msg: str) -> None: + """Print error message with [ERROR] prefix.""" + print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}") diff --git a/.trellis/scripts/common/packages_context.py b/.trellis/scripts/common/packages_context.py new file mode 100755 index 0000000..e7d4e8c --- /dev/null +++ b/.trellis/scripts/common/packages_context.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +""" +Package discovery and context output. + +Provides: + get_packages_info - Get structured package info + get_packages_section - Build PACKAGES text section + get_context_packages_text - Full packages text output (--mode packages) + get_context_packages_json - Full packages JSON output (--mode packages --json) +""" + +from __future__ import annotations + +from pathlib import Path + +from .config import _is_true_config_value, get_default_package, get_packages, get_spec_scope +from .paths import ( + DIR_SPEC, + DIR_WORKFLOW, + get_current_task, + get_repo_root, +) +from .tasks import load_task + + +# ============================================================================= +# Internal Helpers +# ============================================================================= + +def _scan_spec_layers(spec_dir: Path, package: str | None = None) -> list[str]: + """Scan spec directory for available layers (subdirectories). + + For monorepo: scans spec/<package>/ + For single-repo: scans spec/ + """ + target = spec_dir / package if package else spec_dir + if not target.is_dir(): + return [] + return sorted( + d.name for d in target.iterdir() if d.is_dir() and d.name != "guides" + ) + + +def _get_active_task_package(repo_root: Path) -> str | None: + """Get the package field from the active task's task.json.""" + current = get_current_task(repo_root) + if not current: + return None + ct = load_task(repo_root / current) + return ct.package if ct and ct.package else None + + +def _resolve_scope_set( + packages: dict, + spec_scope, + task_pkg: str | None, + default_pkg: str | None, +) -> set | None: + """Resolve spec_scope to a set of allowed package names, or None for full scan.""" + if not packages: + return None + + if spec_scope is None: + return None + + if isinstance(spec_scope, str) and spec_scope == "active_task": + if task_pkg and task_pkg in packages: + return {task_pkg} + if default_pkg and default_pkg in packages: + return {default_pkg} + return None + + if isinstance(spec_scope, list): + valid = {e for e in spec_scope if e in packages} + if valid: + return valid + # All invalid: fallback + if task_pkg and task_pkg in packages: + return {task_pkg} + if default_pkg and default_pkg in packages: + return {default_pkg} + return None + + return None + + +# ============================================================================= +# Public Functions +# ============================================================================= + +def get_packages_info(repo_root: Path) -> list[dict]: + """Get structured package info for monorepo projects. + + Returns list of dicts with keys: name, path, type, default, specLayers, + isSubmodule, isGitRepo. + Returns empty list for single-repo projects. + """ + packages = get_packages(repo_root) + if not packages: + return [] + + default_pkg = get_default_package(repo_root) + spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC + result = [] + + for pkg_name, pkg_config in packages.items(): + pkg_path = pkg_config.get("path", pkg_name) if isinstance(pkg_config, dict) else str(pkg_config) + pkg_type = pkg_config.get("type", "local") if isinstance(pkg_config, dict) else "local" + pkg_git = pkg_config.get("git", False) if isinstance(pkg_config, dict) else False + layers = _scan_spec_layers(spec_dir, pkg_name) + + result.append({ + "name": pkg_name, + "path": pkg_path, + "type": pkg_type, + "default": pkg_name == default_pkg, + "specLayers": layers, + "isSubmodule": pkg_type == "submodule", + "isGitRepo": _is_true_config_value(pkg_git), + }) + + return result + + +def get_packages_section(repo_root: Path) -> str: + """Build the PACKAGES section for text output.""" + spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC + pkg_info = get_packages_info(repo_root) + + lines: list[str] = [] + lines.append("## PACKAGES") + + if not pkg_info: + lines.append("(single-repo mode)") + layers = _scan_spec_layers(spec_dir) + if layers: + lines.append(f"Spec layers: {', '.join(layers)}") + return "\n".join(lines) + + default_pkg = get_default_package(repo_root) + + for pkg in pkg_info: + layers_str = f" [{', '.join(pkg['specLayers'])}]" if pkg["specLayers"] else "" + submodule_tag = " (submodule)" if pkg["isSubmodule"] else "" + git_repo_tag = " (git repo)" if pkg["isGitRepo"] else "" + default_tag = " *" if pkg["default"] else "" + lines.append( + f"- {pkg['name']:<16} {pkg['path']:<20}{layers_str}{submodule_tag}{git_repo_tag}{default_tag}" + ) + + if default_pkg: + lines.append(f"Default package: {default_pkg}") + + return "\n".join(lines) + + +def get_context_packages_text(repo_root: Path | None = None) -> str: + """Get packages context as formatted text (for --mode packages).""" + if repo_root is None: + repo_root = get_repo_root() + + pkg_info = get_packages_info(repo_root) + lines: list[str] = [] + + if not pkg_info: + spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC + lines.append("Single-repo project (no packages configured)") + lines.append("") + layers = _scan_spec_layers(spec_dir) + if layers: + lines.append(f"Spec layers: {', '.join(layers)}") + return "\n".join(lines) + + # Resolve scope for annotations + packages_dict = get_packages(repo_root) or {} + default_pkg = get_default_package(repo_root) + spec_scope = get_spec_scope(repo_root) + task_pkg = _get_active_task_package(repo_root) + scope_set = _resolve_scope_set(packages_dict, spec_scope, task_pkg, default_pkg) + + lines.append("## PACKAGES") + lines.append("") + for pkg in pkg_info: + default_tag = " (default)" if pkg["default"] else "" + type_tag = f" [{pkg['type']}]" if pkg["type"] != "local" else "" + git_tag = " [git repo]" if pkg["isGitRepo"] else "" + + # Scope annotation + scope_tag = "" + if scope_set is not None and pkg["name"] not in scope_set: + scope_tag = " (out of scope)" + + lines.append(f"### {pkg['name']}{default_tag}{type_tag}{git_tag}{scope_tag}") + lines.append(f"Path: {pkg['path']}") + if pkg["specLayers"]: + lines.append(f"Spec layers: {', '.join(pkg['specLayers'])}") + for layer in pkg["specLayers"]: + lines.append(f" - .trellis/spec/{pkg['name']}/{layer}/index.md") + else: + lines.append("Spec: not configured") + lines.append("") + + # Also show shared guides + guides_dir = repo_root / DIR_WORKFLOW / DIR_SPEC / "guides" + if guides_dir.is_dir(): + lines.append("### Shared Guides (always included)") + lines.append("Path: .trellis/spec/guides/index.md") + lines.append("") + + return "\n".join(lines) + + +def get_context_packages_json(repo_root: Path | None = None) -> dict: + """Get packages context as a dictionary (for --mode packages --json).""" + if repo_root is None: + repo_root = get_repo_root() + + pkg_info = get_packages_info(repo_root) + + if not pkg_info: + spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC + layers = _scan_spec_layers(spec_dir) + return { + "mode": "single-repo", + "specLayers": layers, + } + + default_pkg = get_default_package(repo_root) + spec_scope = get_spec_scope(repo_root) + task_pkg = _get_active_task_package(repo_root) + + return { + "mode": "monorepo", + "packages": pkg_info, + "defaultPackage": default_pkg, + "specScope": spec_scope, + "activeTaskPackage": task_pkg, + } diff --git a/.trellis/scripts/common/paths.py b/.trellis/scripts/common/paths.py new file mode 100755 index 0000000..7a28a3d --- /dev/null +++ b/.trellis/scripts/common/paths.py @@ -0,0 +1,444 @@ +#!/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 normalize_task_ref(task_ref: str) -> str: + """Normalize a task ref for stable storage in .current-task. + + Stored refs should prefer repo-relative POSIX paths like + `.trellis/tasks/03-27-my-task`, even on Windows. Absolute paths are preserved + unless they can later be converted back to repo-relative form by callers. + """ + normalized = task_ref.strip() + if not normalized: + return "" + + path_obj = Path(normalized) + if path_obj.is_absolute(): + return str(path_obj) + + normalized = normalized.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + + if normalized.startswith(f"{DIR_TASKS}/"): + return f"{DIR_WORKFLOW}/{normalized}" + + return normalized + + +def resolve_task_ref(task_ref: str, repo_root: Path | None = None) -> Path | None: + """Resolve a task ref from .current-task to an absolute task directory path.""" + if repo_root is None: + repo_root = get_repo_root() + + normalized = normalize_task_ref(task_ref) + if not normalized: + return None + + path_obj = Path(normalized) + if path_obj.is_absolute(): + return path_obj + + if normalized.startswith(f"{DIR_WORKFLOW}/"): + return repo_root / path_obj + + return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj + + +def get_current_task(repo_root: Path | None = None) -> 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: + content = current_file.read_text(encoding="utf-8").strip() + return normalize_task_ref(content) if content else None + 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 resolve_task_ref(relative, repo_root) + 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() + + normalized = normalize_task_ref(task_path) + if not normalized: + return False + + # Verify task directory exists + full_path = resolve_task_ref(normalized, repo_root) + if full_path is None or not full_path.is_dir(): + return False + + try: + normalized = full_path.relative_to(repo_root).as_posix() + except ValueError: + normalized = str(full_path) + + current_file = _get_current_task_file(repo_root) + + try: + current_file.write_text(normalized, 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") + + +# ============================================================================= +# Monorepo / Package Paths +# ============================================================================= + + +def get_spec_dir(package: str | None = None, repo_root: Path | None = None) -> Path: + """Get the spec directory path. + + Single-repo: .trellis/spec + Monorepo with package: .trellis/spec/<package> + + Uses lazy import to avoid circular dependency with config.py. + """ + if repo_root is None: + repo_root = get_repo_root() + + from .config import get_spec_base + + base = get_spec_base(package, repo_root) + return repo_root / DIR_WORKFLOW / base + + +def get_package_path(package: str, repo_root: Path | None = None) -> Path | None: + """Get a package's source directory absolute path from config. + + Returns: + Absolute path to the package directory, or None if not found. + """ + if repo_root is None: + repo_root = get_repo_root() + + from .config import get_packages + + packages = get_packages(repo_root) + if not packages or package not in packages: + return None + + info = packages[package] + if isinstance(info, dict): + rel_path = info.get("path", package) + else: + rel_path = str(info) + + return repo_root / rel_path + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + repo = get_repo_root() + print(f"Repository root: {repo}") + print(f"Developer: {get_developer(repo)}") + print(f"Tasks dir: {get_tasks_dir(repo)}") + print(f"Workspace dir: {get_workspace_dir(repo)}") + print(f"Journal file: {get_active_journal_file(repo)}") + print(f"Current task: {get_current_task(repo)}") diff --git a/.trellis/scripts/common/phase.py b/.trellis/scripts/common/phase.py new file mode 100755 index 0000000..cf7670e --- /dev/null +++ b/.trellis/scripts/common/phase.py @@ -0,0 +1,254 @@ +#!/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 + +from pathlib import Path + +from .io import read_json, write_json + + +# ============================================================================= +# Internal Helpers (operate on pre-loaded data dict) +# ============================================================================= + +def _total_phases(data: dict) -> int: + """Get total phases from pre-loaded data.""" + next_action = data.get("next_action", []) + return len(next_action) if isinstance(next_action, list) else 0 + + +def _phase_action(data: dict, phase: int) -> str: + """Get action name for a phase from pre-loaded data.""" + 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 _phase_for_action(data: dict, action: str) -> int: + """Get phase number for an action name from pre-loaded data.""" + 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 + + +# ============================================================================= +# 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(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(task_json) + if not data: + return 0 + return _total_phases(data) + + +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(task_json) + if not data: + return "unknown" + return _phase_action(data, phase) + + +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(task_json) + if not data: + return "N/A" + + current_phase = data.get("current_phase", 0) or 0 + total = _total_phases(data) + action_name = _phase_action(data, current_phase) + + if current_phase == 0 or current_phase is None: + return f"0/{total} (pending)" + else: + return f"{current_phase}/{total} ({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(task_json) + if not data: + return False + + data["current_phase"] = phase + return write_json(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(task_json) + if not data: + return False + + current = data.get("current_phase", 0) or 0 + total = _total_phases(data) + next_phase = current + 1 + + if next_phase > total: + return False # Already at final phase + + data["current_phase"] = next_phase + return write_json(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(task_json) + if not data: + return 0 + return _phase_for_action(data, action) + + +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. + """ + data = read_json(task_json) + if not data: + return False + current = data.get("current_phase", 0) or 0 + action_phase = _phase_for_action(data, 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..73e523d --- /dev/null +++ b/.trellis/scripts/common/registry.py @@ -0,0 +1,335 @@ +#!/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 + +from datetime import datetime +from pathlib import Path + +from .io import read_json, write_json +from .paths import get_repo_root +from .worktree import get_agents_dir + + +# ============================================================================= +# Internal Helpers +# ============================================================================= + +def _load_registry( + repo_root: Path | None = None, +) -> tuple[Path | None, dict | None]: + """Load registry file and data in one step. + + Returns: + (registry_file_path, data_dict) — either may be None. + """ + 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 registry_file, None + + data = read_json(registry_file) + return registry_file, data + + +# ============================================================================= +# 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(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. + """ + _, data = _load_registry(repo_root) + 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. + """ + _, data = _load_registry(repo_root) + 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. + """ + _, data = _load_registry(repo_root) + 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. + """ + _, data = _load_registry(repo_root) + if not data: + return None + + for agent in data.get("agents", []): + if agent.get("worktree_path") == worktree_path: + 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. + """ + registry_file, data = _load_registry(repo_root) + if not registry_file or not data: + return True # Nothing to remove + + agents = data.get("agents", []) + data["agents"] = [a for a in agents if a.get("id") != agent_id] + + return write_json(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. + """ + registry_file, data = _load_registry(repo_root) + if not registry_file or not data: + return True # Nothing to remove + + agents = data.get("agents", []) + data["agents"] = [a for a in agents if a.get("worktree_path") != worktree_path] + + return write_json(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(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(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. + """ + _, data = _load_registry(repo_root) + 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/session_context.py b/.trellis/scripts/common/session_context.py new file mode 100755 index 0000000..52c6a4a --- /dev/null +++ b/.trellis/scripts/common/session_context.py @@ -0,0 +1,562 @@ +#!/usr/bin/env python3 +""" +Session context generation (default + record modes). + +Provides: + get_context_json - JSON output for default mode + get_context_text - Text output for default mode + get_context_record_json - JSON for record mode + get_context_text_record - Text for record mode + output_json - Print JSON + output_text - Print text +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from .config import get_git_packages +from .git import run_git +from .packages_context import get_packages_section +from .tasks import iter_active_tasks, load_task, get_all_statuses, children_progress +from .paths import ( + DIR_SCRIPTS, + DIR_SPEC, + DIR_TASKS, + DIR_WORKFLOW, + DIR_WORKSPACE, + count_lines, + get_active_journal_file, + get_current_task, + get_developer, + get_repo_root, + get_tasks_dir, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + +def _collect_package_git_info(repo_root: Path) -> list[dict]: + """Collect git status and recent commits for packages with independent git repos. + + Only packages marked with ``git: true`` in config.yaml are included. + + Returns: + List of dicts with keys: name, path, branch, isClean, + uncommittedChanges, recentCommits. + Empty list if no git-repo packages are configured. + """ + git_pkgs = get_git_packages(repo_root) + if not git_pkgs: + return [] + + result = [] + for pkg_name, pkg_path in git_pkgs.items(): + pkg_dir = repo_root / pkg_path + if not (pkg_dir / ".git").exists(): + continue + + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=pkg_dir) + branch = branch_out.strip() or "unknown" + + _, status_out, _ = run_git(["status", "--porcelain"], cwd=pkg_dir) + changes = len([l for l in status_out.splitlines() if l.strip()]) + + _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=pkg_dir) + 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": ""}) + + result.append({ + "name": pkg_name, + "path": pkg_path, + "branch": branch, + "isClean": changes == 0, + "uncommittedChanges": changes, + "recentCommits": commits, + }) + + return result + + +def _append_package_git_context(lines: list[str], package_git_info: list[dict]) -> None: + """Append Git status and recent commits for package repositories.""" + for pkg in package_git_info: + lines.append(f"## GIT STATUS ({pkg['name']}: {pkg['path']})") + lines.append(f"Branch: {pkg['branch']}") + if pkg["isClean"]: + lines.append("Working directory: Clean") + else: + lines.append( + f"Working directory: {pkg['uncommittedChanges']} uncommitted change(s)" + ) + lines.append("") + lines.append(f"## RECENT COMMITS ({pkg['name']}: {pkg['path']})") + if pkg["recentCommits"]: + for commit in pkg["recentCommits"]: + lines.append(f"{commit['hash']} {commit['message']}") + else: + lines.append("(no commits)") + lines.append("") + + +# ============================================================================= +# 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(["branch", "--show-current"], cwd=repo_root) + branch = branch_out.strip() or "unknown" + + _, status_out, _ = run_git(["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(["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 = [ + { + "dir": t.dir_name, + "name": t.name, + "status": t.status, + "children": list(t.children), + "parent": t.parent, + } + for t in iter_active_tasks(tasks_dir) + ] + + # Package git repos (independent sub-repositories) + pkg_git_info = _collect_package_git_info(repo_root) + + result = { + "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, + }, + } + + if pkg_git_info: + result["packageGit"] = pkg_git_info + + return result + + +def output_json(repo_root: Path | None = None) -> None: + """Output context in JSON format. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + """ + context = get_context_json(repo_root) + print(json.dumps(context, indent=2, ensure_ascii=False)) + + +# ============================================================================= +# Text Output +# ============================================================================= + +def get_context_text(repo_root: Path | None = None) -> str: + """Get context as formatted text. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Formatted text output. + """ + if repo_root is None: + repo_root = get_repo_root() + + lines = [] + lines.append("========================================") + lines.append("SESSION CONTEXT") + lines.append("========================================") + lines.append("") + + developer = get_developer(repo_root) + + # Developer section + lines.append("## DEVELOPER") + if not developer: + lines.append( + f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>" + ) + return "\n".join(lines) + + lines.append(f"Name: {developer}") + lines.append("") + + # Git status + lines.append("## GIT STATUS") + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) + branch = branch_out.strip() or "unknown" + lines.append(f"Branch: {branch}") + + _, status_out, _ = run_git(["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(["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(["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("") + + # Package git repos — independent sub-repositories + _append_package_git_context(lines, _collect_package_git_info(repo_root)) + + # Current task + lines.append("## CURRENT TASK") + current_task = get_current_task(repo_root) + if current_task: + current_task_dir = repo_root / current_task + lines.append(f"Path: {current_task}") + + ct = load_task(current_task_dir) + if ct: + lines.append(f"Name: {ct.name}") + lines.append(f"Status: {ct.status}") + lines.append(f"Created: {ct.raw.get('createdAt', 'unknown')}") + if ct.description: + lines.append(f"Description: {ct.description}") + + # Check for prd.md + prd_file = current_task_dir / "prd.md" + if prd_file.is_file(): + lines.append("") + lines.append("[!] This task has prd.md - read it for task details") + else: + lines.append("(none)") + lines.append("") + + # Active tasks + lines.append("## ACTIVE TASKS") + tasks_dir = get_tasks_dir(repo_root) + task_count = 0 + + # Collect all task data for hierarchy display + all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)} + all_statuses = {name: t.status for name, t in all_tasks.items()} + + def _print_task_tree(name: str, indent: int = 0) -> None: + nonlocal task_count + t = all_tasks[name] + progress = children_progress(t.children, all_statuses) + prefix = " " * indent + lines.append(f"{prefix}- {name}/ ({t.status}){progress} @{t.assignee or '-'}") + task_count += 1 + for child in t.children: + if child in all_tasks: + _print_task_tree(child, indent + 1) + + for dir_name in sorted(all_tasks.keys()): + if not all_tasks[dir_name].parent: + _print_task_tree(dir_name) + + if task_count == 0: + lines.append("(no active tasks)") + lines.append(f"Total: {task_count} active task(s)") + lines.append("") + + # My tasks + lines.append("## MY TASKS (Assigned to me)") + my_task_count = 0 + + for t in all_tasks.values(): + if t.assignee == developer and t.status != "done": + progress = children_progress(t.children, all_statuses) + lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress}") + my_task_count += 1 + + if my_task_count == 0: + lines.append("(no tasks assigned to you)") + lines.append("") + + # Journal file + lines.append("## JOURNAL FILE") + journal_file = get_active_journal_file(repo_root) + if journal_file: + journal_lines = count_lines(journal_file) + relative = f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}" + lines.append(f"Active file: {relative}") + lines.append(f"Line count: {journal_lines} / 2000") + if journal_lines > 1800: + lines.append("[!] WARNING: Approaching 2000 line limit!") + else: + lines.append("No journal file found") + lines.append("") + + # Packages + packages_text = get_packages_section(repo_root) + if packages_text: + lines.append(packages_text) + lines.append("") + + # Paths + lines.append("## PATHS") + lines.append(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/") + lines.append(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/") + lines.append(f"Spec: {DIR_WORKFLOW}/{DIR_SPEC}/") + lines.append("") + + lines.append("========================================") + + return "\n".join(lines) + + +# ============================================================================= +# Record Mode +# ============================================================================= + +def get_context_record_json(repo_root: Path | None = None) -> dict: + """Get record-mode context as a dictionary. + + Focused on: my active tasks, git status, current task. + """ + if repo_root is None: + repo_root = get_repo_root() + + developer = get_developer(repo_root) + tasks_dir = get_tasks_dir(repo_root) + + # Git info + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) + branch = branch_out.strip() or "unknown" + + _, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root) + git_status_count = len([line for line in status_out.splitlines() if line.strip()]) + + _, log_out, _ = run_git(["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 (single pass — collect statuses and filter by assignee) + all_tasks_list = list(iter_active_tasks(tasks_dir)) + all_statuses = {t.dir_name: t.status for t in all_tasks_list} + + my_tasks = [] + for t in all_tasks_list: + if t.assignee == developer: + done = sum( + 1 for c in t.children + if all_statuses.get(c) in ("completed", "done") + ) + my_tasks.append({ + "dir": t.dir_name, + "title": t.title, + "status": t.status, + "priority": t.priority, + "children": list(t.children), + "childrenDone": done, + "parent": t.parent, + "meta": t.meta, + }) + + # Current task + current_task_info = None + current_task = get_current_task(repo_root) + if current_task: + ct = load_task(repo_root / current_task) + if ct: + current_task_info = { + "path": current_task, + "name": ct.name, + "status": ct.status, + } + + # Package git repos + pkg_git_info = _collect_package_git_info(repo_root) + + result = { + "developer": developer or "", + "git": { + "branch": branch, + "isClean": git_status_count == 0, + "uncommittedChanges": git_status_count, + "recentCommits": commits, + }, + "myTasks": my_tasks, + "currentTask": current_task_info, + } + + if pkg_git_info: + result["packageGit"] = pkg_git_info + + return result + + +def get_context_text_record(repo_root: Path | None = None) -> str: + """Get context as formatted text for record-session mode. + + Focused output: MY ACTIVE TASKS first (with [!!!] emphasis), + then GIT STATUS, RECENT COMMITS, CURRENT TASK. + """ + if repo_root is None: + repo_root = get_repo_root() + + lines: list[str] = [] + lines.append("========================================") + lines.append("SESSION CONTEXT (RECORD MODE)") + lines.append("========================================") + lines.append("") + + developer = get_developer(repo_root) + if not developer: + lines.append( + f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>" + ) + return "\n".join(lines) + + # MY ACTIVE TASKS — first and prominent + lines.append(f"## [!!!] MY ACTIVE TASKS (Assigned to {developer})") + lines.append("[!] Review whether any should be archived before recording this session.") + lines.append("") + + tasks_dir = get_tasks_dir(repo_root) + my_task_count = 0 + + # Single pass — collect all tasks and filter by assignee + all_statuses = get_all_statuses(tasks_dir) + + for t in iter_active_tasks(tasks_dir): + if t.assignee == developer: + progress = children_progress(t.children, all_statuses) + lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress} — {t.dir_name}") + my_task_count += 1 + + if my_task_count == 0: + lines.append("(no active tasks assigned to you)") + lines.append("") + + # GIT STATUS + lines.append("## GIT STATUS") + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) + branch = branch_out.strip() or "unknown" + lines.append(f"Branch: {branch}") + + _, status_out, _ = run_git(["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(["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(["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("") + + # Package git repos — independent sub-repositories + _append_package_git_context(lines, _collect_package_git_info(repo_root)) + + # CURRENT TASK + lines.append("## CURRENT TASK") + current_task = get_current_task(repo_root) + if current_task: + lines.append(f"Path: {current_task}") + ct = load_task(repo_root / current_task) + if ct: + lines.append(f"Name: {ct.name}") + lines.append(f"Status: {ct.status}") + else: + lines.append("(none)") + lines.append("") + + lines.append("========================================") + + return "\n".join(lines) + + +def output_text(repo_root: Path | None = None) -> None: + """Output context in text format. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + """ + print(get_context_text(repo_root)) diff --git a/.trellis/scripts/common/task_context.py b/.trellis/scripts/common/task_context.py new file mode 100755 index 0000000..283c139 --- /dev/null +++ b/.trellis/scripts/common/task_context.py @@ -0,0 +1,410 @@ +#!/usr/bin/env python3 +""" +Task JSONL context management. + +Provides: + cmd_init_context - Initialize JSONL context files for a task + cmd_add_context - Add entry to JSONL context file + cmd_validate - Validate JSONL context files + cmd_list_context - List JSONL context entries +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from .cli_adapter import get_cli_adapter_auto +from .config import ( + get_packages, + is_monorepo, + resolve_package, + validate_package, +) +from .io import read_json, write_json +from .log import Colors, colored +from .paths import ( + DIR_SPEC, + DIR_WORKFLOW, + FILE_TASK_JSON, + get_repo_root, +) +from .task_utils import resolve_task_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(package: str | None = None) -> list[dict]: + """Get backend implement context entries.""" + spec_base = f"{DIR_SPEC}/{package}" if package else DIR_SPEC + return [ + {"file": f"{DIR_WORKFLOW}/{spec_base}/backend/index.md", "reason": "Backend development guide"}, + ] + + +def get_implement_frontend(package: str | None = None) -> list[dict]: + """Get frontend implement context entries.""" + spec_base = f"{DIR_SPEC}/{package}" if package else DIR_SPEC + return [ + {"file": f"{DIR_WORKFLOW}/{spec_base}/frontend/index.md", "reason": "Frontend development guide"}, + ] + + +def get_check_context(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"}, + {"file": adapter.get_trellis_command_path("check"), "reason": "Code quality check spec"}, + ] + + return entries + + +def get_debug_context(repo_root: Path) -> list[dict]: + """Get debug context entries.""" + adapter = get_cli_adapter_auto(repo_root) + + entries: list[dict] = [ + {"file": adapter.get_trellis_command_path("check"), "reason": "Code quality 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") + + +# ============================================================================= +# 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 + + # Resolve package: --package CLI → task.json.package → default_package + cli_package: str | None = getattr(args, "package", None) + package: str | None = None + if not is_monorepo(repo_root): + # Single-repo: ignore --package, no package prefix + if cli_package: + print(colored("Warning: --package ignored in single-repo project", Colors.YELLOW), file=sys.stderr) + elif cli_package: + if not validate_package(cli_package, repo_root): + packages = get_packages(repo_root) + available = ", ".join(sorted(packages.keys())) if packages else "(none)" + print(colored(f"Error: unknown package '{cli_package}'. Available: {available}", Colors.RED), file=sys.stderr) + return 1 + package = cli_package + else: + # Read task.json.package as inferred source + task_json_path = target_dir / FILE_TASK_JSON + task_pkg_value = None + if task_json_path.is_file(): + task_data = read_json(task_json_path) + if isinstance(task_data, dict): + task_pkg_value = task_data.get("package") + # Only pass string values to resolve_package (guard against malformed JSON) + task_package = task_pkg_value if isinstance(task_pkg_value, str) else None + package = resolve_package(task_package=task_package, repo_root=repo_root) + + # Monorepo fallback prohibition + if package is None: + packages = get_packages(repo_root) + available = ", ".join(sorted(packages.keys())) if packages else "(none)" + print(colored( + f"Error: monorepo project requires --package (or set default_package in config.yaml). Available: {available}", + Colors.RED, + ), file=sys.stderr) + return 1 + + print(colored("=== Initializing Agent Context Files ===", Colors.BLUE)) + print(f"Target dir: {target_dir}") + print(f"Dev type: {dev_type}") + if package: + print(f"Package: {package}") + 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(package)) + elif dev_type == "frontend": + implement_entries.extend(get_implement_frontend(package)) + elif dev_type == "fullstack": + implement_entries.extend(get_implement_backend(package)) + implement_entries.extend(get_implement_frontend(package)) + + 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(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(repo_root) + debug_file = target_dir / "debug.jsonl" + _write_jsonl(debug_file, debug_entries) + print(f" {colored('✓', Colors.GREEN)} {len(debug_entries)} entries") + + # Update task.json dev_type and package + task_json_path = target_dir / FILE_TASK_JSON + if task_json_path.is_file(): + task_data = read_json(task_json_path) + if isinstance(task_data, dict): + task_data["dev_type"] = dev_type + task_data["package"] = package # Always sync to match resolved value + write_json(task_json_path, task_data) + + print() + print(colored("✓ All context files created", Colors.GREEN)) + print() + + # Show what was auto-injected + all_injected = [e["file"] for e in implement_entries] + print(colored("Auto-injected (defaults only):", Colors.YELLOW)) + for f in all_injected: + print(f" - {f}") + print() + + # Scan spec directory for available spec files the AI should consider + spec_base = repo_root / DIR_WORKFLOW / DIR_SPEC + if package: + spec_base = spec_base / package + available_specs: list[str] = [] + if spec_base.is_dir(): + for md_file in sorted(spec_base.rglob("*.md")): + rel = str(md_file.relative_to(repo_root)) + if rel not in all_injected: + available_specs.append(rel) + + if available_specs: + print(colored("Available spec files (not yet injected):", Colors.BLUE)) + for spec in available_specs: + print(f" - {spec}") + print() + + print(colored("Next steps:", Colors.BLUE)) + print(" 1. Review the spec files above and add relevant ones for your task:") + print(f" python3 task.py add-context <dir> implement <spec-path> \"<reason>\"") + 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 diff --git a/.trellis/scripts/common/task_queue.py b/.trellis/scripts/common/task_queue.py new file mode 100755 index 0000000..f7485e2 --- /dev/null +++ b/.trellis/scripts/common/task_queue.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +Task queue utility functions. + +Provides: + list_tasks_by_status - List tasks by status + list_pending_tasks - List tasks with pending status + list_tasks_by_assignee - List tasks by assignee + list_my_tasks - List tasks assigned to current developer + get_task_stats - Get P0/P1/P2/P3 counts +""" + +from __future__ import annotations + +from pathlib import Path + +from .paths import ( + get_repo_root, + get_developer, + get_tasks_dir, +) +from .tasks import iter_active_tasks + + +# ============================================================================= +# Internal helper +# ============================================================================= + +def _task_to_dict(t) -> dict: + """Convert TaskInfo to the dict format callers expect.""" + return { + "priority": t.priority, + "id": t.raw.get("id", ""), + "title": t.title, + "status": t.status, + "assignee": t.assignee or "-", + "dir": t.dir_name, + "children": list(t.children), + "parent": t.parent, + } + + +# ============================================================================= +# Public Functions +# ============================================================================= + +def list_tasks_by_status( + filter_status: str | None = None, + repo_root: Path | None = None +) -> list[dict]: + """List tasks by status. + + Args: + filter_status: Optional status filter. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of task info dicts with keys: priority, id, title, status, assignee. + """ + if repo_root is None: + repo_root = get_repo_root() + + tasks_dir = get_tasks_dir(repo_root) + results = [] + + for t in iter_active_tasks(tasks_dir): + if filter_status and t.status != filter_status: + continue + results.append(_task_to_dict(t)) + + return results + + +def list_pending_tasks(repo_root: Path | None = None) -> list[dict]: + """List pending tasks. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of task info dicts. + """ + return list_tasks_by_status("planning", repo_root) + + +def list_tasks_by_assignee( + assignee: str, + filter_status: str | None = None, + repo_root: Path | None = None +) -> list[dict]: + """List tasks assigned to a specific developer. + + Args: + assignee: Developer name. + filter_status: Optional status filter. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of task info dicts. + """ + if repo_root is None: + repo_root = get_repo_root() + + tasks_dir = get_tasks_dir(repo_root) + results = [] + + for t in iter_active_tasks(tasks_dir): + if (t.assignee or "-") != assignee: + continue + if filter_status and t.status != filter_status: + continue + results.append(_task_to_dict(t)) + + return results + + +def list_my_tasks( + filter_status: str | None = None, + repo_root: Path | None = None +) -> list[dict]: + """List tasks assigned to current developer. + + Args: + filter_status: Optional status filter. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of task info dicts. + + Raises: + ValueError: If developer not set. + """ + if repo_root is None: + repo_root = get_repo_root() + + developer = get_developer(repo_root) + if not developer: + raise ValueError("Developer not set") + + return list_tasks_by_assignee(developer, filter_status, repo_root) + + +def get_task_stats(repo_root: Path | None = None) -> dict[str, int]: + """Get task statistics. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Dict with keys: P0, P1, P2, P3, Total. + """ + if repo_root is None: + repo_root = get_repo_root() + + tasks_dir = get_tasks_dir(repo_root) + stats = {"P0": 0, "P1": 0, "P2": 0, "P3": 0, "Total": 0} + + for t in iter_active_tasks(tasks_dir): + if t.priority in stats: + stats[t.priority] += 1 + stats["Total"] += 1 + + return stats + + +def format_task_stats(stats: dict[str, int]) -> str: + """Format task stats as string. + + Args: + stats: Stats dict from get_task_stats. + + Returns: + Formatted string like "P0:0 P1:1 P2:2 P3:0 Total:3". + """ + return f"P0:{stats['P0']} P1:{stats['P1']} P2:{stats['P2']} P3:{stats['P3']} Total:{stats['Total']}" + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + stats = get_task_stats() + print(format_task_stats(stats)) + print() + print("Pending tasks:") + for task in list_pending_tasks(): + print(f" {task['priority']}|{task['id']}|{task['title']}|{task['status']}|{task['assignee']}") diff --git a/.trellis/scripts/common/task_store.py b/.trellis/scripts/common/task_store.py new file mode 100755 index 0000000..94ac7e5 --- /dev/null +++ b/.trellis/scripts/common/task_store.py @@ -0,0 +1,536 @@ +#!/usr/bin/env python3 +""" +Task CRUD operations. + +Provides: + ensure_tasks_dir - Ensure tasks directory exists + cmd_create - Create a new task + cmd_archive - Archive completed task + cmd_set_branch - Set git branch for task + cmd_set_base_branch - Set PR target branch + cmd_set_scope - Set scope for PR title + cmd_add_subtask - Link child task to parent + cmd_remove_subtask - Unlink child task from parent +""" + +from __future__ import annotations + +import argparse +import re +import sys +from datetime import datetime +from pathlib import Path + +from .config import ( + get_packages, + is_monorepo, + resolve_package, + validate_package, +) +from .git import run_git +from .io import read_json, write_json +from .log import Colors, colored +from .paths import ( + DIR_ARCHIVE, + DIR_TASKS, + DIR_WORKFLOW, + FILE_TASK_JSON, + clear_current_task, + generate_task_date_prefix, + get_current_task, + get_developer, + get_repo_root, + get_tasks_dir, +) +from .task_utils import ( + archive_task_complete, + find_task_by_name, + resolve_task_dir, + run_task_hooks, +) + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def _slugify(title: str) -> str: + """Convert title to slug (only works with ASCII).""" + result = title.lower() + result = re.sub(r"[^a-z0-9]", "-", result) + result = re.sub(r"-+", "-", result) + result = result.strip("-") + return result + + +def ensure_tasks_dir(repo_root: Path) -> Path: + """Ensure tasks directory exists.""" + tasks_dir = get_tasks_dir(repo_root) + archive_dir = tasks_dir / "archive" + + if not tasks_dir.exists(): + tasks_dir.mkdir(parents=True) + print(colored(f"Created tasks directory: {tasks_dir}", Colors.GREEN), file=sys.stderr) + + if not archive_dir.exists(): + archive_dir.mkdir(parents=True) + + return tasks_dir + + +# ============================================================================= +# Command: create +# ============================================================================= + +def cmd_create(args: argparse.Namespace) -> int: + """Create a new task.""" + repo_root = get_repo_root() + + if not args.title: + print(colored("Error: title is required", Colors.RED), file=sys.stderr) + return 1 + + # Validate --package (CLI source: fail-fast) + package: str | None = getattr(args, "package", None) + if not is_monorepo(repo_root): + # Single-repo: ignore --package, no package prefix + if package: + print(colored(f"Warning: --package ignored in single-repo project", Colors.YELLOW), file=sys.stderr) + package = None + elif package: + if not validate_package(package, repo_root): + packages = get_packages(repo_root) + available = ", ".join(sorted(packages.keys())) if packages else "(none)" + print(colored(f"Error: unknown package '{package}'. Available: {available}", Colors.RED), file=sys.stderr) + return 1 + else: + # Inferred: default_package → None (no task.json yet for create) + package = resolve_package(repo_root=repo_root) + + # Default assignee to current developer + assignee = args.assignee + if not assignee: + assignee = get_developer(repo_root) + if not assignee: + print(colored("Error: No developer set. Run init_developer.py first or use --assignee", Colors.RED), file=sys.stderr) + return 1 + + ensure_tasks_dir(repo_root) + + # Get current developer as creator + creator = get_developer(repo_root) or assignee + + # Generate slug if not provided + slug = args.slug or _slugify(args.title) + if not slug: + print(colored("Error: could not generate slug from title", Colors.RED), file=sys.stderr) + return 1 + + # Create task directory with MM-DD-slug format + tasks_dir = get_tasks_dir(repo_root) + date_prefix = generate_task_date_prefix() + dir_name = f"{date_prefix}-{slug}" + task_dir = tasks_dir / dir_name + task_json_path = task_dir / FILE_TASK_JSON + + if task_dir.exists(): + print(colored(f"Warning: Task directory already exists: {dir_name}", Colors.YELLOW), file=sys.stderr) + else: + task_dir.mkdir(parents=True) + + today = datetime.now().strftime("%Y-%m-%d") + + # Record current branch as base_branch (PR target) + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) + current_branch = branch_out.strip() or "main" + + task_data = { + "id": slug, + "name": slug, + "title": args.title, + "description": args.description or "", + "status": "planning", + "dev_type": None, + "scope": None, + "package": package, + "priority": args.priority, + "creator": creator, + "assignee": assignee, + "createdAt": today, + "completedAt": None, + "branch": None, + "base_branch": current_branch, + "worktree_path": None, + "current_phase": 0, + "next_action": [ + {"phase": 1, "action": "brainstorm"}, + {"phase": 2, "action": "research"}, + {"phase": 3, "action": "implement"}, + {"phase": 4, "action": "check"}, + {"phase": 5, "action": "update-spec"}, + {"phase": 6, "action": "record-session"}, + ], + "commit": None, + "pr_url": None, + "subtasks": [], + "children": [], + "parent": None, + "relatedFiles": [], + "notes": "", + "meta": {}, + } + + write_json(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(parent_json_path) + if parent_data: + # Add child to parent's children list + parent_children = parent_data.get("children", []) + if dir_name not in parent_children: + parent_children.append(dir_name) + parent_data["children"] = parent_children + write_json(parent_json_path, parent_data) + + # Set parent in child's task.json + task_data["parent"] = parent_dir.name + write_json(task_json_path, task_data) + + print(colored(f"Linked as child of: {parent_dir.name}", Colors.GREEN), file=sys.stderr) + + 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_task_hooks("after_create", task_json_path, repo_root) + return 0 + + +# ============================================================================= +# Command: archive +# ============================================================================= + +def cmd_archive(args: argparse.Namespace) -> int: + """Archive completed task.""" + repo_root = get_repo_root() + task_name = args.name + + if not task_name: + print(colored("Error: Task name is required", Colors.RED), file=sys.stderr) + return 1 + + tasks_dir = get_tasks_dir(repo_root) + + # 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) + # Import lazily to avoid circular dependency + from .tasks import iter_active_tasks + for t in iter_active_tasks(tasks_dir): + print(f" - {t.dir_name}/", file=sys.stderr) + return 1 + + dir_name = task_dir.name + task_json_path = task_dir / FILE_TASK_JSON + + # Update status before archiving + today = datetime.now().strftime("%Y-%m-%d") + if task_json_path.is_file(): + data = read_json(task_json_path) + if data: + data["status"] = "completed" + data["completedAt"] = today + write_json(task_json_path, data) + + # Handle subtask relationships on archive + 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(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(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(child_json) + if child_data: + child_data["parent"] = None + write_json(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_task_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(["add", "-A", tasks_rel], cwd=repo_root) + + # Check if there are staged changes + rc, _, _ = run_git( + ["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(["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(parent_json_path) + child_data = read_json(child_json_path) + + if not parent_data or not child_data: + print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr) + return 1 + + # Check if child already has a parent + existing_parent = child_data.get("parent") + if existing_parent: + print(colored(f"Error: Child task already has a parent: {existing_parent}", Colors.RED), file=sys.stderr) + return 1 + + # Add child to parent's children list + parent_children = parent_data.get("children", []) + child_dir_name = child_dir.name + if child_dir_name not in parent_children: + parent_children.append(child_dir_name) + parent_data["children"] = parent_children + + # Set parent in child's task.json + child_data["parent"] = parent_dir.name + + # Write both + write_json(parent_json_path, parent_data) + write_json(child_json_path, child_data) + + print(colored(f"Linked: {child_dir.name} -> {parent_dir.name}", Colors.GREEN), file=sys.stderr) + return 0 + + +# ============================================================================= +# Command: remove-subtask +# ============================================================================= + +def cmd_remove_subtask(args: argparse.Namespace) -> int: + """Unlink a child task from a parent task.""" + repo_root = get_repo_root() + + parent_dir = resolve_task_dir(args.parent_dir, repo_root) + child_dir = resolve_task_dir(args.child_dir, repo_root) + + parent_json_path = parent_dir / FILE_TASK_JSON + child_json_path = child_dir / FILE_TASK_JSON + + if not parent_json_path.is_file(): + print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr) + return 1 + + if not child_json_path.is_file(): + print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) + return 1 + + parent_data = read_json(parent_json_path) + child_data = read_json(child_json_path) + + if not parent_data or not child_data: + print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr) + return 1 + + # Remove child from parent's children list + parent_children = parent_data.get("children", []) + child_dir_name = child_dir.name + if child_dir_name in parent_children: + parent_children.remove(child_dir_name) + parent_data["children"] = parent_children + + # Clear parent in child's task.json + child_data["parent"] = None + + # Write both + write_json(parent_json_path, parent_data) + write_json(child_json_path, child_data) + + print(colored(f"Unlinked: {child_dir.name} from {parent_dir.name}", Colors.GREEN), file=sys.stderr) + return 0 + + +# ============================================================================= +# Command: set-branch +# ============================================================================= + +def cmd_set_branch(args: argparse.Namespace) -> int: + """Set git branch for task.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + branch = args.branch + + if not branch: + print(colored("Error: Missing arguments", Colors.RED)) + print("Usage: python3 task.py set-branch <task-dir> <branch-name>") + return 1 + + task_json = target_dir / FILE_TASK_JSON + if not task_json.is_file(): + print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) + return 1 + + data = read_json(task_json) + if not data: + return 1 + + data["branch"] = branch + write_json(task_json, data) + + print(colored(f"✓ Branch set to: {branch}", Colors.GREEN)) + 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(task_json) + if not data: + return 1 + + data["base_branch"] = base_branch + write_json(task_json, data) + + print(colored(f"✓ Base branch set to: {base_branch}", Colors.GREEN)) + print(f" PR will target: {base_branch}") + return 0 + + +# ============================================================================= +# Command: set-scope +# ============================================================================= + +def cmd_set_scope(args: argparse.Namespace) -> int: + """Set scope for PR title.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + scope = args.scope + + if not scope: + print(colored("Error: Missing arguments", Colors.RED)) + print("Usage: python3 task.py set-scope <task-dir> <scope>") + return 1 + + task_json = target_dir / FILE_TASK_JSON + if not task_json.is_file(): + print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) + return 1 + + data = read_json(task_json) + if not data: + return 1 + + data["scope"] = scope + write_json(task_json, data) + + print(colored(f"✓ Scope set to: {scope}", Colors.GREEN)) + return 0 diff --git a/.trellis/scripts/common/task_utils.py b/.trellis/scripts/common/task_utils.py new file mode 100755 index 0000000..62c215e --- /dev/null +++ b/.trellis/scripts/common/task_utils.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +""" +Task utility functions. + +Provides: + is_safe_task_path - Validate task path is safe to operate on + find_task_by_name - Find task directory by name + resolve_task_dir - Resolve task directory from name, relative, or absolute path + archive_task_dir - Archive task to monthly directory + run_task_hooks - Run lifecycle hooks for task events +""" + +from __future__ import annotations + +import shutil +import sys +from datetime import datetime +from pathlib import Path + +from .paths import get_repo_root, get_tasks_dir + + +# ============================================================================= +# Path Safety +# ============================================================================= + +def is_safe_task_path(task_path: str, repo_root: Path | None = None) -> bool: + """Check if a relative task path is safe to operate on. + + Args: + task_path: Task path (relative to repo_root). + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True if safe, False if dangerous. + """ + if repo_root is None: + repo_root = get_repo_root() + + normalized = task_path.replace("\\", "/") + + # Check empty or null + if not normalized or normalized == "null": + print("Error: empty or null task path", file=sys.stderr) + return False + + # Reject absolute paths + if Path(task_path).is_absolute(): + print(f"Error: absolute path not allowed: {task_path}", file=sys.stderr) + return False + + # Reject ".", "..", paths starting with "./" or "../", or containing ".." + if normalized in (".", "..") or normalized.startswith("./") or normalized.startswith("../") or ".." in normalized: + print(f"Error: path traversal not allowed: {task_path}", file=sys.stderr) + return False + + # Final check: ensure resolved path is not the repo root + abs_path = repo_root / Path(normalized) + if abs_path.exists(): + try: + resolved = abs_path.resolve() + root_resolved = repo_root.resolve() + if resolved == root_resolved: + print(f"Error: path resolves to repo root: {task_path}", file=sys.stderr) + return False + except (OSError, IOError): + pass + + return True + + +# ============================================================================= +# Task Lookup +# ============================================================================= + +def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None: + """Find task directory by name (exact or suffix match). + + Args: + task_name: Task name to find. + tasks_dir: Tasks directory path. + + Returns: + Absolute path to task directory, or None if not found. + """ + if not task_name or not tasks_dir or not tasks_dir.is_dir(): + return None + + # Try exact match first + exact_match = tasks_dir / task_name + if exact_match.is_dir(): + return exact_match + + # Try suffix match (e.g., "my-task" matches "01-21-my-task") + for d in tasks_dir.iterdir(): + if d.is_dir() and d.name.endswith(f"-{task_name}"): + return d + + return None + + +# ============================================================================= +# Archive Operations +# ============================================================================= + +def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path | None: + """Archive a task directory to archive/{YYYY-MM}/. + + Args: + task_dir_abs: Absolute path to task directory. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Path to archived directory, or None on error. + """ + if not task_dir_abs.is_dir(): + print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr) + return None + + # Get tasks directory (parent of the task) + tasks_dir = task_dir_abs.parent + archive_dir = tasks_dir / "archive" + year_month = datetime.now().strftime("%Y-%m") + month_dir = archive_dir / year_month + + # Create archive directory + try: + month_dir.mkdir(parents=True, exist_ok=True) + except (OSError, IOError) as e: + print(f"Error: Failed to create archive directory: {e}", file=sys.stderr) + return None + + # Move task to archive + task_name = task_dir_abs.name + dest = month_dir / task_name + + try: + shutil.move(str(task_dir_abs), str(dest)) + except (OSError, IOError, shutil.Error) as e: + print(f"Error: Failed to move task to archive: {e}", file=sys.stderr) + return None + + return dest + + +def archive_task_complete( + task_dir_abs: Path, + repo_root: Path | None = None +) -> dict[str, str]: + """Complete archive workflow: archive directory. + + Args: + task_dir_abs: Absolute path to task directory. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Dict with archive result info. + """ + if not task_dir_abs.is_dir(): + print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr) + return {} + + archive_dest = archive_task_dir(task_dir_abs, repo_root) + if archive_dest: + return {"archived_to": str(archive_dest)} + + return {} + + +# ============================================================================= +# Task Directory Resolution +# ============================================================================= + +def resolve_task_dir(target_dir: str, repo_root: Path) -> Path: + """Resolve task directory to absolute path. + + Supports: + - Absolute path: /path/to/task + - Relative path: .trellis/tasks/01-31-my-task + - Task name: my-task (uses find_task_by_name for lookup) + + Args: + target_dir: Task directory specification. + repo_root: Repository root path. + + Returns: + Resolved absolute path. + """ + if not target_dir: + return Path() + + normalized = target_dir.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + + # Absolute path + if Path(target_dir).is_absolute(): + return Path(target_dir) + + # Relative path (contains path separator or starts with .trellis) + if "/" in normalized or normalized.startswith(".trellis"): + return repo_root / Path(normalized) + + # Task name - try to find in tasks directory + tasks_dir = get_tasks_dir(repo_root) + found = find_task_by_name(target_dir, tasks_dir) + if found: + return found + + # Fallback to treating as relative path + return repo_root / Path(normalized) + + +# ============================================================================= +# Lifecycle Hooks +# ============================================================================= + +def run_task_hooks(event: str, task_json_path: Path, repo_root: Path) -> None: + """Run lifecycle hooks for a task event. + + Args: + event: Event name (e.g. "after_create"). + task_json_path: Absolute path to the task's task.json. + repo_root: Repository root for cwd and config lookup. + """ + import os + import subprocess + + from .config import get_hooks + from .log import Colors, colored + + commands = get_hooks(event, repo_root) + if not commands: + return + + env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)} + + for cmd in commands: + try: + result = subprocess.run( + cmd, + shell=True, + cwd=repo_root, + env=env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if result.returncode != 0: + print( + colored(f"[WARN] Hook failed ({event}): {cmd}", Colors.YELLOW), + file=sys.stderr, + ) + if result.stderr.strip(): + print(f" {result.stderr.strip()}", file=sys.stderr) + except Exception as e: + print( + colored(f"[WARN] Hook error ({event}): {cmd} — {e}", Colors.YELLOW), + file=sys.stderr, + ) + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + repo = get_repo_root() + tasks = get_tasks_dir(repo) + + print(f"Tasks dir: {tasks}") + print(f"is_safe_task_path('.trellis/tasks/test'): {is_safe_task_path('.trellis/tasks/test', repo)}") + print(f"is_safe_task_path('../test'): {is_safe_task_path('../test', repo)}") diff --git a/.trellis/scripts/common/tasks.py b/.trellis/scripts/common/tasks.py new file mode 100755 index 0000000..47d78c2 --- /dev/null +++ b/.trellis/scripts/common/tasks.py @@ -0,0 +1,109 @@ +""" +Task data access layer. + +Single source of truth for loading and iterating task directories. +Replaces scattered task.json parsing across 9+ files. + +Provides: + load_task — Load a single task by directory path + iter_active_tasks — Iterate all non-archived tasks (sorted) + get_all_statuses — Get {dir_name: status} map for children progress +""" + +from __future__ import annotations + +from collections.abc import Iterator +from pathlib import Path + +from .io import read_json +from .paths import FILE_TASK_JSON +from .types import TaskInfo + + +def load_task(task_dir: Path) -> TaskInfo | None: + """Load task from a directory containing task.json. + + Args: + task_dir: Absolute path to the task directory. + + Returns: + TaskInfo if task.json exists and is valid, None otherwise. + """ + task_json = task_dir / FILE_TASK_JSON + if not task_json.is_file(): + return None + + data = read_json(task_json) + if not data: + return None + + return TaskInfo( + dir_name=task_dir.name, + directory=task_dir, + title=data.get("title") or data.get("name") or "unknown", + status=data.get("status", "unknown"), + assignee=data.get("assignee", ""), + priority=data.get("priority", "P2"), + children=tuple(data.get("children", [])), + parent=data.get("parent"), + package=data.get("package"), + raw=data, + ) + + +def iter_active_tasks(tasks_dir: Path) -> Iterator[TaskInfo]: + """Iterate all active (non-archived) tasks, sorted by directory name. + + Skips the "archive" directory and directories without valid task.json. + + Args: + tasks_dir: Path to the tasks directory. + + Yields: + TaskInfo for each valid task. + """ + if not tasks_dir.is_dir(): + return + + for d in sorted(tasks_dir.iterdir()): + if not d.is_dir() or d.name == "archive": + continue + info = load_task(d) + if info is not None: + yield info + + +def get_all_statuses(tasks_dir: Path) -> dict[str, str]: + """Get a {dir_name: status} mapping for all active tasks. + + Useful for computing children progress without loading full TaskInfo. + + Args: + tasks_dir: Path to the tasks directory. + + Returns: + Dict mapping directory names to status strings. + """ + return {t.dir_name: t.status for t in iter_active_tasks(tasks_dir)} + + +def children_progress( + children: tuple[str, ...] | list[str], + all_statuses: dict[str, str], +) -> str: + """Format children progress string like " [2/3 done]". + + Args: + children: List of child directory names. + all_statuses: Status map from get_all_statuses(). + + Returns: + Formatted string, or "" if no children. + """ + if not children: + return "" + done = sum( + 1 for c in children + if all_statuses.get(c) in ("completed", "done") + ) + return f" [{done}/{len(children)} done]" diff --git a/.trellis/scripts/common/types.py b/.trellis/scripts/common/types.py new file mode 100755 index 0000000..f78b7df --- /dev/null +++ b/.trellis/scripts/common/types.py @@ -0,0 +1,112 @@ +""" +Core type definitions for Trellis task data. + +Provides: + TaskData — TypedDict for task.json shape (read-path type hints only) + TaskInfo — Frozen dataclass for loaded task (the public API type) + AgentRecord — TypedDict for registry.json agent entries +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TypedDict + + +# ============================================================================= +# task.json shape (TypedDict — used only for read-path type hints) +# ============================================================================= + +class TaskData(TypedDict, total=False): + """Shape of task.json on disk. + + Used only for type annotations when reading task.json. + Writes must use the original dict to avoid losing unknown fields. + """ + + id: str + name: str + title: str + description: str + status: str + dev_type: str + scope: str | None + package: str | None + priority: str + creator: str + assignee: str + createdAt: str + completedAt: str | None + branch: str | None + base_branch: str | None + worktree_path: str | None + current_phase: int + next_action: list[dict] + commit: str | None + pr_url: str | None + subtasks: list[str] + children: list[str] + parent: str | None + relatedFiles: list[str] + notes: str + meta: dict + + +# ============================================================================= +# Loaded task object (frozen dataclass — the public API type) +# ============================================================================= + +@dataclass(frozen=True) +class TaskInfo: + """Immutable view of a loaded task. + + Created by load_task() / iter_active_tasks(). + Contains the commonly accessed fields; the original dict + is preserved in `raw` for write-back and uncommon field access. + """ + + dir_name: str + directory: Path + title: str + status: str + assignee: str + priority: str + children: tuple[str, ...] + parent: str | None + package: str | None + raw: dict # original dict — use for writes and uncommon fields + + @property + def name(self) -> str: + """Task name (id or name field).""" + return self.raw.get("name") or self.raw.get("id") or self.dir_name + + @property + def description(self) -> str: + return self.raw.get("description", "") + + @property + def branch(self) -> str | None: + return self.raw.get("branch") + + @property + def meta(self) -> dict: + return self.raw.get("meta", {}) + + +# ============================================================================= +# registry.json agent entry +# ============================================================================= + +class AgentRecord(TypedDict, total=False): + """Shape of an agent entry in registry.json.""" + + id: str + pid: int + task_dir: str + worktree_path: str + branch: str + platform: str + started_at: str + status: str 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..b295b7d --- /dev/null +++ b/.trellis/scripts/create_bootstrap.py @@ -0,0 +1,298 @@ +#!/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, +) +from common.config import get_spec_base, resolve_package + + +# ============================================================================= +# 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. +**Starting from scratch = 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(spec_base: str) -> str: + """Write PRD backend section.""" + return f""" + +### Backend Guidelines + +| File | What to Document | +|------|------------------| +| `.trellis/{spec_base}/backend/directory-structure.md` | Where different file types go (routes, services, utils) | +| `.trellis/{spec_base}/backend/database-guidelines.md` | ORM, migrations, query patterns, naming conventions | +| `.trellis/{spec_base}/backend/error-handling.md` | How errors are caught, logged, and returned | +| `.trellis/{spec_base}/backend/logging-guidelines.md` | Log levels, format, what to log | +| `.trellis/{spec_base}/backend/quality-guidelines.md` | Code review standards, testing requirements | +""" + + +def write_prd_frontend_section(spec_base: str) -> str: + """Write PRD frontend section.""" + return f""" + +### Frontend Guidelines + +| File | What to Document | +|------|------------------| +| `.trellis/{spec_base}/frontend/directory-structure.md` | Component/page/hook organization | +| `.trellis/{spec_base}/frontend/component-guidelines.md` | Component patterns, props conventions | +| `.trellis/{spec_base}/frontend/hook-guidelines.md` | Custom hook naming, patterns | +| `.trellis/{spec_base}/frontend/state-management.md` | State library, patterns, what goes where | +| `.trellis/{spec_base}/frontend/type-safety.md` | TypeScript conventions, type organization | +| `.trellis/{spec_base}/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, spec_base: str) -> None: + """Write prd.md file.""" + content = write_prd_header() + + if project_type == "frontend": + content += write_prd_frontend_section(spec_base) + elif project_type == "backend": + content += write_prd_backend_section(spec_base) + else: # fullstack + content += write_prd_backend_section(spec_base) + content += write_prd_frontend_section(spec_base) + + 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, spec_base: 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 = [f".trellis/{spec_base}/frontend/"] + elif project_type == "backend": + subtasks = [ + {"name": "Fill backend guidelines", "status": "pending"}, + {"name": "Add code examples", "status": "pending"}, + ] + related_files = [f".trellis/{spec_base}/backend/"] + else: # fullstack + subtasks = [ + {"name": "Fill backend guidelines", "status": "pending"}, + {"name": "Fill frontend guidelines", "status": "pending"}, + {"name": "Add code examples", "status": "pending"}, + ] + related_files = [f".trellis/{spec_base}/backend/", f".trellis/{spec_base}/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 + + # Resolve spec base path (monorepo: spec/<package>, single-repo: spec) + package = resolve_package(repo_root=repo_root) + spec_base = get_spec_base(package, repo_root) + + 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, spec_base) + write_prd(task_dir, project_type, spec_base) + + # 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/hooks/linear_sync.py b/.trellis/scripts/hooks/linear_sync.py new file mode 100755 index 0000000..5659fde --- /dev/null +++ b/.trellis/scripts/hooks/linear_sync.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Linear sync hook for Trellis task lifecycle. + +Syncs task events to Linear via the `linearis` CLI. + +Usage (called automatically by task.py hooks): + python3 .trellis/scripts/hooks/linear_sync.py create + python3 .trellis/scripts/hooks/linear_sync.py start + python3 .trellis/scripts/hooks/linear_sync.py archive + +Manual usage: + TASK_JSON_PATH=.trellis/tasks/<name>/task.json python3 .trellis/scripts/hooks/linear_sync.py sync + +Environment: + TASK_JSON_PATH - Absolute path to task.json (set by task.py) + +Configuration: + .trellis/hooks.local.json - Local config (gitignored), example: + { + "linear": { + "team": "TEAM_KEY", + "project": "Project Name", + "assignees": { + "dev-name": "linear-user-id" + } + } + } +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +# ─── Configuration ──────────────────────────────────────────────────────────── + +# Trellis priority → Linear priority (1=Urgent, 2=High, 3=Medium, 4=Low) +PRIORITY_MAP = {"P0": 1, "P1": 2, "P2": 3, "P3": 4} + +# Linear status names (must match your team's workflow) +STATUS_IN_PROGRESS = "In Progress" +STATUS_DONE = "Done" + + +def _load_config() -> dict: + """Load local hook config from .trellis/hooks.local.json.""" + task_json_path = os.environ.get("TASK_JSON_PATH", "") + if task_json_path: + # Walk up from task.json to find .trellis/ + trellis_dir = Path(task_json_path).parent.parent.parent + else: + trellis_dir = Path(".trellis") + + config_path = trellis_dir / "hooks.local.json" + try: + with open(config_path, encoding="utf-8") as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + return {} + + +CONFIG = _load_config() +LINEAR_CFG = CONFIG.get("linear", {}) + +TEAM = LINEAR_CFG.get("team", "") +PROJECT = LINEAR_CFG.get("project", "") +ASSIGNEE_MAP = LINEAR_CFG.get("assignees", {}) + +# ─── Helpers ────────────────────────────────────────────────────────────────── + + +def _read_task() -> tuple[dict, str]: + path = os.environ.get("TASK_JSON_PATH", "") + if not path: + print("TASK_JSON_PATH not set", file=sys.stderr) + sys.exit(1) + with open(path, encoding="utf-8") as f: + return json.load(f), path + + +def _write_task(data: dict, path: str) -> None: + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write("\n") + + +def _linearis(*args: str) -> dict | None: + result = subprocess.run( + ["linearis", *args], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if result.returncode != 0: + print(f"linearis error: {result.stderr.strip()}", file=sys.stderr) + sys.exit(1) + stdout = result.stdout.strip() + if stdout: + return json.loads(stdout) + return None + + +def _get_linear_issue(task: dict) -> str | None: + meta = task.get("meta") + if isinstance(meta, dict): + return meta.get("linear_issue") + return None + + +# ─── Actions ────────────────────────────────────────────────────────────────── + + +def cmd_create() -> None: + if not TEAM: + print("No linear.team configured in hooks.local.json", file=sys.stderr) + sys.exit(1) + + task, path = _read_task() + + # Skip if already linked + if _get_linear_issue(task): + print(f"Already linked: {_get_linear_issue(task)}") + return + + title = task.get("title") or task.get("name") or "Untitled" + args = ["issues", "create", title, "--team", TEAM] + + # Map priority + priority = PRIORITY_MAP.get(task.get("priority", ""), 0) + if priority: + args.extend(["-p", str(priority)]) + + # Set project + if PROJECT: + args.extend(["--project", PROJECT]) + + # Assign to Linear user + assignee = task.get("assignee", "") + linear_user_id = ASSIGNEE_MAP.get(assignee) + if linear_user_id: + args.extend(["--assignee", linear_user_id]) + + # Link to parent's Linear issue if available + parent_issue = _resolve_parent_linear_issue(task) + if parent_issue: + args.extend(["--parent-ticket", parent_issue]) + + result = _linearis(*args) + if result and "identifier" in result: + if not isinstance(task.get("meta"), dict): + task["meta"] = {} + task["meta"]["linear_issue"] = result["identifier"] + _write_task(task, path) + print(f"Created Linear issue: {result['identifier']}") + + +def cmd_start() -> None: + task, _ = _read_task() + issue = _get_linear_issue(task) + if not issue: + return + _linearis("issues", "update", issue, "-s", STATUS_IN_PROGRESS) + print(f"Updated {issue} -> {STATUS_IN_PROGRESS}") + cmd_sync() + + +def cmd_archive() -> None: + task, _ = _read_task() + issue = _get_linear_issue(task) + if not issue: + return + _linearis("issues", "update", issue, "-s", STATUS_DONE) + print(f"Updated {issue} -> {STATUS_DONE}") + + +def cmd_sync() -> None: + """Sync prd.md content to Linear issue description.""" + task, _ = _read_task() + issue = _get_linear_issue(task) + if not issue: + print("No linear_issue in meta, run create first", file=sys.stderr) + sys.exit(1) + + # Find prd.md next to task.json + task_json_path = os.environ.get("TASK_JSON_PATH", "") + prd_path = Path(task_json_path).parent / "prd.md" + if not prd_path.is_file(): + print(f"No prd.md found at {prd_path}", file=sys.stderr) + sys.exit(1) + + description = prd_path.read_text(encoding="utf-8").strip() + _linearis("issues", "update", issue, "-d", description) + print(f"Synced prd.md to {issue} description") + + +# ─── Parent Issue Resolution ───────────────────────────────────────────────── + + +def _resolve_parent_linear_issue(task: dict) -> str | None: + """Find parent task's Linear issue identifier.""" + parent_name = task.get("parent") + if not parent_name: + return None + + task_json_path = os.environ.get("TASK_JSON_PATH", "") + if not task_json_path: + return None + + current_task_dir = Path(task_json_path).parent + tasks_dir = current_task_dir.parent + parent_json = tasks_dir / parent_name / "task.json" + + if parent_json.exists(): + try: + with open(parent_json, encoding="utf-8") as f: + parent_task = json.load(f) + return _get_linear_issue(parent_task) + except (json.JSONDecodeError, OSError): + pass + return None + + +# ─── Main ───────────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + action = sys.argv[1] if len(sys.argv) > 1 else "" + actions = { + "create": cmd_create, + "start": cmd_start, + "archive": cmd_archive, + "sync": cmd_sync, + } + fn = actions.get(action) + if fn: + fn() + else: + print(f"Unknown action: {action}", file=sys.stderr) + print(f"Valid actions: {', '.join(actions)}", file=sys.stderr) + sys.exit(1) 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/_bootstrap.py b/.trellis/scripts/multi_agent/_bootstrap.py new file mode 100755 index 0000000..bba6061 --- /dev/null +++ b/.trellis/scripts/multi_agent/_bootstrap.py @@ -0,0 +1,17 @@ +"""Bootstrap path setup for multi_agent scripts. + +Import this module before importing from common/: + + import _bootstrap # noqa: F401 + +This adds the parent scripts/ directory to sys.path so that +`from common.xxx import yyy` works when running scripts directly +via `python3 .trellis/scripts/multi_agent/some_script.py`. +""" + +import sys +from pathlib import Path + +_scripts_dir = str(Path(__file__).resolve().parent.parent) +if _scripts_dir not in sys.path: + sys.path.insert(0, _scripts_dir) diff --git a/.trellis/scripts/multi_agent/cleanup.py b/.trellis/scripts/multi_agent/cleanup.py new file mode 100755 index 0000000..0ed2588 --- /dev/null +++ b/.trellis/scripts/multi_agent/cleanup.py @@ -0,0 +1,398 @@ +#!/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 json +import shutil +import subprocess +import sys +from pathlib import Path + +import _bootstrap # noqa: F401 — adds parent scripts/ dir to sys.path + +from common.git import run_git +from common.log import Colors, log_info, log_success, log_warn, log_error +from common.paths import FILE_TASK_JSON, 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, log_info, log_success, log_warn, log_error +# are now imported from common.log above. + + +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") + + +def _warn_submodule_prs(task_dir_abs: Path) -> None: + """Print reminders for any open submodule PRs found in task.json.""" + task_json = task_dir_abs / FILE_TASK_JSON + if not task_json.is_file(): + return + + try: + task_data = json.loads(task_json.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return + + submodule_prs = task_data.get("submodule_prs") + if not isinstance(submodule_prs, dict) or not submodule_prs: + return + + for name, url in submodule_prs.items(): + log_warn(f"Submodule PR still open: {name} -> {url}") + log_info("Remember to close/merge submodule PRs before cleanup") + + +# ============================================================================= +# 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() + + 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 + + # Check for submodule PRs and archive task directory + 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(): + _warn_submodule_prs(task_dir_abs) + 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( + ["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 (and check for submodule PRs) + task_dir = registry_get_task_dir(worktree_path, repo_root) + if task_dir and is_safe_task_path(task_dir, repo_root): + task_dir_abs_for_warn = repo_root / task_dir + if task_dir_abs_for_warn.is_dir(): + _warn_submodule_prs(task_dir_abs_for_warn) + 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( + ["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(["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( + ["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( + ["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(["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( + ["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(["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..0028548 --- /dev/null +++ b/.trellis/scripts/multi_agent/create_pr.py @@ -0,0 +1,620 @@ +#!/usr/bin/env python3 +""" +Multi-Agent Pipeline: Create PR. + +Usage: + python3 create_pr.py [task-dir] [--dry-run] + +This script: +1. Handles submodule changes (commit, push, PR) if any submodules are configured +2. Stages and commits all main-repo changes (excluding workspace/) +3. Pushes to origin +4. Creates a Draft PR using `gh pr create` +5. Updates task.json with status="completed", pr_url, submodule_prs, 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 subprocess +import sys +from pathlib import Path + +import _bootstrap # noqa: F401 — adds parent scripts/ dir to sys.path + +from common.config import get_submodule_packages +from common.git import run_git +from common.io import read_json, write_json +from common.log import Colors +from common.paths import ( + DIR_WORKFLOW, + FILE_TASK_JSON, + get_current_task, + get_repo_root, +) +from common.phase import get_phase_for_action + +# Colors, read_json, write_json +# are now imported from common.log and common.io above. + + +# ============================================================================= +# Submodule PR Helpers +# ============================================================================= + +# Warning message prepended to main PR body when submodule PRs exist +_SUBMODULE_SQUASH_WARNING_MARKER = ( + "Merge submodule PR(s) first. If squash-merged, update submodule ref after merge." +) + + +def _get_submodule_default_branch(submodule_abs: Path) -> str: + """Get the default branch of a submodule repository. + + Uses `git symbolic-ref refs/remotes/origin/HEAD` for portability + (no grep, no English-dependent output). + + Returns: + Default branch name (e.g. "main"), falls back to "main" on failure. + """ + ret, out, _ = run_git( + ["symbolic-ref", "refs/remotes/origin/HEAD"], cwd=submodule_abs + ) + if ret == 0 and out.strip(): + # Output: "refs/remotes/origin/main" -> "main" + ref = out.strip() + prefix = "refs/remotes/origin/" + if ref.startswith(prefix): + return ref[len(prefix):] + return "main" + + +def _process_submodule_changes( + repo_root: Path, + current_branch: str, + commit_prefix: str, + scope: str, + task_name: str, + task_data: dict, + task_json: Path, + dry_run: bool, +) -> tuple[dict[str, str], list[str], bool]: + """Process submodule changes: commit, push, create PRs. + + Returns: + Tuple of (submodule_prs dict, changed_submodule_paths list, success bool). + On failure, submodule_prs contains URLs persisted so far. + """ + submodule_packages = get_submodule_packages(repo_root) + if not submodule_packages: + return {}, [], True + + # Load existing submodule_prs for incremental merge + raw_prs = task_data.get("submodule_prs") + submodule_prs: dict[str, str] = dict(raw_prs) if isinstance(raw_prs, dict) else {} + + # Detect which submodules have changes + changed: list[tuple[str, str]] = [] # (name, path) + for pkg_name, pkg_path in submodule_packages.items(): + sub_abs = repo_root / pkg_path + if not sub_abs.is_dir(): + continue + + ret, status_out, _ = run_git( + ["status", "--porcelain"], cwd=sub_abs + ) + if ret != 0: + continue + if status_out.strip(): + changed.append((pkg_name, pkg_path)) + + if not changed: + return submodule_prs, [], True + + # Determine submodule branch name: <repo-dir-name>/<main-branch> + repo_dir_name = repo_root.name + sub_branch = f"{repo_dir_name}/{current_branch}" + + print(f"\n{Colors.BLUE}=== Submodule Changes Detected ==={Colors.NC}") + for pkg_name, pkg_path in changed: + print(f" - {pkg_name} ({pkg_path})") + print() + + changed_paths: list[str] = [] + + for pkg_name, pkg_path in changed: + sub_abs = repo_root / pkg_path + sub_base = _get_submodule_default_branch(sub_abs) + sub_commit_msg = f"{commit_prefix}({scope}): {task_name}" + + print(f"{Colors.YELLOW}Processing submodule: {pkg_name} ({pkg_path}){Colors.NC}") + print(f" Submodule base branch: {sub_base}") + print(f" Submodule branch: {sub_branch}") + + if dry_run: + print(f" [DRY-RUN] Would checkout branch: {sub_branch}") + print(f" [DRY-RUN] Would commit: {sub_commit_msg}") + print(f" [DRY-RUN] Would push to: origin/{sub_branch}") + print(f" [DRY-RUN] Would create PR: {sub_branch} -> {sub_base}") + submodule_prs[pkg_name] = "https://github.com/example/repo/pull/DRY-RUN" + changed_paths.append(pkg_path) + continue + + # --- Checkout or create branch in submodule --- + ret, _, _ = run_git( + ["show-ref", "--verify", "--quiet", f"refs/heads/{sub_branch}"], + cwd=sub_abs, + ) + if ret == 0: + # Branch exists, checkout + ret, _, err = run_git( + ["checkout", sub_branch], cwd=sub_abs + ) + if ret != 0: + print(f"{Colors.RED}Failed to checkout branch in {pkg_name}: {err}{Colors.NC}") + return submodule_prs, changed_paths, False + + # Check for divergence (reuse risk) + ret_anc, _, _ = run_git( + ["merge-base", "--is-ancestor", sub_base, sub_branch], + cwd=sub_abs, + ) + if ret_anc != 0: + print( + f" {Colors.YELLOW}[WARN] submodule branch has diverged history, " + f"consider recreating{Colors.NC}" + ) + else: + # Create new branch + ret, _, err = run_git( + ["checkout", "-b", sub_branch], cwd=sub_abs + ) + if ret != 0: + print(f"{Colors.RED}Failed to create branch in {pkg_name}: {err}{Colors.NC}") + return submodule_prs, changed_paths, False + + # --- Stage and commit --- + run_git(["add", "-A"], cwd=sub_abs) + + ret, _, _ = run_git(["diff", "--cached", "--quiet"], cwd=sub_abs) + if ret != 0: + # Has staged changes + ret, _, err = run_git( + ["commit", "-m", sub_commit_msg], cwd=sub_abs + ) + if ret != 0: + print(f"{Colors.RED}Failed to commit in {pkg_name}: {err}{Colors.NC}") + return submodule_prs, changed_paths, False + print(f" {Colors.GREEN}Committed in {pkg_name}{Colors.NC}") + else: + print(f" No staged changes in {pkg_name}, skipping commit") + + # --- Push --- + ret, _, err = run_git( + ["push", "-u", "origin", sub_branch], cwd=sub_abs + ) + if ret != 0: + print(f"{Colors.RED}Failed to push {pkg_name}: {err}{Colors.NC}") + return submodule_prs, changed_paths, False + print(f" {Colors.GREEN}Pushed {pkg_name} to origin/{sub_branch}{Colors.NC}") + + # --- Create or reuse PR --- + result = subprocess.run( + [ + "gh", "pr", "list", + "--head", sub_branch, + "--base", sub_base, + "--json", "url", + "--jq", ".[0].url", + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + cwd=str(sub_abs), + ) + existing_sub_pr = result.stdout.strip() + + if existing_sub_pr: + print(f" {Colors.YELLOW}PR already exists: {existing_sub_pr}{Colors.NC}") + sub_pr_url = existing_sub_pr + else: + result = subprocess.run( + [ + "gh", "pr", "create", + "--draft", + "--base", sub_base, + "--title", f"{commit_prefix}({scope}): {task_name} [{pkg_name}]", + "--body", f"Submodule changes for {task_name}", + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + cwd=str(sub_abs), + ) + if result.returncode != 0: + print( + f"{Colors.RED}Failed to create PR for {pkg_name}: " + f"{result.stderr}{Colors.NC}" + ) + return submodule_prs, changed_paths, False + + sub_pr_url = result.stdout.strip() + print(f" {Colors.GREEN}PR created for {pkg_name}: {sub_pr_url}{Colors.NC}") + + # Persist immediately (incremental, supports re-entry) + submodule_prs[pkg_name] = sub_pr_url + task_data["submodule_prs"] = submodule_prs + write_json(task_json, task_data) + + changed_paths.append(pkg_path) + + return submodule_prs, changed_paths, True + + +def _build_submodule_warning(submodule_prs: dict[str, str]) -> str: + """Build the squash-merge warning block for the main PR body.""" + pr_lines = "\n".join(f"> - {name}: {url}" for name, url in submodule_prs.items()) + return ( + f"> {_SUBMODULE_SQUASH_WARNING_MARKER}\n" + f">\n" + f"> Submodule PRs:\n" + f"{pr_lines}\n" + f"\n---\n\n" + ) + + +def _ensure_submodule_warning_on_existing_pr( + submodule_prs: dict[str, str], + dry_run: bool, +) -> None: + """Read-modify-write: add squash warning to existing PR if missing.""" + if dry_run: + print("[DRY-RUN] Would check/add submodule warning to existing PR") + return + + # Read current PR body + result = subprocess.run( + [ + "gh", "pr", "view", + "--json", "body", + "--jq", ".body", + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if result.returncode != 0: + return + + current_body = result.stdout.strip() + if _SUBMODULE_SQUASH_WARNING_MARKER in current_body: + return # Warning already present + + # Prepend warning to existing body + warning = _build_submodule_warning(submodule_prs) + new_body = warning + current_body + + subprocess.run( + ["gh", "pr", "edit", "--body", new_body], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + print(f" {Colors.GREEN}Added submodule merge warning to existing PR{Colors.NC}") + + +# ============================================================================= +# 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(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(["branch", "--show-current"]) + current_branch = branch_out.strip() + print(f"Current branch: {current_branch}") + + # ============================================================================= + # Submodule PR Flow (runs BEFORE main repo staging) + # ============================================================================= + submodule_prs, changed_submodule_paths, sub_success = _process_submodule_changes( + repo_root=repo_root, + current_branch=current_branch, + commit_prefix=commit_prefix, + scope=scope, + task_name=task_name, + task_data=task_data, + task_json=task_json, + dry_run=args.dry_run, + ) + + if not sub_success: + print( + f"\n{Colors.RED}Submodule PR flow failed. " + f"Skipping main repo commit/PR.{Colors.NC}" + ) + print("Already-created submodule PRs have been saved to task.json.") + return 1 + + # ============================================================================= + # Main Repo: Stage, Commit, Push, PR + # ============================================================================= + + # Check for changes + print(f"{Colors.YELLOW}Checking for changes...{Colors.NC}") + + # Stage changes + run_git(["add", "-A"]) + + # Exclude workspace and temp files + run_git(["reset", f"{DIR_WORKFLOW}/workspace/"]) + run_git(["reset", ".agent-log", ".session-id"]) + + # If submodules changed, ensure their ref updates are staged + for sub_path in changed_submodule_paths: + run_git(["add", sub_path]) + + # Check if there are staged changes + ret, _, _ = run_git(["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( + ["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(["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(["diff", "--cached", "--name-only"]) + for line in staged_out.splitlines(): + print(f" - {line}") + else: + run_git(["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(["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 = "" + + # Build PR body with optional submodule warning + has_submodule_prs = bool(submodule_prs) + + 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)") + if has_submodule_prs: + print(" Body includes submodule merge warning") + 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 + + # Read-modify-write: add submodule warning if missing + if has_submodule_prs: + _ensure_submodule_warning_on_existing_pr( + submodule_prs, args.dry_run + ) + 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") + + # Prepend submodule warning if applicable + if has_submodule_prs: + pr_body = _build_submodule_warning(submodule_prs) + pr_body + + # 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}") + if has_submodule_prs: + print(f" submodule_prs: {submodule_prs}") + 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 + if has_submodule_prs: + task_data["submodule_prs"] = submodule_prs + + write_json(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(["reset", "HEAD"]) + + print() + print(f"{Colors.GREEN}=== PR Created Successfully ==={Colors.NC}") + print(f"PR URL: {pr_url}") + if has_submodule_prs: + print("Submodule PRs:") + for name, url in submodule_prs.items(): + print(f" - {name}: {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..7a76ba8 --- /dev/null +++ b/.trellis/scripts/multi_agent/plan.py @@ -0,0 +1,213 @@ +#!/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 + +import _bootstrap # noqa: F401 — adds parent scripts/ dir to sys.path + +from common.cli_adapter import get_cli_adapter +from common.log import Colors, log_info, log_success, log_error +from common.paths import get_repo_root +from common.developer import ensure_developer + + +# ============================================================================= +# 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", "codex", "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) + if adapter.requires_agent_definition_file: + plan_md = adapter.get_agent_path("plan", project_root) + if not plan_md.is_file(): + log_error(f"Agent definition 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..83985cc --- /dev/null +++ b/.trellis/scripts/multi_agent/start.py @@ -0,0 +1,539 @@ +#!/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 os +import shutil +import subprocess +import sys +import uuid +from pathlib import Path + +import _bootstrap # noqa: F401 — adds parent scripts/ dir to sys.path + +from common.cli_adapter import get_cli_adapter +from common.git import run_git +from common.io import read_json, write_json +from common.log import Colors, log_info, log_success, log_warn, log_error +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.config import ( + get_default_package, + get_packages, + get_submodule_packages, + validate_package, +) +from common.worktree import ( + get_worktree_base_dir, + get_worktree_config, + get_worktree_copy_files, + get_worktree_post_create_hooks, +) + +# Colors, log_info, log_success, log_warn, log_error, read_json, write_json +# are now imported from common.log and common.io above. + + +# ============================================================================= +# Constants +# ============================================================================= + +DEFAULT_PLATFORM = "claude" + + +# ============================================================================= +# Submodule Init +# ============================================================================= + + +def _init_submodules_for_task( + task_data: dict, worktree_path: str, project_root: Path +) -> None: + """Initialize submodules in worktree based on task's target package. + + Resolves the target package from task_data.package -> default_package -> None. + Only initializes submodule-type packages. Idempotent: skips already-initialized + submodules to avoid detaching HEAD on in-progress work. + """ + # Skip if not a monorepo (no packages configured) + if get_packages(project_root) is None: + return + + # Resolve package: task.package -> default_package -> None + task_package = task_data.get("package") + package = None + + if task_package and isinstance(task_package, str): + if validate_package(task_package, project_root): + package = task_package + else: + log_warn( + f"package '{task_package}' not found in config.yaml, " + "skipping submodule init" + ) + return + else: + # Fallback to default_package + default_pkg = get_default_package(project_root) + if default_pkg: + if validate_package(default_pkg, project_root): + package = default_pkg + else: + log_warn( + f"package '{default_pkg}' not found in config.yaml, " + "skipping submodule init" + ) + return + + if not package: + log_warn("no package specified, skipping submodule init") + return + + # Check if this package is a submodule + submodule_packages = get_submodule_packages(project_root) + if package not in submodule_packages: + log_info(f"Package '{package}' is not a submodule, skipping submodule init") + return + + submodule_path = submodule_packages[package] + log_info(f"Checking submodule status for '{package}' ({submodule_path})...") + + # Run git submodule status in worktree directory + ret, status_out, status_err = run_git( + ["submodule", "status", submodule_path], cwd=Path(worktree_path) + ) + + if ret != 0: + log_warn( + f"git submodule status failed for '{submodule_path}': {status_err.strip()}, " + "skipping submodule init" + ) + return + + # Parse the prefix character from submodule status output + # Format: "<prefix><sha1> <path> (<describe>)" + # Prefix: '-' (uninitialized), ' ' (normal), '+' (commit mismatch), 'U' (conflict) + status_line = status_out.rstrip("\n\r") + if not status_line: + log_warn(f"Empty submodule status for '{submodule_path}', skipping") + return + + prefix = status_line[0] + + if prefix == "-": + # Uninitialized: run git submodule update --init + log_info(f"Initializing submodule '{submodule_path}'...") + ret, _, err = run_git( + ["submodule", "update", "--init", submodule_path], + cwd=Path(worktree_path), + ) + if ret != 0: + log_warn(f"Failed to initialize submodule '{submodule_path}': {err.strip()}") + else: + log_success(f"Submodule '{submodule_path}' initialized") + elif prefix == " ": + log_info(f"Submodule '{submodule_path}' already initialized, skipping") + elif prefix == "+": + log_warn( + f"submodule {submodule_path} has local changes, skipping update" + ) + elif prefix == "U": + log_warn( + f"submodule {submodule_path} has conflicts, skipping" + ) + else: + log_warn( + f"Unknown submodule status prefix '{prefix}' for '{submodule_path}', skipping" + ) + + +# ============================================================================= +# 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", "codex", "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 + task_dir_path = Path(task_dir_arg) + if task_dir_path.is_absolute(): + task_dir_abs = task_dir_path + else: + task_dir_abs = project_root / task_dir_path + + try: + task_dir_relative = task_dir_abs.relative_to(project_root).as_posix() + except ValueError: + task_dir_relative = str(task_dir_abs) + + 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 + + if adapter.requires_agent_definition_file: + dispatch_md = adapter.get_agent_path("dispatch", project_root) + if not dispatch_md.is_file(): + log_error(f"Agent definition 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(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( + ["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( + ["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"], + cwd=project_root, + ) + if ret == 0: + log_info("Branch exists, checking out...") + ret, _, err = run_git( + ["worktree", "add", worktree_path, branch], cwd=project_root + ) + else: + log_info(f"Creating new branch: {branch}") + ret, _, err = run_git( + ["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(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") + + # ----- Initialize submodules (before hooks, so hooks can use submodule content) ----- + _init_submodules_for_task(task_data, worktree_path, project_root) + + # ----- 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}") + + # ----- Initialize submodules (idempotent, for reused worktrees) ----- + _init_submodules_for_task(task_data, worktree_path, project_root) + + # ============================================================================= + # 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(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..cba7890 --- /dev/null +++ b/.trellis/scripts/multi_agent/status.py @@ -0,0 +1,76 @@ +#!/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 + +Entry shim — delegates to status_display and status_monitor. +""" + +from __future__ import annotations + +import argparse +import sys + +import _bootstrap # noqa: F401 — adds parent scripts/ dir to sys.path + +from common.paths import get_repo_root + +from .status_display import ( + cmd_detail, + cmd_help, + cmd_list, + cmd_registry, + cmd_summary, +) +from .status_monitor import cmd_log, cmd_watch + + +# ============================================================================= +# 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/multi_agent/status_display.py b/.trellis/scripts/multi_agent/status_display.py new file mode 100755 index 0000000..6b15d86 --- /dev/null +++ b/.trellis/scripts/multi_agent/status_display.py @@ -0,0 +1,542 @@ +#!/usr/bin/env python3 +""" +Multi-Agent Pipeline: Status display and formatting. + +Provides: + cmd_help - Show help text + cmd_list - List worktrees and agents + cmd_summary - Summary of all tasks with agent status + cmd_detail - Detailed single-agent status + cmd_registry - Dump agent registry + +Also exports shared utilities used by status_monitor: + is_running, find_agent, get_registry_file, calc_elapsed, count_modified_files +""" + +from __future__ import annotations + +import json +import os +import subprocess +from datetime import datetime +from pathlib import Path + +from common.cli_adapter import get_cli_adapter +from common.io import read_json +from common.log import Colors +from common.developer import ensure_developer +from common.paths import ( + 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.tasks import iter_active_tasks +from common.worktree import get_agents_dir + + +# ============================================================================= +# Shared Utilities +# ============================================================================= + +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(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 + + +# ============================================================================= +# 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(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.""" + # Import lazily to avoid circular import at module level + from .status_monitor import get_last_tool, get_last_message + + 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(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(registry_file) + if registry_file and registry_file.is_file() + else None + ) + + for t in iter_active_tasks(tasks_dir): + name = t.dir_name + status = t.status + assignee = t.assignee or "unassigned" + priority = t.priority + + # 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 = t.directory / "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(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(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(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_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(registry_file) + if data: + print(json.dumps(data, indent=2)) + else: + print("(registry not found)") + + return 0 diff --git a/.trellis/scripts/multi_agent/status_monitor.py b/.trellis/scripts/multi_agent/status_monitor.py new file mode 100755 index 0000000..d92087f --- /dev/null +++ b/.trellis/scripts/multi_agent/status_monitor.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +""" +Multi-Agent Pipeline: Process monitoring and log parsing. + +Provides: + tail_follow - Follow a file like 'tail -f' + get_last_tool - Get last tool call from agent log + get_last_message - Get last assistant text from agent log + cmd_watch - Watch agent log in real-time + cmd_log - Show recent log entries +""" + +from __future__ import annotations + +import json +import time +from pathlib import Path + +from common.log import Colors + +from .status_display import find_agent + + +# ============================================================================= +# Log Parsing +# ============================================================================= + +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_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 diff --git a/.trellis/scripts/task.py b/.trellis/scripts/task.py new file mode 100755 index 0000000..e207b61 --- /dev/null +++ b/.trellis/scripts/task.py @@ -0,0 +1,445 @@ +#!/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>] [--package <pkg>] + python3 task.py init-context <dir> <type> [--package <pkg>] # 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 argparse +import sys +from pathlib import Path + +from common.log import Colors, colored +from common.paths import ( + DIR_WORKFLOW, + DIR_TASKS, + FILE_TASK_JSON, + get_repo_root, + get_developer, + get_tasks_dir, + get_current_task, + set_current_task, + clear_current_task, +) +from common.task_utils import resolve_task_dir, run_task_hooks +from common.tasks import iter_active_tasks, children_progress + +# Import command handlers from split modules (also re-exports for plan.py compatibility) +from common.task_store import ( + cmd_create, + cmd_archive, + cmd_set_branch, + cmd_set_base_branch, + cmd_set_scope, + cmd_add_subtask, + cmd_remove_subtask, +) +from common.task_context import ( + cmd_init_context, + cmd_add_context, + cmd_validate, + cmd_list_context, +) + + +# ============================================================================= +# 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 = full_path.relative_to(repo_root).as_posix() + 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_task_hooks("after_start", task_json_path, repo_root) + return 0 + else: + print(colored("Error: Failed to set current task", Colors.RED)) + return 1 + + +def cmd_finish(args: argparse.Namespace) -> int: + """Clear 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_task_hooks("after_finish", task_json_path, repo_root) + return 0 + + +# ============================================================================= +# Command: list +# ============================================================================= + +def cmd_list(args: argparse.Namespace) -> int: + """List active tasks.""" + repo_root = get_repo_root() + tasks_dir = get_tasks_dir(repo_root) + current_task = get_current_task(repo_root) + developer = get_developer(repo_root) + filter_mine = args.mine + filter_status = args.status + + if filter_mine: + if not developer: + print(colored("Error: No developer set. Run init_developer.py first", Colors.RED), file=sys.stderr) + return 1 + print(colored(f"My tasks (assignee: {developer}):", Colors.BLUE)) + else: + print(colored("All active tasks:", Colors.BLUE)) + print() + + # Single pass: collect all tasks via shared iterator + all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)} + all_statuses = {name: t.status for name, t in all_tasks.items()} + + # Display tasks hierarchically + count = 0 + + def _print_task(dir_name: str, indent: int = 0) -> None: + nonlocal count + t = all_tasks[dir_name] + + # Apply --mine filter + if filter_mine and (t.assignee or "-") != developer: + return + + # Apply --status filter + if filter_status and t.status != filter_status: + return + + relative_path = f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}" + marker = "" + if relative_path == current_task: + marker = f" {colored('<- current', Colors.GREEN)}" + + # Children progress + progress = children_progress(t.children, all_statuses) + + # Package tag + pkg_tag = f" @{t.package}" if t.package else "" + + prefix = " " * indent + " - " + + if filter_mine: + print(f"{prefix}{dir_name}/ ({t.status}){pkg_tag}{progress}{marker}") + else: + print(f"{prefix}{dir_name}/ ({t.status}){pkg_tag}{progress} [{colored(t.assignee or '-', Colors.CYAN)}]{marker}") + count += 1 + + # Print children indented + for child_name in t.children: + if child_name in all_tasks: + _print_task(child_name, indent + 1) + + # Display only top-level tasks (those without a parent) + for dir_name in sorted(all_tasks.keys()): + if not all_tasks[dir_name].parent: + _print_task(dir_name) + + if count == 0: + if filter_mine: + print(" (no tasks assigned to you)") + else: + print(" (no active tasks)") + + print() + print(f"Total: {count} task(s)") + return 0 + + +# ============================================================================= +# Command: list-archive +# ============================================================================= + +def cmd_list_archive(args: argparse.Namespace) -> int: + """List archived tasks.""" + repo_root = get_repo_root() + tasks_dir = get_tasks_dir(repo_root) + archive_dir = tasks_dir / "archive" + month = args.month + + print(colored("Archived tasks:", Colors.BLUE)) + print() + + if month: + month_dir = archive_dir / month + if month_dir.is_dir(): + print(f"[{month}]") + for d in sorted(month_dir.iterdir()): + if d.is_dir(): + print(f" - {d.name}/") + else: + print(f" No archives for {month}") + else: + if archive_dir.is_dir(): + for month_dir in sorted(archive_dir.iterdir()): + if month_dir.is_dir(): + month_name = month_dir.name + count = sum(1 for d in month_dir.iterdir() if d.is_dir()) + print(f"[{month_name}] - {count} task(s)") + + return 0 + + +# ============================================================================= +# 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> --package <pkg> Create task for a specific package + 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 init-context <dir> <type> --package <pkg> With explicit package + 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 + +Monorepo options: + --package <pkg> Package name (validated against config.yaml packages) + +List options: + --mine, -m Show only tasks assigned to current developer + --status, -s <s> Filter by status (planning, in_progress, review, completed) + +Examples: + python3 task.py create "Add login feature" --slug add-login + python3 task.py create "Add login feature" --slug add-login --package cli + python3 task.py create "Child task" --slug child --parent .trellis/tasks/01-21-parent + python3 task.py init-context .trellis/tasks/01-21-add-login backend + python3 task.py init-context .trellis/tasks/01-21-add-login backend --package cli + python3 task.py add-context <dir> implement .trellis/spec/cli/backend/auth.md "Auth guidelines" + python3 task.py set-branch <dir> task/add-login + python3 task.py start .trellis/tasks/01-21-add-login + python3 task.py 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)") + p_create.add_argument("--package", help="Package name for monorepo projects") + + # 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") + p_init.add_argument("--package", help="Package name for monorepo projects") + + # add-context + p_add = subparsers.add_parser("add-context", help="Add context entry") + p_add.add_argument("dir", help="Task directory") + p_add.add_argument("file", help="JSONL file (implement|check|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..b61aa78 --- /dev/null +++ b/.trellis/spec/backend/database-guidelines.md @@ -0,0 +1,51 @@ +# Database Guidelines + +> Database patterns and conventions for this project. + +--- + +## Overview + +<!-- +Document your project's database conventions here. + +Questions to answer: +- What ORM/query library do you use? +- How are migrations managed? +- What are the naming conventions for tables/columns? +- How do you handle transactions? +--> + +(To be filled by the team) + +--- + +## Query Patterns + +<!-- How should queries be written? Batch operations? --> + +(To be filled by the team) + +--- + +## Migrations + +<!-- How to create and run migrations --> + +(To be filled by the team) + +--- + +## Naming Conventions + +<!-- Table names, column names, index names --> + +(To be filled by the team) + +--- + +## Common Mistakes + +<!-- Database-related mistakes your team has made --> + +(To be filled by the team) diff --git a/.trellis/spec/backend/directory-structure.md b/.trellis/spec/backend/directory-structure.md new file mode 100644 index 0000000..9bb253d --- /dev/null +++ b/.trellis/spec/backend/directory-structure.md @@ -0,0 +1,54 @@ +# Directory Structure + +> How backend code is organized in this project. + +--- + +## Overview + +<!-- +Document your project's backend directory structure here. + +Questions to answer: +- How are modules/packages organized? +- Where does business logic live? +- Where are API endpoints defined? +- How are utilities and helpers organized? +--> + +(To be filled by the team) + +--- + +## Directory Layout + +``` +<!-- Replace with your actual structure --> +src/ +├── ... +└── ... +``` + +--- + +## Module Organization + +<!-- How should new features/modules be organized? --> + +(To be filled by the team) + +--- + +## Naming Conventions + +<!-- File and folder naming rules --> + +(To be filled by the team) + +--- + +## Examples + +<!-- Link to well-organized modules as examples --> + +(To be filled by the team) diff --git a/.trellis/spec/backend/error-handling.md b/.trellis/spec/backend/error-handling.md new file mode 100644 index 0000000..bcd5533 --- /dev/null +++ b/.trellis/spec/backend/error-handling.md @@ -0,0 +1,51 @@ +# Error Handling + +> How errors are handled in this project. + +--- + +## Overview + +<!-- +Document your project's error handling conventions here. + +Questions to answer: +- What error types do you define? +- How are errors propagated? +- How are errors logged? +- How are errors returned to clients? +--> + +(To be filled by the team) + +--- + +## Error Types + +<!-- Custom error classes/types --> + +(To be filled by the team) + +--- + +## Error Handling Patterns + +<!-- Try-catch patterns, error propagation --> + +(To be filled by the team) + +--- + +## API Error Responses + +<!-- Standard error response format --> + +(To be filled by the team) + +--- + +## Common Mistakes + +<!-- Error handling mistakes your team has made --> + +(To be filled by the team) diff --git a/.trellis/spec/backend/index.md b/.trellis/spec/backend/index.md new file mode 100644 index 0000000..1c0b4c4 --- /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 | To fill | +| [Database Guidelines](./database-guidelines.md) | ORM patterns, queries, migrations | To fill | +| [Error Handling](./error-handling.md) | Error types, handling strategies | To fill | +| [Quality Guidelines](./quality-guidelines.md) | Code standards, forbidden patterns | To fill | +| [Logging Guidelines](./logging-guidelines.md) | Structured logging, log levels | To fill | + +--- + +## How to Fill These Guidelines + +For each guideline file: + +1. Document your project's **actual conventions** (not ideals) +2. Include **code examples** from your codebase +3. List **forbidden patterns** and why +4. Add **common mistakes** your team has made + +The goal is to help AI assistants and new team members understand how YOUR project works. + +--- + +**Language**: All documentation should be written in **English**. diff --git a/.trellis/spec/backend/logging-guidelines.md b/.trellis/spec/backend/logging-guidelines.md new file mode 100644 index 0000000..bb930df --- /dev/null +++ b/.trellis/spec/backend/logging-guidelines.md @@ -0,0 +1,51 @@ +# Logging Guidelines + +> How logging is done in this project. + +--- + +## Overview + +<!-- +Document your project's logging conventions here. + +Questions to answer: +- What logging library do you use? +- What are the log levels and when to use each? +- What should be logged? +- What should NOT be logged (PII, secrets)? +--> + +(To be filled by the team) + +--- + +## Log Levels + +<!-- When to use each level: debug, info, warn, error --> + +(To be filled by the team) + +--- + +## Structured Logging + +<!-- Log format, required fields --> + +(To be filled by the team) + +--- + +## What to Log + +<!-- Important events to log --> + +(To be filled by the team) + +--- + +## What NOT to Log + +<!-- Sensitive data, PII, secrets --> + +(To be filled by the team) diff --git a/.trellis/spec/backend/quality-guidelines.md b/.trellis/spec/backend/quality-guidelines.md new file mode 100644 index 0000000..c1e1065 --- /dev/null +++ b/.trellis/spec/backend/quality-guidelines.md @@ -0,0 +1,51 @@ +# Quality Guidelines + +> Code quality standards for backend development. + +--- + +## Overview + +<!-- +Document your project's quality standards here. + +Questions to answer: +- What patterns are forbidden? +- What linting rules do you enforce? +- What are your testing requirements? +- What code review standards apply? +--> + +(To be filled by the team) + +--- + +## Forbidden Patterns + +<!-- Patterns that should never be used and why --> + +(To be filled by the team) + +--- + +## Required Patterns + +<!-- Patterns that must always be used --> + +(To be filled by the team) + +--- + +## Testing Requirements + +<!-- What level of testing is expected --> + +(To be filled by the team) + +--- + +## Code Review Checklist + +<!-- What reviewers should check --> + +(To be filled by the team) diff --git a/.trellis/spec/frontend/component-guidelines.md b/.trellis/spec/frontend/component-guidelines.md new file mode 100644 index 0000000..6836c3f --- /dev/null +++ b/.trellis/spec/frontend/component-guidelines.md @@ -0,0 +1,59 @@ +# Component Guidelines + +> How components are built in this project. + +--- + +## Overview + +<!-- +Document your project's component conventions here. + +Questions to answer: +- What component patterns do you use? +- How are props defined? +- How do you handle composition? +- What accessibility standards apply? +--> + +(To be filled by the team) + +--- + +## Component Structure + +<!-- Standard structure of a component file --> + +(To be filled by the team) + +--- + +## Props Conventions + +<!-- How props should be defined and typed --> + +(To be filled by the team) + +--- + +## Styling Patterns + +<!-- How styles are applied (CSS modules, styled-components, Tailwind, etc.) --> + +(To be filled by the team) + +--- + +## Accessibility + +<!-- A11y requirements and patterns --> + +(To be filled by the team) + +--- + +## Common Mistakes + +<!-- Component-related mistakes your team has made --> + +(To be filled by the team) diff --git a/.trellis/spec/frontend/directory-structure.md b/.trellis/spec/frontend/directory-structure.md new file mode 100644 index 0000000..1eb57d1 --- /dev/null +++ b/.trellis/spec/frontend/directory-structure.md @@ -0,0 +1,54 @@ +# Directory Structure + +> How frontend code is organized in this project. + +--- + +## Overview + +<!-- +Document your project's frontend directory structure here. + +Questions to answer: +- Where do components live? +- How are features/modules organized? +- Where are shared utilities? +- How are assets organized? +--> + +(To be filled by the team) + +--- + +## Directory Layout + +``` +<!-- Replace with your actual structure --> +src/ +├── ... +└── ... +``` + +--- + +## Module Organization + +<!-- How should new features be organized? --> + +(To be filled by the team) + +--- + +## Naming Conventions + +<!-- File and folder naming rules --> + +(To be filled by the team) + +--- + +## Examples + +<!-- Link to well-organized modules as examples --> + +(To be filled by the team) diff --git a/.trellis/spec/frontend/hook-guidelines.md b/.trellis/spec/frontend/hook-guidelines.md new file mode 100644 index 0000000..60c6bb6 --- /dev/null +++ b/.trellis/spec/frontend/hook-guidelines.md @@ -0,0 +1,51 @@ +# Hook Guidelines + +> How hooks are used in this project. + +--- + +## Overview + +<!-- +Document your project's hook conventions here. + +Questions to answer: +- What custom hooks do you have? +- How do you handle data fetching? +- What are the naming conventions? +- How do you share stateful logic? +--> + +(To be filled by the team) + +--- + +## Custom Hook Patterns + +<!-- How to create and structure custom hooks --> + +(To be filled by the team) + +--- + +## Data Fetching + +<!-- How data fetching is handled (React Query, SWR, etc.) --> + +(To be filled by the team) + +--- + +## Naming Conventions + +<!-- Hook naming rules (use*, etc.) --> + +(To be filled by the team) + +--- + +## Common Mistakes + +<!-- Hook-related mistakes your team has made --> + +(To be filled by the team) diff --git a/.trellis/spec/frontend/index.md b/.trellis/spec/frontend/index.md new file mode 100644 index 0000000..a5e51b8 --- /dev/null +++ b/.trellis/spec/frontend/index.md @@ -0,0 +1,39 @@ +# Frontend Development Guidelines + +> Best practices for frontend development in this project. + +--- + +## Overview + +This directory contains guidelines for frontend development. Fill in each file with your project's specific conventions. + +--- + +## Guidelines Index + +| Guide | Description | Status | +|-------|-------------|--------| +| [Directory Structure](./directory-structure.md) | Module organization and file layout | To fill | +| [Component Guidelines](./component-guidelines.md) | Component patterns, props, composition | To fill | +| [Hook Guidelines](./hook-guidelines.md) | Custom hooks, data fetching patterns | To fill | +| [State Management](./state-management.md) | Local state, global state, server state | To fill | +| [Quality Guidelines](./quality-guidelines.md) | Code standards, forbidden patterns | To fill | +| [Type Safety](./type-safety.md) | Type patterns, validation | To fill | + +--- + +## How to Fill These Guidelines + +For each guideline file: + +1. Document your project's **actual conventions** (not ideals) +2. Include **code examples** from your codebase +3. List **forbidden patterns** and why +4. Add **common mistakes** your team has made + +The goal is to help AI assistants and new team members understand how YOUR project works. + +--- + +**Language**: All documentation should be written in **English**. diff --git a/.trellis/spec/frontend/quality-guidelines.md b/.trellis/spec/frontend/quality-guidelines.md new file mode 100644 index 0000000..05a1411 --- /dev/null +++ b/.trellis/spec/frontend/quality-guidelines.md @@ -0,0 +1,51 @@ +# Quality Guidelines + +> Code quality standards for frontend development. + +--- + +## Overview + +<!-- +Document your project's quality standards here. + +Questions to answer: +- What patterns are forbidden? +- What linting rules do you enforce? +- What are your testing requirements? +- What code review standards apply? +--> + +(To be filled by the team) + +--- + +## Forbidden Patterns + +<!-- Patterns that should never be used and why --> + +(To be filled by the team) + +--- + +## Required Patterns + +<!-- Patterns that must always be used --> + +(To be filled by the team) + +--- + +## Testing Requirements + +<!-- What level of testing is expected --> + +(To be filled by the team) + +--- + +## Code Review Checklist + +<!-- What reviewers should check --> + +(To be filled by the team) diff --git a/.trellis/spec/frontend/state-management.md b/.trellis/spec/frontend/state-management.md new file mode 100644 index 0000000..b4fc966 --- /dev/null +++ b/.trellis/spec/frontend/state-management.md @@ -0,0 +1,51 @@ +# State Management + +> How state is managed in this project. + +--- + +## Overview + +<!-- +Document your project's state management conventions here. + +Questions to answer: +- What state management solution do you use? +- How is local vs global state decided? +- How do you handle server state? +- What are the patterns for derived state? +--> + +(To be filled by the team) + +--- + +## State Categories + +<!-- Local state, global state, server state, URL state --> + +(To be filled by the team) + +--- + +## When to Use Global State + +<!-- Criteria for promoting state to global --> + +(To be filled by the team) + +--- + +## Server State + +<!-- How server data is cached and synchronized --> + +(To be filled by the team) + +--- + +## Common Mistakes + +<!-- State management mistakes your team has made --> + +(To be filled by the team) diff --git a/.trellis/spec/frontend/type-safety.md b/.trellis/spec/frontend/type-safety.md new file mode 100644 index 0000000..1b1b19e --- /dev/null +++ b/.trellis/spec/frontend/type-safety.md @@ -0,0 +1,51 @@ +# Type Safety + +> Type safety patterns in this project. + +--- + +## Overview + +<!-- +Document your project's type safety conventions here. + +Questions to answer: +- What type system do you use? +- How are types organized? +- What validation library do you use? +- How do you handle type inference? +--> + +(To be filled by the team) + +--- + +## Type Organization + +<!-- Where types are defined, shared types vs local types --> + +(To be filled by the team) + +--- + +## Validation + +<!-- Runtime validation patterns (Zod, Yup, io-ts, etc.) --> + +(To be filled by the team) + +--- + +## Common Patterns + +<!-- Type utilities, generics, type guards --> + +(To be filled by the team) + +--- + +## Forbidden Patterns + +<!-- any, type assertions, etc. --> + +(To be filled by the team) 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/tasks/00-bootstrap-guidelines/prd.md b/.trellis/tasks/00-bootstrap-guidelines/prd.md new file mode 100644 index 0000000..f1dec07 --- /dev/null +++ b/.trellis/tasks/00-bootstrap-guidelines/prd.md @@ -0,0 +1,113 @@ +# 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. +**Starting from scratch = 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**. + + +### Backend Guidelines + +| File | What to Document | +|------|------------------| +| `.trellis/spec/backend/directory-structure.md` | Where different file types go (routes, services, utils) | +| `.trellis/spec/backend/database-guidelines.md` | ORM, migrations, query patterns, naming conventions | +| `.trellis/spec/backend/error-handling.md` | How errors are caught, logged, and returned | +| `.trellis/spec/backend/logging-guidelines.md` | Log levels, format, what to log | +| `.trellis/spec/backend/quality-guidelines.md` | Code review standards, testing requirements | + + +### Frontend Guidelines + +| File | What to Document | +|------|------------------| +| `.trellis/spec/frontend/directory-structure.md` | Component/page/hook organization | +| `.trellis/spec/frontend/component-guidelines.md` | Component patterns, props conventions | +| `.trellis/spec/frontend/hook-guidelines.md` | Custom hook naming, patterns | +| `.trellis/spec/frontend/state-management.md` | State library, patterns, what goes where | +| `.trellis/spec/frontend/type-safety.md` | TypeScript conventions, type organization | +| `.trellis/spec/frontend/quality-guidelines.md` | Linting, testing, accessibility | + + +### Thinking Guides (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 + +### Step 0: Import from Existing Specs (Recommended) + +Many projects already have coding conventions documented. **Check these first** before writing from scratch: + +| File / Directory | Tool | +|------|------| +| `CLAUDE.md` / `CLAUDE.local.md` | Claude Code | +| `AGENTS.md` | Codex / Claude Code / agent-compatible tools | +| `.cursorrules` | Cursor | +| `.cursor/rules/*.mdc` | Cursor (rules directory) | +| `.windsurfrules` | Windsurf | +| `.clinerules` | Cline | +| `.roomodes` | Roo Code | +| `.github/copilot-instructions.md` | GitHub Copilot | +| `.vscode/settings.json` → `github.copilot.chat.codeGeneration.instructions` | VS Code Copilot | +| `CONVENTIONS.md` / `.aider.conf.yml` | aider | +| `CONTRIBUTING.md` | General project conventions | +| `.editorconfig` | Editor formatting rules | + +If any of these exist, read them first and extract the relevant coding conventions into the corresponding `.trellis/spec/` files. This saves significant effort compared to writing everything from scratch. + +### Step 1: Analyze the Codebase + +Ask AI to help discover patterns from actual code: + +- "Read all existing config files (CLAUDE.md, .cursorrules, etc.) and extract coding conventions into .trellis/spec/" +- "Analyze my codebase and document the patterns you see" +- "Find error handling / component / API patterns and document them" + +### Step 2: 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. + +- **Look at existing code** - Find 2-3 examples of each pattern +- **Include file paths** - Reference real files as examples +- **List anti-patterns** - What does your team avoid? + +--- + +## 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 diff --git a/.trellis/tasks/00-bootstrap-guidelines/task.json b/.trellis/tasks/00-bootstrap-guidelines/task.json new file mode 100644 index 0000000..cfc381d --- /dev/null +++ b/.trellis/tasks/00-bootstrap-guidelines/task.json @@ -0,0 +1,35 @@ +{ + "id": "00-bootstrap-guidelines", + "name": "Bootstrap Guidelines", + "description": "Fill in project development guidelines for AI agents", + "status": "in_progress", + "dev_type": "docs", + "priority": "P1", + "creator": "Apale", + "assignee": "Apale", + "createdAt": "2026-04-16", + "completedAt": null, + "commit": null, + "subtasks": [ + { + "name": "Fill backend guidelines", + "status": "pending" + }, + { + "name": "Fill frontend guidelines", + "status": "pending" + }, + { + "name": "Add code examples", + "status": "pending" + } + ], + "children": [], + "parent": null, + "relatedFiles": [ + ".trellis/spec/backend/", + ".trellis/spec/frontend/" + ], + "notes": "First-time setup task created by trellis init (fullstack project)", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/workflow.md b/.trellis/workflow.md new file mode 100644 index 0000000..28239e8 --- /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 +# Discover available packages and spec layers +python3 ./.trellis/scripts/get_context.py --mode packages + +# Read the spec index for each relevant module +cat .trellis/spec/<package>/<layer>/index.md + +# Always read shared guides +cat .trellis/spec/guides/index.md +``` + +**Why this matters?** +- Understand which spec layers apply to your task +- Know coding standards for the packages you'll modify +- Learn the overall code quality requirements + +### Step 3: Before Coding - Read Specific Guidelines (Required) + +Based on your task, read the **detailed** guideline files listed in each spec index's **Pre-Development Checklist**: + +```bash +# The index points to specific files — read those, not just the index +cat .trellis/spec/<package>/<layer>/error-handling.md +cat .trellis/spec/<package>/<layer>/conventions.md +# etc. — based on what the Pre-Development Checklist lists +``` + +--- + +## 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: + +```bash +# Discover available packages and spec layers +python3 ./.trellis/scripts/get_context.py --mode packages + +# Read spec indexes for relevant modules +cat .trellis/spec/<package>/<layer>/index.md + +# For cross-layer features +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. Start task (mark as current) + --> python3 ./.trellis/scripts/task.py start <name> + --> Writes .trellis/.current-task; future sessions see it in <current-state> + +3. Write code according to guidelines + --> Read .trellis/spec/ docs relevant to your task + --> For cross-layer: read .trellis/spec/guides/ + +4. Self-test + --> Run project's lint/test commands (see spec docs) + --> Manual feature testing + +5. Commit code + --> git add <files> + --> git commit -m "type(scope): description" + Format: feat/fix/docs/refactor/test/chore + +6. Record session (one command) + --> python3 ./.trellis/scripts/add_session.py --title "Title" --commit "hash" + +7. Finish task (clear current) + --> python3 ./.trellis/scripts/task.py finish + --> Only when the task is fully done; otherwise leave it set so the + next session resumes where you left off +``` + +### 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/<package>/<layer>/quality-guidelines.md` for package-specific checks + +--- + +## 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 start <name> # Set as current task (writes .current-task, triggers after_start hooks) +python3 ./.trellis/scripts/task.py finish # Clear current task (triggers after_finish hooks) +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 +``` + +**Current task mechanism**: `task.py start <name>` writes the selected task path to `.trellis/.current-task`. The SessionStart hook reads this file to inject `## CURRENT TASK` into every new session's context, so the AI immediately knows what you're working on without being told. Run `task.py finish` when you're done — subsequent sessions will show `(none)` until you start another task. + +--- + +## 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/Apale/index.md b/.trellis/workspace/Apale/index.md new file mode 100644 index 0000000..ccfeaf6 --- /dev/null +++ b/.trellis/workspace/Apale/index.md @@ -0,0 +1,40 @@ +# Workspace Index - Apale + +> Journal tracking for AI development sessions. + +--- + +## Current Status + +<!-- @@@auto:current-status --> +- **Active File**: `journal-1.md` +- **Total Sessions**: 0 +- **Last Active**: - +<!-- @@@/auto:current-status --> + +--- + +## Active Documents + +<!-- @@@auto:active-documents --> +| File | Lines | Status | +|------|-------|--------| +| `journal-1.md` | ~0 | Active | +<!-- @@@/auto:active-documents --> + +--- + +## Session History + +<!-- @@@auto:session-history --> +| # | Date | Title | Commits | Branch | +|---|------|-------|---------|--------| +<!-- @@@/auto:session-history --> + +--- + +## Notes + +- Sessions are appended to journal files +- New journal file created when current exceeds 2000 lines +- Use `add_session.py` to record sessions diff --git a/.trellis/workspace/Apale/journal-1.md b/.trellis/workspace/Apale/journal-1.md new file mode 100644 index 0000000..d17b3e0 --- /dev/null +++ b/.trellis/workspace/Apale/journal-1.md @@ -0,0 +1,7 @@ +# Journal - Apale (Part 1) + +> AI development session journal +> Started: 2026-04-16 + +--- + diff --git a/.trellis/workspace/index.md b/.trellis/workspace/index.md new file mode 100644 index 0000000..f132a77 --- /dev/null +++ b/.trellis/workspace/index.md @@ -0,0 +1,125 @@ +# Workspace Index + +> Records of all AI Agent work records across all developers + +--- + +## Overview + +This directory tracks records for all developers working with AI Agents on this project. + +### File Structure + +``` +workspace/ +|-- index.md # This file - main index ++-- {developer}/ # Per-developer directory + |-- index.md # Personal index with session history + |-- tasks/ # Task files + | |-- *.json # Active tasks + | +-- archive/ # Archived tasks by month + +-- journal-N.md # Journal files (sequential: 1, 2, 3...) +``` + +--- + +## Active Developers + +| Developer | Last Active | Sessions | Active File | +|-----------|-------------|----------|-------------| +| (none yet) | - | - | - | + +--- + +## Getting Started + +### For New Developers + +Run the initialization script: + +```bash +python3 ./.trellis/scripts/init_developer.py <your-name> +``` + +This will: +1. Create your identity file (gitignored) +2. Create your progress directory +3. Create your personal index +4. Create initial journal file + +### For Returning Developers + +1. Get your developer name: + ```bash + python3 ./.trellis/scripts/get_developer.py + ``` + +2. Read your personal index: + ```bash + cat .trellis/workspace/$(python3 ./.trellis/scripts/get_developer.py)/index.md + ``` + +--- + +## Guidelines + +### Journal File Rules + +- **Max 2000 lines** per journal file +- When limit is reached, create `journal-{N+1}.md` +- Update your personal `index.md` when creating new files + +### Session Record Format + +Each session should include: +- Summary: One-line description +- Branch: Which branch the work was done on +- Main Changes: What was modified +- Git Commits: Commit hashes and messages +- Next Steps: What to do next + +--- + +## Session Template + +Use this template when recording sessions: + +```markdown +## Session {N}: {Title} + +**Date**: YYYY-MM-DD +**Task**: {task-name} +**Branch**: `{branch-name}` + +### Summary + +{One-line summary} + +### Main Changes + +- {Change 1} +- {Change 2} + +### Git Commits + +| Hash | Message | +|------|---------| +| `abc1234` | {commit message} | + +### Testing + +- [OK] {Test result} + +### Status + +[OK] **Completed** / # **In Progress** / [P] **Blocked** + +### Next Steps + +- {Next step 1} +- {Next step 2} +``` + +--- + +**Language**: All documentation must be written in **English**. 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 new file mode 100644 index 0000000..3f95406 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,22 @@ +<!-- 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/`) + +If you're using Codex, project-scoped helpers may also live in: +- `.agents/skills/` for reusable Trellis skills +- `.codex/agents/` for optional custom subagents + +Keep this managed block so 'trellis update' can refresh the instructions. + +<!-- TRELLIS:END -->