init with trellis

This commit is contained in:
apale7 2026-04-16 23:46:01 +08:00
commit 834a240fe3
89 changed files with 16447 additions and 0 deletions

146
.opencode/agents/check.md Normal file
View File

@ -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. `<file>:<line>` - <what was fixed>
2. `<file>:<line>` - <what was fixed>
### 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
```

129
.opencode/agents/debug.md Normal file
View File

@ -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]` `<file>:<line>` - <what was fixed>
2. `[P2]` `<file>:<line>` - <what was fixed>
### Issues Not Fixed
- `<file>:<line>` - <reason why 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

View File

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

View File

@ -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/<package>/<layer>/`
- 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

View File

@ -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>/<layer>/` - 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

View File

@ -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
<category from above>
## Details
<specific explanation of why this requirement cannot proceed>
## Suggestions
- <what the user should clarify or change>
- <how to make the requirement actionable>
## To Retry
1. Delete this directory:
```bash
rm -rf <task_dir>
```
2. Run with revised requirement:
```bash
python3 ./.trellis/scripts/multi_agent/plan.py --name "<name>" --type "<type>" --requirement "<revised requirement>"
```
```
**Step R3: Print summary** - Execute:
```bash
echo "=== PLAN REJECTED ==="
echo ""
echo "Reason: <category>"
echo "Details: <brief explanation>"
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: <relative file path>, reason: <why needed>
- path: <relative file path>, reason: <why needed>
## check.jsonl
- path: <relative file path>, reason: <why needed>
## debug.jsonl
- path: <relative file path>, reason: <why needed>
## Suggested Scope
<single word for commit scope, e.g., auth, api, ui>
## Technical Notes
<any important technical considerations for prd.md>",
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 "<path>" "<reason>"
# For each entry in check.jsonl section:
python3 ./.trellis/scripts/task.py add-context "$PLAN_TASK_DIR" check "<path>" "<reason>"
# For each entry in debug.jsonl section:
python3 ./.trellis/scripts/task.py add-context "$PLAN_TASK_DIR" debug "<path>" "<reason>"
```
### 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" "<scope>"
# 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.)
```

View File

@ -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/<package>/<layer>/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.

View File

@ -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 23 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: <short goal>" --slug <auto>)
```
Create/seed `prd.md` immediately with what you know:
```markdown
# brainstorm: <short goal>
## Goal
<one paragraph: what + why>
## What I already know
* <facts from user message>
* <facts discovered from repo/docs>
## Assumptions (temporary)
* <assumptions to validate>
## Open Questions
* <ONLY Blocking / Preference questions; keep list short>
## Requirements (evolving)
* <start with what is known>
## Acceptance Criteria (evolving)
* [ ] <testable criterion>
## 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)
* <what we will not do in this task>
## Technical Notes
* <files inspected, constraints, links, references>
* <research notes summary if applicable>
```
---
## 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, 12 files, scope well-defined | Ask 1 confirm question, then implement |
| **Moderate** | Multiple files, some ambiguity | Light brainstorm (23 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 24 comparable tools/patterns
2. Summarize common conventions and why they exist
3. Map conventions onto our repo constraints
4. Produce **23 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: <name>** (Recommended)
* How it works:
* Pros:
* Cons:
**Approach B: <name>**
* How it works:
* Pros:
* Cons:
**Approach C: <name>** (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 12 bullets each)
1. **Future evolution**
* What might this feature become in 13 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: <current goal>.
Before diving into design, let me quickly diverge to consider three categories (to avoid rework later):
1. Future evolution: <12 bullets>
2. Related scenarios: <12 bullets>
3. Failure/edge cases: <12 bullets>
For this MVP, which would you like to include (or none)?
1. Current requirement only (minimal viable)
2. Add <X> (reserve for future extension)
3. Add <Y> (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 <topic>, which approach do you prefer?
1. **Option A**<what it means + trade-off>
2. **Option B**<what it means + trade-off>
3. **Option C**<what it means + trade-off>
4. **Other** — describe your preference
```
---
## Step 7: Propose Approaches + Record Decisions (Complex tasks)
After requirements are clear enough, propose 23 approaches (if not already done via research-first):
```markdown
Based on current information, here are 23 feasible approaches:
**Approach A: <name>** (Recommended)
* How:
* Pros:
* Cons:
**Approach B: <name>**
* 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**: <one sentence>
**Requirements**:
* ...
* ...
**Acceptance Criteria**:
* [ ] ...
* [ ] ...
**Definition of Done**:
* ...
**Out of Scope**:
* ...
**Technical Approach**:
<brief summary + key decisions>
**Implementation Plan (small PRs)**:
* PR1: <scaffolding + tests + minimal plumbing>
* PR2: <core behavior>
* PR3: <edge cases + docs + cleanup>
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
# <Task Title>
## Goal
<why + what>
## Requirements
* ...
## Acceptance Criteria
* [ ] ...
## Definition of Done
* ...
## Technical Approach
<key design + decisions>
## Decision (ADR-lite)
Context / Decision / Consequences
## Out of Scope
* ...
## Technical Notes
<constraints, references, files, research 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 17: 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 |

View File

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

View File

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

View File

@ -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/<package>/<layer>/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.

View File

@ -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 <command-name> <description>
```
**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-<command-name>.md`
- `.opencode/commands/trellis/<command-name>.md`
### 5. Confirm Creation
Output result:
```
[OK] Created Slash Command: /<command-name>
File paths:
- .cursor/commands/trellis-<command-name>.md
- .opencode/commands/trellis/<command-name>.md
Usage:
/trellis:<command-name>
Description:
<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]
```

View File

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

View File

@ -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 <skill-name>
```
**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/<skill-name>/`
> - 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 <skill-name>
```
If the skill doesn't exist, prompt user to check available skills:
```bash
# Available skills are listed in AGENTS.md under <available_skills>
```
### 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-<skill-name>
## # <Skill Name> 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/<skill-name>/`
@@@/section:skill-<skill-name>
```
#### 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/
+-- <skill-name>/
|-- README.md # Example documentation
|-- example-1.ts.template # Code example (use .template suffix)
+-- example-2.tsx.template
```
**File naming conventions**:
- Code files: `<name>.<ext>.template` (e.g., `component.tsx.template`)
- Config files: `<name>.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-related task> | <Section name> | `skill-<skill-name>` |
```
### 5. Generate Integration Report
---
## Skill Integration Report: `<skill-name>`
### # 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-<name>`) |
| Code examples | `.trellis/spec/{target}/examples/skills/<name>/` |
| 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 <package>
# or
pnpm add <package>
# or
yarn add <package>
```
### [OK] Completed Changes
- [ ] Added `@@@section:skill-<name>` section to `doc.md`
- [ ] Added index entry to `index.md`
- [ ] Created example files in `examples/skills/<name>/`
- [ ] 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-<skill-name> Use <skill-name> 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
```

View File

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

View File

@ -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 "<feature-name>" \
--type "<backend|frontend|fullstack>" \
--requirement "<user requirement description>" \
--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 "<title>" --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

View File

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

View File

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

View File

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

View File

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

5
.opencode/package.json Normal file
View File

@ -0,0 +1,5 @@
{
"dependencies": {
"@opencode-ai/plugin": "1.1.40"
}
}

View File

@ -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)
}
}
}
}
}

View File

@ -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)
}
}
}
}
}

29
.trellis/.gitignore vendored Normal file
View File

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

View File

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

1
.trellis/.version Normal file
View File

@ -0,0 +1 @@
0.4.0

53
.trellis/config.yaml Normal file
View File

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

5
.trellis/scripts/__init__.py Executable file
View File

@ -0,0 +1,5 @@
"""
Trellis Python Scripts
This module provides Python implementations of Trellis workflow scripts.
"""

521
.trellis/scripts/add_session.py Executable file
View File

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

View File

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

View File

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

264
.trellis/scripts/common/config.py Executable file
View File

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

View File

@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""
Developer management utilities.
Provides:
init_developer - Initialize developer
ensure_developer - Ensure developer is initialized (exit if not)
show_developer_info - Show developer information
"""
from __future__ import annotations
import sys
from datetime import datetime
from pathlib import Path
from .paths import (
DIR_WORKFLOW,
DIR_WORKSPACE,
DIR_TASKS,
FILE_DEVELOPER,
FILE_JOURNAL_PREFIX,
get_repo_root,
get_developer,
check_developer,
)
# =============================================================================
# Developer Initialization
# =============================================================================
def init_developer(name: str, repo_root: Path | None = None) -> bool:
"""Initialize developer.
Creates:
- .trellis/.developer file with developer info
- .trellis/workspace/<name>/ directory structure
- Initial journal file and index.md
Args:
name: Developer name.
repo_root: Repository root path. Defaults to auto-detected.
Returns:
True on success, False on error.
"""
if not name:
print("Error: developer name is required", file=sys.stderr)
return False
if repo_root is None:
repo_root = get_repo_root()
dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
workspace_dir = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / name
# Create .developer file
initialized_at = datetime.now().isoformat()
try:
dev_file.write_text(
f"name={name}\ninitialized_at={initialized_at}\n",
encoding="utf-8"
)
except (OSError, IOError) as e:
print(f"Error: Failed to create .developer file: {e}", file=sys.stderr)
return False
# Create workspace directory structure
try:
workspace_dir.mkdir(parents=True, exist_ok=True)
except (OSError, IOError) as e:
print(f"Error: Failed to create workspace directory: {e}", file=sys.stderr)
return False
# Create initial journal file
journal_file = workspace_dir / f"{FILE_JOURNAL_PREFIX}1.md"
if not journal_file.exists():
today = datetime.now().strftime("%Y-%m-%d")
journal_content = f"""# Journal - {name} (Part 1)
> AI development session journal
> Started: {today}
---
"""
try:
journal_file.write_text(journal_content, encoding="utf-8")
except (OSError, IOError) as e:
print(f"Error: Failed to create journal file: {e}", file=sys.stderr)
return False
# Create index.md with markers for auto-update
index_file = workspace_dir / "index.md"
if not index_file.exists():
index_content = f"""# Workspace Index - {name}
> Journal tracking for AI development sessions.
---
## Current Status
<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 0
- **Last Active**: -
<!-- @@@/auto:current-status -->
---
## Active Documents
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~0 | Active |
<!-- @@@/auto:active-documents -->
---
## Session History
<!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------|
<!-- @@@/auto:session-history -->
---
## Notes
- Sessions are appended to journal files
- New journal file created when current exceeds 2000 lines
- Use `add_session.py` to record sessions
"""
try:
index_file.write_text(index_content, encoding="utf-8")
except (OSError, IOError) as e:
print(f"Error: Failed to create index.md: {e}", file=sys.stderr)
return False
print(f"Developer initialized: {name}")
print(f" .developer file: {dev_file}")
print(f" Workspace dir: {workspace_dir}")
return True
def ensure_developer(repo_root: Path | None = None) -> None:
"""Ensure developer is initialized, exit if not.
Args:
repo_root: Repository root path. Defaults to auto-detected.
"""
if repo_root is None:
repo_root = get_repo_root()
if not check_developer(repo_root):
print("Error: Developer not initialized.", file=sys.stderr)
print(f"Run: python3 ./{DIR_WORKFLOW}/scripts/init_developer.py <your-name>", file=sys.stderr)
sys.exit(1)
def show_developer_info(repo_root: Path | None = None) -> None:
"""Show developer information.
Args:
repo_root: Repository root path. Defaults to auto-detected.
"""
if repo_root is None:
repo_root = get_repo_root()
developer = get_developer(repo_root)
if not developer:
print("Developer: (not initialized)")
else:
print(f"Developer: {developer}")
print(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/")
print(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/")
# =============================================================================
# Main Entry (for testing)
# =============================================================================
if __name__ == "__main__":
show_developer_info()

31
.trellis/scripts/common/git.py Executable file
View File

@ -0,0 +1,31 @@
"""
Git command execution utility.
Single source of truth for running git commands across all Trellis scripts.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
def run_git(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]:
"""Run a git command and return (returncode, stdout, stderr).
Uses UTF-8 encoding with -c i18n.logOutputEncoding=UTF-8 to ensure
consistent output across all platforms (Windows, macOS, Linux).
"""
try:
git_args = ["git", "-c", "i18n.logOutputEncoding=UTF-8"] + args
result = subprocess.run(
git_args,
cwd=cwd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
return result.returncode, result.stdout, result.stderr
except Exception as e:
return 1, "", str(e)

View File

@ -0,0 +1,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()

37
.trellis/scripts/common/io.py Executable file
View File

@ -0,0 +1,37 @@
"""
JSON file I/O utilities.
Provides read_json and write_json as the single source of truth
for JSON file operations across all Trellis scripts.
"""
from __future__ import annotations
import json
from pathlib import Path
def read_json(path: Path) -> dict | None:
"""Read and parse a JSON file.
Returns None if the file doesn't exist, is invalid JSON, or can't be read.
"""
try:
return json.loads(path.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
def write_json(path: Path, data: dict) -> bool:
"""Write dict to JSON file with pretty formatting.
Returns True on success, False on error.
"""
try:
path.write_text(
json.dumps(data, indent=2, ensure_ascii=False),
encoding="utf-8",
)
return True
except (OSError, IOError):
return False

45
.trellis/scripts/common/log.py Executable file
View File

@ -0,0 +1,45 @@
"""
Terminal output utilities: colors and structured logging.
Single source of truth for Colors and log_* functions
used across all Trellis scripts.
"""
from __future__ import annotations
class Colors:
"""ANSI color codes for terminal output."""
RED = "\033[0;31m"
GREEN = "\033[0;32m"
YELLOW = "\033[1;33m"
BLUE = "\033[0;34m"
CYAN = "\033[0;36m"
DIM = "\033[2m"
NC = "\033[0m" # No Color / Reset
def colored(text: str, color: str) -> str:
"""Apply ANSI color to text."""
return f"{color}{text}{Colors.NC}"
def log_info(msg: str) -> None:
"""Print info-level message with [INFO] prefix."""
print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}")
def log_success(msg: str) -> None:
"""Print success message with [SUCCESS] prefix."""
print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}")
def log_warn(msg: str) -> None:
"""Print warning message with [WARN] prefix."""
print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}")
def log_error(msg: str) -> None:
"""Print error message with [ERROR] prefix."""
print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}")

View File

@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""
Package discovery and context output.
Provides:
get_packages_info - Get structured package info
get_packages_section - Build PACKAGES text section
get_context_packages_text - Full packages text output (--mode packages)
get_context_packages_json - Full packages JSON output (--mode packages --json)
"""
from __future__ import annotations
from pathlib import Path
from .config import _is_true_config_value, get_default_package, get_packages, get_spec_scope
from .paths import (
DIR_SPEC,
DIR_WORKFLOW,
get_current_task,
get_repo_root,
)
from .tasks import load_task
# =============================================================================
# Internal Helpers
# =============================================================================
def _scan_spec_layers(spec_dir: Path, package: str | None = None) -> list[str]:
"""Scan spec directory for available layers (subdirectories).
For monorepo: scans spec/<package>/
For single-repo: scans spec/
"""
target = spec_dir / package if package else spec_dir
if not target.is_dir():
return []
return sorted(
d.name for d in target.iterdir() if d.is_dir() and d.name != "guides"
)
def _get_active_task_package(repo_root: Path) -> str | None:
"""Get the package field from the active task's task.json."""
current = get_current_task(repo_root)
if not current:
return None
ct = load_task(repo_root / current)
return ct.package if ct and ct.package else None
def _resolve_scope_set(
packages: dict,
spec_scope,
task_pkg: str | None,
default_pkg: str | None,
) -> set | None:
"""Resolve spec_scope to a set of allowed package names, or None for full scan."""
if not packages:
return None
if spec_scope is None:
return None
if isinstance(spec_scope, str) and spec_scope == "active_task":
if task_pkg and task_pkg in packages:
return {task_pkg}
if default_pkg and default_pkg in packages:
return {default_pkg}
return None
if isinstance(spec_scope, list):
valid = {e for e in spec_scope if e in packages}
if valid:
return valid
# All invalid: fallback
if task_pkg and task_pkg in packages:
return {task_pkg}
if default_pkg and default_pkg in packages:
return {default_pkg}
return None
return None
# =============================================================================
# Public Functions
# =============================================================================
def get_packages_info(repo_root: Path) -> list[dict]:
"""Get structured package info for monorepo projects.
Returns list of dicts with keys: name, path, type, default, specLayers,
isSubmodule, isGitRepo.
Returns empty list for single-repo projects.
"""
packages = get_packages(repo_root)
if not packages:
return []
default_pkg = get_default_package(repo_root)
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
result = []
for pkg_name, pkg_config in packages.items():
pkg_path = pkg_config.get("path", pkg_name) if isinstance(pkg_config, dict) else str(pkg_config)
pkg_type = pkg_config.get("type", "local") if isinstance(pkg_config, dict) else "local"
pkg_git = pkg_config.get("git", False) if isinstance(pkg_config, dict) else False
layers = _scan_spec_layers(spec_dir, pkg_name)
result.append({
"name": pkg_name,
"path": pkg_path,
"type": pkg_type,
"default": pkg_name == default_pkg,
"specLayers": layers,
"isSubmodule": pkg_type == "submodule",
"isGitRepo": _is_true_config_value(pkg_git),
})
return result
def get_packages_section(repo_root: Path) -> str:
"""Build the PACKAGES section for text output."""
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
pkg_info = get_packages_info(repo_root)
lines: list[str] = []
lines.append("## PACKAGES")
if not pkg_info:
lines.append("(single-repo mode)")
layers = _scan_spec_layers(spec_dir)
if layers:
lines.append(f"Spec layers: {', '.join(layers)}")
return "\n".join(lines)
default_pkg = get_default_package(repo_root)
for pkg in pkg_info:
layers_str = f" [{', '.join(pkg['specLayers'])}]" if pkg["specLayers"] else ""
submodule_tag = " (submodule)" if pkg["isSubmodule"] else ""
git_repo_tag = " (git repo)" if pkg["isGitRepo"] else ""
default_tag = " *" if pkg["default"] else ""
lines.append(
f"- {pkg['name']:<16} {pkg['path']:<20}{layers_str}{submodule_tag}{git_repo_tag}{default_tag}"
)
if default_pkg:
lines.append(f"Default package: {default_pkg}")
return "\n".join(lines)
def get_context_packages_text(repo_root: Path | None = None) -> str:
"""Get packages context as formatted text (for --mode packages)."""
if repo_root is None:
repo_root = get_repo_root()
pkg_info = get_packages_info(repo_root)
lines: list[str] = []
if not pkg_info:
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
lines.append("Single-repo project (no packages configured)")
lines.append("")
layers = _scan_spec_layers(spec_dir)
if layers:
lines.append(f"Spec layers: {', '.join(layers)}")
return "\n".join(lines)
# Resolve scope for annotations
packages_dict = get_packages(repo_root) or {}
default_pkg = get_default_package(repo_root)
spec_scope = get_spec_scope(repo_root)
task_pkg = _get_active_task_package(repo_root)
scope_set = _resolve_scope_set(packages_dict, spec_scope, task_pkg, default_pkg)
lines.append("## PACKAGES")
lines.append("")
for pkg in pkg_info:
default_tag = " (default)" if pkg["default"] else ""
type_tag = f" [{pkg['type']}]" if pkg["type"] != "local" else ""
git_tag = " [git repo]" if pkg["isGitRepo"] else ""
# Scope annotation
scope_tag = ""
if scope_set is not None and pkg["name"] not in scope_set:
scope_tag = " (out of scope)"
lines.append(f"### {pkg['name']}{default_tag}{type_tag}{git_tag}{scope_tag}")
lines.append(f"Path: {pkg['path']}")
if pkg["specLayers"]:
lines.append(f"Spec layers: {', '.join(pkg['specLayers'])}")
for layer in pkg["specLayers"]:
lines.append(f" - .trellis/spec/{pkg['name']}/{layer}/index.md")
else:
lines.append("Spec: not configured")
lines.append("")
# Also show shared guides
guides_dir = repo_root / DIR_WORKFLOW / DIR_SPEC / "guides"
if guides_dir.is_dir():
lines.append("### Shared Guides (always included)")
lines.append("Path: .trellis/spec/guides/index.md")
lines.append("")
return "\n".join(lines)
def get_context_packages_json(repo_root: Path | None = None) -> dict:
"""Get packages context as a dictionary (for --mode packages --json)."""
if repo_root is None:
repo_root = get_repo_root()
pkg_info = get_packages_info(repo_root)
if not pkg_info:
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
layers = _scan_spec_layers(spec_dir)
return {
"mode": "single-repo",
"specLayers": layers,
}
default_pkg = get_default_package(repo_root)
spec_scope = get_spec_scope(repo_root)
task_pkg = _get_active_task_package(repo_root)
return {
"mode": "monorepo",
"packages": pkg_info,
"defaultPackage": default_pkg,
"specScope": spec_scope,
"activeTaskPackage": task_pkg,
}

444
.trellis/scripts/common/paths.py Executable file
View File

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

254
.trellis/scripts/common/phase.py Executable file
View File

@ -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>")

View File

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

View File

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

View File

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

View File

@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""
Task queue utility functions.
Provides:
list_tasks_by_status - List tasks by status
list_pending_tasks - List tasks with pending status
list_tasks_by_assignee - List tasks by assignee
list_my_tasks - List tasks assigned to current developer
get_task_stats - Get P0/P1/P2/P3 counts
"""
from __future__ import annotations
from pathlib import Path
from .paths import (
get_repo_root,
get_developer,
get_tasks_dir,
)
from .tasks import iter_active_tasks
# =============================================================================
# Internal helper
# =============================================================================
def _task_to_dict(t) -> dict:
"""Convert TaskInfo to the dict format callers expect."""
return {
"priority": t.priority,
"id": t.raw.get("id", ""),
"title": t.title,
"status": t.status,
"assignee": t.assignee or "-",
"dir": t.dir_name,
"children": list(t.children),
"parent": t.parent,
}
# =============================================================================
# Public Functions
# =============================================================================
def list_tasks_by_status(
filter_status: str | None = None,
repo_root: Path | None = None
) -> list[dict]:
"""List tasks by status.
Args:
filter_status: Optional status filter.
repo_root: Repository root path. Defaults to auto-detected.
Returns:
List of task info dicts with keys: priority, id, title, status, assignee.
"""
if repo_root is None:
repo_root = get_repo_root()
tasks_dir = get_tasks_dir(repo_root)
results = []
for t in iter_active_tasks(tasks_dir):
if filter_status and t.status != filter_status:
continue
results.append(_task_to_dict(t))
return results
def list_pending_tasks(repo_root: Path | None = None) -> list[dict]:
"""List pending tasks.
Args:
repo_root: Repository root path. Defaults to auto-detected.
Returns:
List of task info dicts.
"""
return list_tasks_by_status("planning", repo_root)
def list_tasks_by_assignee(
assignee: str,
filter_status: str | None = None,
repo_root: Path | None = None
) -> list[dict]:
"""List tasks assigned to a specific developer.
Args:
assignee: Developer name.
filter_status: Optional status filter.
repo_root: Repository root path. Defaults to auto-detected.
Returns:
List of task info dicts.
"""
if repo_root is None:
repo_root = get_repo_root()
tasks_dir = get_tasks_dir(repo_root)
results = []
for t in iter_active_tasks(tasks_dir):
if (t.assignee or "-") != assignee:
continue
if filter_status and t.status != filter_status:
continue
results.append(_task_to_dict(t))
return results
def list_my_tasks(
filter_status: str | None = None,
repo_root: Path | None = None
) -> list[dict]:
"""List tasks assigned to current developer.
Args:
filter_status: Optional status filter.
repo_root: Repository root path. Defaults to auto-detected.
Returns:
List of task info dicts.
Raises:
ValueError: If developer not set.
"""
if repo_root is None:
repo_root = get_repo_root()
developer = get_developer(repo_root)
if not developer:
raise ValueError("Developer not set")
return list_tasks_by_assignee(developer, filter_status, repo_root)
def get_task_stats(repo_root: Path | None = None) -> dict[str, int]:
"""Get task statistics.
Args:
repo_root: Repository root path. Defaults to auto-detected.
Returns:
Dict with keys: P0, P1, P2, P3, Total.
"""
if repo_root is None:
repo_root = get_repo_root()
tasks_dir = get_tasks_dir(repo_root)
stats = {"P0": 0, "P1": 0, "P2": 0, "P3": 0, "Total": 0}
for t in iter_active_tasks(tasks_dir):
if t.priority in stats:
stats[t.priority] += 1
stats["Total"] += 1
return stats
def format_task_stats(stats: dict[str, int]) -> str:
"""Format task stats as string.
Args:
stats: Stats dict from get_task_stats.
Returns:
Formatted string like "P0:0 P1:1 P2:2 P3:0 Total:3".
"""
return f"P0:{stats['P0']} P1:{stats['P1']} P2:{stats['P2']} P3:{stats['P3']} Total:{stats['Total']}"
# =============================================================================
# Main Entry (for testing)
# =============================================================================
if __name__ == "__main__":
stats = get_task_stats()
print(format_task_stats(stats))
print()
print("Pending tasks:")
for task in list_pending_tasks():
print(f" {task['priority']}|{task['id']}|{task['title']}|{task['status']}|{task['assignee']}")

View File

@ -0,0 +1,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

View File

@ -0,0 +1,274 @@
#!/usr/bin/env python3
"""
Task utility functions.
Provides:
is_safe_task_path - Validate task path is safe to operate on
find_task_by_name - Find task directory by name
resolve_task_dir - Resolve task directory from name, relative, or absolute path
archive_task_dir - Archive task to monthly directory
run_task_hooks - Run lifecycle hooks for task events
"""
from __future__ import annotations
import shutil
import sys
from datetime import datetime
from pathlib import Path
from .paths import get_repo_root, get_tasks_dir
# =============================================================================
# Path Safety
# =============================================================================
def is_safe_task_path(task_path: str, repo_root: Path | None = None) -> bool:
"""Check if a relative task path is safe to operate on.
Args:
task_path: Task path (relative to repo_root).
repo_root: Repository root path. Defaults to auto-detected.
Returns:
True if safe, False if dangerous.
"""
if repo_root is None:
repo_root = get_repo_root()
normalized = task_path.replace("\\", "/")
# Check empty or null
if not normalized or normalized == "null":
print("Error: empty or null task path", file=sys.stderr)
return False
# Reject absolute paths
if Path(task_path).is_absolute():
print(f"Error: absolute path not allowed: {task_path}", file=sys.stderr)
return False
# Reject ".", "..", paths starting with "./" or "../", or containing ".."
if normalized in (".", "..") or normalized.startswith("./") or normalized.startswith("../") or ".." in normalized:
print(f"Error: path traversal not allowed: {task_path}", file=sys.stderr)
return False
# Final check: ensure resolved path is not the repo root
abs_path = repo_root / Path(normalized)
if abs_path.exists():
try:
resolved = abs_path.resolve()
root_resolved = repo_root.resolve()
if resolved == root_resolved:
print(f"Error: path resolves to repo root: {task_path}", file=sys.stderr)
return False
except (OSError, IOError):
pass
return True
# =============================================================================
# Task Lookup
# =============================================================================
def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None:
"""Find task directory by name (exact or suffix match).
Args:
task_name: Task name to find.
tasks_dir: Tasks directory path.
Returns:
Absolute path to task directory, or None if not found.
"""
if not task_name or not tasks_dir or not tasks_dir.is_dir():
return None
# Try exact match first
exact_match = tasks_dir / task_name
if exact_match.is_dir():
return exact_match
# Try suffix match (e.g., "my-task" matches "01-21-my-task")
for d in tasks_dir.iterdir():
if d.is_dir() and d.name.endswith(f"-{task_name}"):
return d
return None
# =============================================================================
# Archive Operations
# =============================================================================
def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path | None:
"""Archive a task directory to archive/{YYYY-MM}/.
Args:
task_dir_abs: Absolute path to task directory.
repo_root: Repository root path. Defaults to auto-detected.
Returns:
Path to archived directory, or None on error.
"""
if not task_dir_abs.is_dir():
print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr)
return None
# Get tasks directory (parent of the task)
tasks_dir = task_dir_abs.parent
archive_dir = tasks_dir / "archive"
year_month = datetime.now().strftime("%Y-%m")
month_dir = archive_dir / year_month
# Create archive directory
try:
month_dir.mkdir(parents=True, exist_ok=True)
except (OSError, IOError) as e:
print(f"Error: Failed to create archive directory: {e}", file=sys.stderr)
return None
# Move task to archive
task_name = task_dir_abs.name
dest = month_dir / task_name
try:
shutil.move(str(task_dir_abs), str(dest))
except (OSError, IOError, shutil.Error) as e:
print(f"Error: Failed to move task to archive: {e}", file=sys.stderr)
return None
return dest
def archive_task_complete(
task_dir_abs: Path,
repo_root: Path | None = None
) -> dict[str, str]:
"""Complete archive workflow: archive directory.
Args:
task_dir_abs: Absolute path to task directory.
repo_root: Repository root path. Defaults to auto-detected.
Returns:
Dict with archive result info.
"""
if not task_dir_abs.is_dir():
print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr)
return {}
archive_dest = archive_task_dir(task_dir_abs, repo_root)
if archive_dest:
return {"archived_to": str(archive_dest)}
return {}
# =============================================================================
# Task Directory Resolution
# =============================================================================
def resolve_task_dir(target_dir: str, repo_root: Path) -> Path:
"""Resolve task directory to absolute path.
Supports:
- Absolute path: /path/to/task
- Relative path: .trellis/tasks/01-31-my-task
- Task name: my-task (uses find_task_by_name for lookup)
Args:
target_dir: Task directory specification.
repo_root: Repository root path.
Returns:
Resolved absolute path.
"""
if not target_dir:
return Path()
normalized = target_dir.replace("\\", "/")
while normalized.startswith("./"):
normalized = normalized[2:]
# Absolute path
if Path(target_dir).is_absolute():
return Path(target_dir)
# Relative path (contains path separator or starts with .trellis)
if "/" in normalized or normalized.startswith(".trellis"):
return repo_root / Path(normalized)
# Task name - try to find in tasks directory
tasks_dir = get_tasks_dir(repo_root)
found = find_task_by_name(target_dir, tasks_dir)
if found:
return found
# Fallback to treating as relative path
return repo_root / Path(normalized)
# =============================================================================
# Lifecycle Hooks
# =============================================================================
def run_task_hooks(event: str, task_json_path: Path, repo_root: Path) -> None:
"""Run lifecycle hooks for a task event.
Args:
event: Event name (e.g. "after_create").
task_json_path: Absolute path to the task's task.json.
repo_root: Repository root for cwd and config lookup.
"""
import os
import subprocess
from .config import get_hooks
from .log import Colors, colored
commands = get_hooks(event, repo_root)
if not commands:
return
env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)}
for cmd in commands:
try:
result = subprocess.run(
cmd,
shell=True,
cwd=repo_root,
env=env,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if result.returncode != 0:
print(
colored(f"[WARN] Hook failed ({event}): {cmd}", Colors.YELLOW),
file=sys.stderr,
)
if result.stderr.strip():
print(f" {result.stderr.strip()}", file=sys.stderr)
except Exception as e:
print(
colored(f"[WARN] Hook error ({event}): {cmd}{e}", Colors.YELLOW),
file=sys.stderr,
)
# =============================================================================
# Main Entry (for testing)
# =============================================================================
if __name__ == "__main__":
repo = get_repo_root()
tasks = get_tasks_dir(repo)
print(f"Tasks dir: {tasks}")
print(f"is_safe_task_path('.trellis/tasks/test'): {is_safe_task_path('.trellis/tasks/test', repo)}")
print(f"is_safe_task_path('../test'): {is_safe_task_path('../test', repo)}")

109
.trellis/scripts/common/tasks.py Executable file
View File

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

112
.trellis/scripts/common/types.py Executable file
View File

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

View File

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

View File

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

16
.trellis/scripts/get_context.py Executable file
View File

@ -0,0 +1,16 @@
#!/usr/bin/env python3
"""
Get Session Context for AI Agent.
Usage:
python3 get_context.py Output context in text format
python3 get_context.py --json Output context in JSON format
"""
from __future__ import annotations
from common.git_context import main
if __name__ == "__main__":
main()

View File

@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""
Get current developer name.
This is a wrapper that uses common/paths.py
"""
from __future__ import annotations
import sys
from common.paths import get_developer
def main() -> None:
"""CLI entry point."""
developer = get_developer()
if developer:
print(developer)
else:
print("Developer not initialized", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""Linear sync hook for Trellis task lifecycle.
Syncs task events to Linear via the `linearis` CLI.
Usage (called automatically by task.py hooks):
python3 .trellis/scripts/hooks/linear_sync.py create
python3 .trellis/scripts/hooks/linear_sync.py start
python3 .trellis/scripts/hooks/linear_sync.py archive
Manual usage:
TASK_JSON_PATH=.trellis/tasks/<name>/task.json python3 .trellis/scripts/hooks/linear_sync.py sync
Environment:
TASK_JSON_PATH - Absolute path to task.json (set by task.py)
Configuration:
.trellis/hooks.local.json - Local config (gitignored), example:
{
"linear": {
"team": "TEAM_KEY",
"project": "Project Name",
"assignees": {
"dev-name": "linear-user-id"
}
}
}
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
# ─── Configuration ────────────────────────────────────────────────────────────
# Trellis priority → Linear priority (1=Urgent, 2=High, 3=Medium, 4=Low)
PRIORITY_MAP = {"P0": 1, "P1": 2, "P2": 3, "P3": 4}
# Linear status names (must match your team's workflow)
STATUS_IN_PROGRESS = "In Progress"
STATUS_DONE = "Done"
def _load_config() -> dict:
"""Load local hook config from .trellis/hooks.local.json."""
task_json_path = os.environ.get("TASK_JSON_PATH", "")
if task_json_path:
# Walk up from task.json to find .trellis/
trellis_dir = Path(task_json_path).parent.parent.parent
else:
trellis_dir = Path(".trellis")
config_path = trellis_dir / "hooks.local.json"
try:
with open(config_path, encoding="utf-8") as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return {}
CONFIG = _load_config()
LINEAR_CFG = CONFIG.get("linear", {})
TEAM = LINEAR_CFG.get("team", "")
PROJECT = LINEAR_CFG.get("project", "")
ASSIGNEE_MAP = LINEAR_CFG.get("assignees", {})
# ─── Helpers ──────────────────────────────────────────────────────────────────
def _read_task() -> tuple[dict, str]:
path = os.environ.get("TASK_JSON_PATH", "")
if not path:
print("TASK_JSON_PATH not set", file=sys.stderr)
sys.exit(1)
with open(path, encoding="utf-8") as f:
return json.load(f), path
def _write_task(data: dict, path: str) -> None:
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
def _linearis(*args: str) -> dict | None:
result = subprocess.run(
["linearis", *args],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if result.returncode != 0:
print(f"linearis error: {result.stderr.strip()}", file=sys.stderr)
sys.exit(1)
stdout = result.stdout.strip()
if stdout:
return json.loads(stdout)
return None
def _get_linear_issue(task: dict) -> str | None:
meta = task.get("meta")
if isinstance(meta, dict):
return meta.get("linear_issue")
return None
# ─── Actions ──────────────────────────────────────────────────────────────────
def cmd_create() -> None:
if not TEAM:
print("No linear.team configured in hooks.local.json", file=sys.stderr)
sys.exit(1)
task, path = _read_task()
# Skip if already linked
if _get_linear_issue(task):
print(f"Already linked: {_get_linear_issue(task)}")
return
title = task.get("title") or task.get("name") or "Untitled"
args = ["issues", "create", title, "--team", TEAM]
# Map priority
priority = PRIORITY_MAP.get(task.get("priority", ""), 0)
if priority:
args.extend(["-p", str(priority)])
# Set project
if PROJECT:
args.extend(["--project", PROJECT])
# Assign to Linear user
assignee = task.get("assignee", "")
linear_user_id = ASSIGNEE_MAP.get(assignee)
if linear_user_id:
args.extend(["--assignee", linear_user_id])
# Link to parent's Linear issue if available
parent_issue = _resolve_parent_linear_issue(task)
if parent_issue:
args.extend(["--parent-ticket", parent_issue])
result = _linearis(*args)
if result and "identifier" in result:
if not isinstance(task.get("meta"), dict):
task["meta"] = {}
task["meta"]["linear_issue"] = result["identifier"]
_write_task(task, path)
print(f"Created Linear issue: {result['identifier']}")
def cmd_start() -> None:
task, _ = _read_task()
issue = _get_linear_issue(task)
if not issue:
return
_linearis("issues", "update", issue, "-s", STATUS_IN_PROGRESS)
print(f"Updated {issue} -> {STATUS_IN_PROGRESS}")
cmd_sync()
def cmd_archive() -> None:
task, _ = _read_task()
issue = _get_linear_issue(task)
if not issue:
return
_linearis("issues", "update", issue, "-s", STATUS_DONE)
print(f"Updated {issue} -> {STATUS_DONE}")
def cmd_sync() -> None:
"""Sync prd.md content to Linear issue description."""
task, _ = _read_task()
issue = _get_linear_issue(task)
if not issue:
print("No linear_issue in meta, run create first", file=sys.stderr)
sys.exit(1)
# Find prd.md next to task.json
task_json_path = os.environ.get("TASK_JSON_PATH", "")
prd_path = Path(task_json_path).parent / "prd.md"
if not prd_path.is_file():
print(f"No prd.md found at {prd_path}", file=sys.stderr)
sys.exit(1)
description = prd_path.read_text(encoding="utf-8").strip()
_linearis("issues", "update", issue, "-d", description)
print(f"Synced prd.md to {issue} description")
# ─── Parent Issue Resolution ─────────────────────────────────────────────────
def _resolve_parent_linear_issue(task: dict) -> str | None:
"""Find parent task's Linear issue identifier."""
parent_name = task.get("parent")
if not parent_name:
return None
task_json_path = os.environ.get("TASK_JSON_PATH", "")
if not task_json_path:
return None
current_task_dir = Path(task_json_path).parent
tasks_dir = current_task_dir.parent
parent_json = tasks_dir / parent_name / "task.json"
if parent_json.exists():
try:
with open(parent_json, encoding="utf-8") as f:
parent_task = json.load(f)
return _get_linear_issue(parent_task)
except (json.JSONDecodeError, OSError):
pass
return None
# ─── Main ─────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
action = sys.argv[1] if len(sys.argv) > 1 else ""
actions = {
"create": cmd_create,
"start": cmd_start,
"archive": cmd_archive,
"sync": cmd_sync,
}
fn = actions.get(action)
if fn:
fn()
else:
print(f"Unknown action: {action}", file=sys.stderr)
print(f"Valid actions: {', '.join(actions)}", file=sys.stderr)
sys.exit(1)

View File

@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""
Initialize developer for workflow.
Usage:
python3 init_developer.py <developer-name>
This creates:
- .trellis/.developer file with developer info
- .trellis/workspace/<name>/ directory structure
"""
from __future__ import annotations
import sys
from common.paths import (
DIR_WORKFLOW,
FILE_DEVELOPER,
get_developer,
)
from common.developer import init_developer
def main() -> None:
"""CLI entry point."""
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <developer-name>")
print()
print("Example:")
print(f" {sys.argv[0]} john")
sys.exit(1)
name = sys.argv[1]
# Check if already initialized
existing = get_developer()
if existing:
print(f"Developer already initialized: {existing}")
print()
print(f"To reinitialize, remove {DIR_WORKFLOW}/{FILE_DEVELOPER} first")
sys.exit(0)
if init_developer(name):
sys.exit(0)
else:
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,5 @@
"""
Multi-Agent Pipeline Scripts.
This module provides orchestration for multi-agent workflows.
"""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

445
.trellis/scripts/task.py Executable file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

416
.trellis/workflow.md Normal file
View File

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

View File

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

View File

@ -0,0 +1,7 @@
# Journal - Apale (Part 1)
> AI development session journal
> Started: 2026-04-16
---

125
.trellis/workspace/index.md Normal file
View File

@ -0,0 +1,125 @@
# Workspace Index
> Records of all AI Agent work records across all developers
---
## Overview
This directory tracks records for all developers working with AI Agents on this project.
### File Structure
```
workspace/
|-- index.md # This file - main index
+-- {developer}/ # Per-developer directory
|-- index.md # Personal index with session history
|-- tasks/ # Task files
| |-- *.json # Active tasks
| +-- archive/ # Archived tasks by month
+-- journal-N.md # Journal files (sequential: 1, 2, 3...)
```
---
## Active Developers
| Developer | Last Active | Sessions | Active File |
|-----------|-------------|----------|-------------|
| (none yet) | - | - | - |
---
## Getting Started
### For New Developers
Run the initialization script:
```bash
python3 ./.trellis/scripts/init_developer.py <your-name>
```
This will:
1. Create your identity file (gitignored)
2. Create your progress directory
3. Create your personal index
4. Create initial journal file
### For Returning Developers
1. Get your developer name:
```bash
python3 ./.trellis/scripts/get_developer.py
```
2. Read your personal index:
```bash
cat .trellis/workspace/$(python3 ./.trellis/scripts/get_developer.py)/index.md
```
---
## Guidelines
### Journal File Rules
- **Max 2000 lines** per journal file
- When limit is reached, create `journal-{N+1}.md`
- Update your personal `index.md` when creating new files
### Session Record Format
Each session should include:
- Summary: One-line description
- Branch: Which branch the work was done on
- Main Changes: What was modified
- Git Commits: Commit hashes and messages
- Next Steps: What to do next
---
## Session Template
Use this template when recording sessions:
```markdown
## Session {N}: {Title}
**Date**: YYYY-MM-DD
**Task**: {task-name}
**Branch**: `{branch-name}`
### Summary
{One-line summary}
### Main Changes
- {Change 1}
- {Change 2}
### Git Commits
| Hash | Message |
|------|---------|
| `abc1234` | {commit message} |
### Testing
- [OK] {Test result}
### Status
[OK] **Completed** / # **In Progress** / [P] **Blocked**
### Next Steps
- {Next step 1}
- {Next step 2}
```
---
**Language**: All documentation must be written in **English**.

47
.trellis/worktree.yaml Normal file
View File

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

22
AGENTS.md Normal file
View File

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