Browse Source

chore: migrate trellis to 0.4.0

dev
hechang27-sprt 3 months ago
parent
commit
935bc32edd
  1. 18
      .agents/skills/before-backend-dev/SKILL.md
  2. 34
      .agents/skills/before-dev/SKILL.md
  3. 18
      .agents/skills/before-frontend-dev/SKILL.md
  4. 2
      .agents/skills/brainstorm/SKILL.md
  5. 2
      .agents/skills/break-loop/SKILL.md
  6. 18
      .agents/skills/check-backend/SKILL.md
  7. 2
      .agents/skills/check-cross-layer/SKILL.md
  8. 18
      .agents/skills/check-frontend/SKILL.md
  9. 30
      .agents/skills/check/SKILL.md
  10. 6
      .agents/skills/create-command/SKILL.md
  11. 2
      .agents/skills/finish-work/SKILL.md
  12. 69
      .agents/skills/improve-ut/SKILL.md
  13. 2
      .agents/skills/integrate-skill/SKILL.md
  14. 24
      .agents/skills/onboard/SKILL.md
  15. 7
      .agents/skills/record-session/SKILL.md
  16. 13
      .agents/skills/start/SKILL.md
  17. 2
      .agents/skills/update-spec/SKILL.md
  18. 3
      .claude/agents/dispatch.md
  19. 5
      .claude/agents/implement.md
  20. 13
      .claude/commands/trellis/before-backend-dev.md
  21. 29
      .claude/commands/trellis/before-dev.md
  22. 13
      .claude/commands/trellis/before-frontend-dev.md
  23. 13
      .claude/commands/trellis/check-backend.md
  24. 13
      .claude/commands/trellis/check-frontend.md
  25. 25
      .claude/commands/trellis/check.md
  26. 4
      .claude/commands/trellis/create-command.md
  27. 26
      .claude/commands/trellis/onboard.md
  28. 3
      .claude/commands/trellis/parallel.md
  29. 5
      .claude/commands/trellis/record-session.md
  30. 12
      .claude/commands/trellis/start.md
  31. 34
      .claude/hooks/inject-subagent-context.py
  32. 172
      .claude/hooks/session-start.py
  33. 23
      .codex/agents/check.toml
  34. 19
      .codex/agents/implement.toml
  35. 26
      .codex/agents/research.toml
  36. 5
      .codex/config.toml
  37. 16
      .codex/hooks.json
  38. 204
      .codex/hooks/session-start.py
  39. 194
      .codex/skills/parallel/SKILL.md
  40. 104
      .trellis/.template-hashes.json
  41. 2
      .trellis/.version
  42. 20
      .trellis/config.yaml
  43. 117
      .trellis/scripts/add_session.py
  44. 150
      .trellis/scripts/common/cli_adapter.py
  45. 192
      .trellis/scripts/common/config.py
  46. 4
      .trellis/scripts/common/developer.py
  47. 31
      .trellis/scripts/common/git.py
  48. 609
      .trellis/scripts/common/git_context.py
  49. 37
      .trellis/scripts/common/io.py
  50. 45
      .trellis/scripts/common/log.py
  51. 238
      .trellis/scripts/common/packages_context.py
  52. 46
      .trellis/scripts/common/paths.py
  53. 99
      .trellis/scripts/common/phase.py
  54. 113
      .trellis/scripts/common/registry.py
  55. 562
      .trellis/scripts/common/session_context.py
  56. 410
      .trellis/scripts/common/task_context.py
  57. 125
      .trellis/scripts/common/task_queue.py
  58. 534
      .trellis/scripts/common/task_store.py
  59. 102
      .trellis/scripts/common/task_utils.py
  60. 109
      .trellis/scripts/common/tasks.py
  61. 112
      .trellis/scripts/common/types.py
  62. 59
      .trellis/scripts/create_bootstrap.py
  63. 91
      .trellis/scripts/multi_agent/cleanup.py
  64. 381
      .trellis/scripts/multi_agent/create_pr.py
  65. 41
      .trellis/scripts/multi_agent/plan.py
  66. 196
      .trellis/scripts/multi_agent/start.py
  67. 765
      .trellis/scripts/multi_agent/status.py
  68. 542
      .trellis/scripts/multi_agent/status_display.py
  69. 225
      .trellis/scripts/multi_agent/status_monitor.py
  70. 1025
      .trellis/scripts/task.py
  71. 70
      .trellis/tasks/04-07-migrate-to-0.4.0-beta.8/prd.md
  72. 40
      .trellis/tasks/04-07-migrate-to-0.4.0-beta.8/task.json

18
.agents/skills/before-backend-dev/SKILL.md

@ -1,18 +0,0 @@ @@ -1,18 +0,0 @@
---
name: before-backend-dev
description: "Read the backend development guidelines before starting your development task."
---
Read the backend development guidelines before starting your development task.
Execute these steps:
1. Read `.trellis/spec/backend/index.md` to understand available guidelines
2. Based on your task, read the relevant guideline files:
- Database work → `.trellis/spec/backend/database-guidelines.md`
- Error handling → `.trellis/spec/backend/error-handling.md`
- Logging → `.trellis/spec/backend/logging-guidelines.md`
- Type questions → `.trellis/spec/backend/type-safety.md`
3. Understand the coding standards and patterns you need to follow
4. Then proceed with your development plan
This step is **mandatory** before writing any backend code.

34
.agents/skills/before-dev/SKILL.md

@ -0,0 +1,34 @@ @@ -0,0 +1,34 @@
---
name: before-dev
description: "Discovers and injects project-specific coding guidelines from .trellis/spec/ before implementation begins. Reads spec indexes, pre-development checklists, and shared thinking guides for the target package. Use when starting a new coding task, before writing any code, switching to a different package, or needing to refresh project conventions and standards."
---
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.

18
.agents/skills/before-frontend-dev/SKILL.md

@ -1,18 +0,0 @@ @@ -1,18 +0,0 @@
---
name: before-frontend-dev
description: "Read the frontend development guidelines before starting your development task."
---
Read the frontend development guidelines before starting your development task.
Execute these steps:
1. Read `.trellis/spec/frontend/index.md` to understand available guidelines
2. Based on your task, read the relevant guideline files:
- Component work → `.trellis/spec/frontend/component-guidelines.md`
- Hook work → `.trellis/spec/frontend/hook-guidelines.md`
- State management → `.trellis/spec/frontend/state-management.md`
- Type questions → `.trellis/spec/frontend/type-safety.md`
3. Understand the coding standards and patterns you need to follow
4. Then proceed with your development plan
This step is **mandatory** before writing any frontend code.

2
.agents/skills/brainstorm/SKILL.md

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
---
name: brainstorm
description: "Brainstorm - Requirements Discovery (AI Coding Enhanced)"
description: "Collaborative requirements discovery session optimized for AI coding workflows. Creates task directories, seeds PRDs, runs codebase research, proposes concrete implementation approaches with trade-offs, and converges on MVP scope through structured Q&A. Use when requirements are unclear, multiple implementation paths exist, trade-offs need evaluation, or a complex feature needs scoping before development."
---
# Brainstorm - Requirements Discovery (AI Coding Enhanced)

2
.agents/skills/break-loop/SKILL.md

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
---
name: break-loop
description: "Break the Loop - Deep Bug Analysis"
description: "Deep post-fix bug analysis across five dimensions: root cause categorization, fix failure analysis, prevention mechanisms, systematic expansion, and knowledge capture. Updates .trellis/spec/ guides with lessons learned to prevent recurring bugs. Use when a debugging session completes, after fixing a tricky bug, when the same class of bug keeps recurring, or when you want to capture debugging insights into project documentation."
---
# Break the Loop - Deep Bug Analysis

18
.agents/skills/check-backend/SKILL.md

@ -1,18 +0,0 @@ @@ -1,18 +0,0 @@
---
name: check-backend
description: "Check if the code you just wrote follows the backend development guidelines."
---
Check if the code you just wrote follows the backend development guidelines.
Execute these steps:
1. Run `git status` to see modified files
2. Read `.trellis/spec/backend/index.md` to understand which guidelines apply
3. Based on what you changed, read the relevant guideline files:
- Database changes → `.trellis/spec/backend/database-guidelines.md`
- Error handling → `.trellis/spec/backend/error-handling.md`
- Logging changes → `.trellis/spec/backend/logging-guidelines.md`
- Type changes → `.trellis/spec/backend/type-safety.md`
- Any changes → `.trellis/spec/backend/quality-guidelines.md`
4. Review your code against the guidelines
5. Report any violations and fix them if found

2
.agents/skills/check-cross-layer/SKILL.md

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
---
name: check-cross-layer
description: "Cross-Layer Check"
description: "Post-implementation verification across multiple code dimensions: cross-layer data flow, code reuse analysis, import path validation, and same-layer consistency checks. Identifies missed update sites, type mismatches, and duplicated constants. Use when changes span 3+ architectural layers, after modifying shared constants or configs, after batch file modifications, or when creating new utility functions."
---
# Cross-Layer Check

18
.agents/skills/check-frontend/SKILL.md

@ -1,18 +0,0 @@ @@ -1,18 +0,0 @@
---
name: check-frontend
description: "Check if the code you just wrote follows the frontend development guidelines."
---
Check if the code you just wrote follows the frontend development guidelines.
Execute these steps:
1. Run `git status` to see modified files
2. Read `.trellis/spec/frontend/index.md` to understand which guidelines apply
3. Based on what you changed, read the relevant guideline files:
- Component changes → `.trellis/spec/frontend/component-guidelines.md`
- Hook changes → `.trellis/spec/frontend/hook-guidelines.md`
- State changes → `.trellis/spec/frontend/state-management.md`
- Type changes → `.trellis/spec/frontend/type-safety.md`
- Any changes → `.trellis/spec/frontend/quality-guidelines.md`
4. Review your code against the guidelines
5. Report any violations and fix them if found

30
.agents/skills/check/SKILL.md

@ -0,0 +1,30 @@ @@ -0,0 +1,30 @@
---
name: check
description: "Validates recently written code against project-specific development guidelines from .trellis/spec/. Identifies changed files via git diff, discovers applicable spec modules, runs lint and typecheck, and reports guideline violations. Use when code is written and needs quality verification, to catch context drift during long sessions, or before committing changes."
---
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.

6
.agents/skills/create-command/SKILL.md

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
---
name: create-command
description: "Create New Skill"
description: "Scaffolds a new skill file with proper naming conventions and structure. Analyzes requirements to determine skill type and generates appropriate content. Use when adding a new developer workflow skill, creating a custom skill, or extending the Trellis skill set."
---
# Create New Skill
@ -93,8 +93,8 @@ Description: @@ -93,8 +93,8 @@ Description:
| Skill Type | Prefix | Example |
|------------|--------|---------|
| Session Start | `start` | `start` |
| Pre-development | `before-` | `before-frontend-dev` |
| Check | `check-` | `check-frontend` |
| Pre-development | `before-` | `before-dev` |
| Check | `check-` | `check` |
| Record | `record-` | `record-session` |
| Generate | `generate-` | `generate-api-doc` |
| Update | `update-` | `update-changelog` |

2
.agents/skills/finish-work/SKILL.md

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
---
name: finish-work
description: "Finish Work - Pre-Commit Checklist"
description: "Pre-commit quality checklist covering lint, typecheck, tests, code-spec sync, API changes, database migrations, cross-layer verification, and manual testing. Blocks commit if infra or cross-layer specs lack executable depth. Use when code is written and tested but not yet committed, before submitting changes, or as a final review before git commit."
---
# Finish Work - Pre-Commit Checklist

69
.agents/skills/improve-ut/SKILL.md

@ -0,0 +1,69 @@ @@ -0,0 +1,69 @@
---
name: improve-ut
description: "Analyzes changed files and improves unit test coverage using project-specific testing conventions from .trellis/spec/ unit-test specs. Determines test scope (unit vs integration vs regression), adds or updates tests following existing patterns, and runs validation. Use when code changes need test coverage, after implementing a feature, after fixing a bug, or when test gaps are identified."
---
# Improve Unit Tests (UT)
Use this skill to improve test coverage after code changes.
## Usage
```text
$improve-ut
```
## Source of Truth
Discover and read unit-test specs dynamically:
```bash
# Discover available packages and their spec layers
python3 ./.trellis/scripts/get_context.py --mode packages
```
Look for packages with `unit-test` spec layer in the output. For each discovered `unit-test/` directory, read all relevant spec files inside it (for example `index.md`, `conventions.md`, `integration-patterns.md`, `mock-strategies.md`).
> If this skill conflicts with the unit-test specs, the specs win.
---
## Execution Flow
1. Inspect changed files:
- `git diff --name-only`
2. Decide test scope using unit-test specs:
- unit vs integration vs regression
- mock vs real filesystem flow
3. Add/update tests using existing project test patterns
4. Run validation:
```bash
pnpm lint
pnpm typecheck
pnpm test
```
5. Summarize decisions, updates, and remaining test gaps.
---
## Output Format
```markdown
## UT Coverage Plan
- Changed areas: ...
- Test scope (unit/integration/regression): ...
## Test Updates
- Added: ...
- Updated: ...
## Validation
- pnpm lint: pass/fail
- pnpm typecheck: pass/fail
- pnpm test: pass/fail
## Gaps / Follow-ups
- <none or explicit rationale>
```

2
.agents/skills/integrate-skill/SKILL.md

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
---
name: integrate-skill
description: "Integrate Skill into Project Guidelines"
description: "Adapts an external skill into project-specific development guidelines in .trellis/spec/. Creates guideline sections, code example templates with .template suffix, and updates spec indexes. Use when integrating an external skill, adding a new skill's patterns to project conventions, or incorporating third-party skill best practices into .trellis/spec/ documentation."
---
# Integrate Skill into Project Guidelines

24
.agents/skills/onboard/SKILL.md

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
---
name: onboard
description: "PART 3: Customize Your Development Guidelines"
description: "Interactive three-part onboarding for new team members to the Trellis AI-assisted workflow system. Covers core philosophy (AI memory, project-specific knowledge, context drift), system structure and command deep-dives, real-world workflow examples, and guideline customization. Use when a new developer joins the project, someone needs to understand the Trellis workflow, or project guidelines need initial setup."
---
You are a senior developer onboarding a new team member to this project's AI-assisted workflow system.
@ -131,13 +131,13 @@ AI needs the same onboarding - but compressed into seconds at session start. @@ -131,13 +131,13 @@ AI needs the same onboarding - but compressed into seconds at session start.
---
### $before-frontend-dev and $before-backend-dev - Inject Specialized Knowledge
### $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. Reads `.trellis/spec/frontend/` or `.trellis/spec/backend/`
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
@ -145,12 +145,12 @@ AI models have "pre-trained knowledge" - general patterns from millions of codeb @@ -145,12 +145,12 @@ AI models have "pre-trained knowledge" - general patterns from millions of codeb
- 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.
- 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.
---
### $check-frontend and $check-backend - Combat Context Drift
### $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."
@ -216,9 +216,9 @@ All the context AI built during this session will be lost when session ends. The @@ -216,9 +216,9 @@ All the context AI built during this session will be lost when session ends. The
**[1/8] $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] $before-frontend-dev** - Inject project-specific frontend knowledge
**[3/8] $before-dev** - Inject project-specific development guidelines
**[4/8] Investigate and fix the bug** - Actual development work
**[5/8] $check-frontend** - Re-verify code against guidelines
**[5/8] $check** - Re-verify code against guidelines
**[6/8] $finish-work** - Holistic cross-layer review
**[7/8] Human tests and commits** - Human validates before code enters repo
**[8/8] $record-session** - Persist memory for future sessions
@ -233,9 +233,9 @@ All the context AI built during this session will be lost when session ends. The @@ -233,9 +233,9 @@ All the context AI built during this session will be lost when session ends. The
### Example 3: Code Review Fixes
**[1/6] $start** - Resume context from previous session
**[2/6] $before-backend-dev** - Re-inject guidelines before fixes
**[2/6] $before-dev** - Re-inject guidelines before fixes
**[3/6] Fix each CR issue** - Address feedback with guidelines in context
**[4/6] $check-backend** - Verify fixes didn't introduce new issues
**[4/6] $check** - Verify fixes did not introduce new issues
**[5/6] $finish-work** - Document lessons from CR
**[6/6] Human commits, then $record-session** - Preserve CR lessons
@ -250,9 +250,9 @@ All the context AI built during this session will be lost when session ends. The @@ -250,9 +250,9 @@ All the context AI built during this session will be lost when session ends. The
### Example 5: Debug Session
**[1/6] $start** - See if this bug was investigated before
**[2/6] $before-backend-dev** - Guidelines might document known gotchas
**[2/6] $before-dev** - Guidelines might document known gotchas
**[3/6] Investigation** - Actual debugging work
**[4/6] $check-backend** - Verify debug changes don't break other things
**[4/6] $check** - Verify debug changes do not break other things
**[5/6] $finish-work** - Debug findings might need documentation
**[6/6] Human commits, then $record-session** - Debug knowledge is valuable

7
.agents/skills/record-session/SKILL.md

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
---
name: record-session
description: "Record work progress after human has tested and committed code"
description: "Records completed work progress to .trellis/workspace/ journal files after human testing and commit. Captures session summaries, commit hashes, and updates developer index files for future session context. Use when a coding session is complete, after the human has committed code, or to persist session knowledge for future AI sessions."
---
[!] **Prerequisite**: This skill should only be used AFTER the human has tested and committed the code.
@ -36,7 +36,7 @@ python3 ./.trellis/scripts/add_session.py \ @@ -36,7 +36,7 @@ python3 ./.trellis/scripts/add_session.py \
--summary "Brief summary of what was done"
# Method 2: Pass detailed content via stdin
cat << 'EOF' | python3 ./.trellis/scripts/add_session.py --title "Title" --commit "hash"
cat << 'EOF' | python3 ./.trellis/scripts/add_session.py --stdin --title "Title" --commit "hash"
| Feature | Description |
|---------|-------------|
| New API | Added user authentication endpoint |
@ -51,6 +51,7 @@ EOF @@ -51,6 +51,7 @@ 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
@ -61,6 +62,6 @@ EOF @@ -61,6 +62,6 @@ EOF
| Command | Purpose |
|---------|---------|
| `python3 ./.trellis/scripts/get_context.py --mode record` | Get context for record-session |
| `python3 ./.trellis/scripts/add_session.py --title "..." --commit "..."` | **One-click add session (recommended)** |
| `python3 ./.trellis/scripts/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 |

13
.agents/skills/start/SKILL.md

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
---
name: start
description: "Start Session"
description: "Initializes an AI development session by reading workflow guides, developer identity, git status, active tasks, and project guidelines from .trellis/. Classifies incoming tasks and routes to brainstorm, direct edit, or task workflow. Use when beginning a new coding session, resuming work, starting a new task, or re-establishing project context."
---
# Start Session
@ -45,9 +45,14 @@ This shows: developer identity, git status, current task (if any), active tasks. @@ -45,9 +45,14 @@ This shows: developer identity, git status, current task (if any), active tasks.
### Step 3: Read Guidelines Index
```bash
cat .trellis/spec/frontend/index.md # Frontend guidelines
cat .trellis/spec/backend/index.md # Backend guidelines
cat .trellis/spec/guides/index.md # Thinking guides
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`).

2
.agents/skills/update-spec/SKILL.md

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
---
name: update-spec
description: "Update Code-Spec - Capture Executable Contracts"
description: "Captures executable contracts and coding knowledge into .trellis/spec/ documents after implementation, debugging, or design decisions. Enforces code-spec depth for infra and cross-layer changes with mandatory sections for signatures, contracts, validation matrices, and test points. Use when a feature is implemented, a bug is fixed, a design decision is made, a new pattern is discovered, or cross-layer contracts change."
---
# Update Code-Spec - Capture Executable Contracts

3
.claude/agents/dispatch.md

@ -99,8 +99,7 @@ Hook will auto-inject: @@ -99,8 +99,7 @@ Hook will auto-inject:
- finish-work.md
- check-cross-layer.md
- check-backend.md
- check-frontend.md
- check.md
- All spec files from check.jsonl
### action: "debug"

5
.claude/agents/implement.md

@ -41,9 +41,8 @@ Before implementing, read: @@ -41,9 +41,8 @@ Before implementing, read:
Read relevant specs based on task type:
- Backend: `.trellis/spec/backend/`
- Frontend: `.trellis/spec/frontend/`
- Guides: `.trellis/spec/guides/`
- Spec layers: `.trellis/spec/<package>/<layer>/`
- Shared guides: `.trellis/spec/guides/`
### 2. Understand Requirements

13
.claude/commands/trellis/before-backend-dev.md

@ -1,13 +0,0 @@ @@ -1,13 +0,0 @@
Read the backend development guidelines before starting your development task.
Execute these steps:
1. Read `.trellis/spec/backend/index.md` to understand available guidelines
2. Based on your task, read the relevant guideline files:
- Database work → `.trellis/spec/backend/database-guidelines.md`
- Error handling → `.trellis/spec/backend/error-handling.md`
- Logging → `.trellis/spec/backend/logging-guidelines.md`
- Type questions → `.trellis/spec/backend/type-safety.md`
3. Understand the coding standards and patterns you need to follow
4. Then proceed with your development plan
This step is **mandatory** before writing any backend code.

29
.claude/commands/trellis/before-dev.md

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

13
.claude/commands/trellis/before-frontend-dev.md

@ -1,13 +0,0 @@ @@ -1,13 +0,0 @@
Read the frontend development guidelines before starting your development task.
Execute these steps:
1. Read `.trellis/spec/frontend/index.md` to understand available guidelines
2. Based on your task, read the relevant guideline files:
- Component work → `.trellis/spec/frontend/component-guidelines.md`
- Hook work → `.trellis/spec/frontend/hook-guidelines.md`
- State management → `.trellis/spec/frontend/state-management.md`
- Type questions → `.trellis/spec/frontend/type-safety.md`
3. Understand the coding standards and patterns you need to follow
4. Then proceed with your development plan
This step is **mandatory** before writing any frontend code.

13
.claude/commands/trellis/check-backend.md

@ -1,13 +0,0 @@ @@ -1,13 +0,0 @@
Check if the code you just wrote follows the backend development guidelines.
Execute these steps:
1. Run `git status` to see modified files
2. Read `.trellis/spec/backend/index.md` to understand which guidelines apply
3. Based on what you changed, read the relevant guideline files:
- Database changes → `.trellis/spec/backend/database-guidelines.md`
- Error handling → `.trellis/spec/backend/error-handling.md`
- Logging changes → `.trellis/spec/backend/logging-guidelines.md`
- Type changes → `.trellis/spec/backend/type-safety.md`
- Any changes → `.trellis/spec/backend/quality-guidelines.md`
4. Review your code against the guidelines
5. Report any violations and fix them if found

13
.claude/commands/trellis/check-frontend.md

@ -1,13 +0,0 @@ @@ -1,13 +0,0 @@
Check if the code you just wrote follows the frontend development guidelines.
Execute these steps:
1. Run `git status` to see modified files
2. Read `.trellis/spec/frontend/index.md` to understand which guidelines apply
3. Based on what you changed, read the relevant guideline files:
- Component changes → `.trellis/spec/frontend/component-guidelines.md`
- Hook changes → `.trellis/spec/frontend/hook-guidelines.md`
- State changes → `.trellis/spec/frontend/state-management.md`
- Type changes → `.trellis/spec/frontend/type-safety.md`
- Any changes → `.trellis/spec/frontend/quality-guidelines.md`
4. Review your code against the guidelines
5. Report any violations and fix them if found

25
.claude/commands/trellis/check.md

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

4
.claude/commands/trellis/create-command.md

@ -101,8 +101,8 @@ Description: @@ -101,8 +101,8 @@ Description:
| Command Type | Prefix | Example |
|--------------|--------|---------|
| Session Start | `start` | `start` |
| Pre-development | `before-` | `before-frontend-dev` |
| Check | `check-` | `check-frontend` |
| Pre-development | `before-` | `before-dev` |
| Check | `check-` | `check` |
| Record | `record-` | `record-session` |
| Generate | `generate-` | `generate-api-doc` |
| Update | `update-` | `update-changelog` |

26
.claude/commands/trellis/onboard.md

@ -126,13 +126,13 @@ AI needs the same onboarding - but compressed into seconds at session start. @@ -126,13 +126,13 @@ AI needs the same onboarding - but compressed into seconds at session start.
---
### /trellis:before-frontend-dev and /trellis:before-backend-dev - Inject Specialized Knowledge
### /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. Reads `.trellis/spec/frontend/` or `.trellis/spec/backend/`
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
@ -140,12 +140,12 @@ AI models have "pre-trained knowledge" - general patterns from millions of codeb @@ -140,12 +140,12 @@ AI models have "pre-trained knowledge" - general patterns from millions of codeb
- 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.
- Without before-dev: AI writes generic code that doesn't match project style.
- With before-dev: AI writes code that looks like the rest of the codebase.
---
### /trellis:check-frontend and /trellis:check-backend - Combat Context Drift
### /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."
@ -211,9 +211,9 @@ All the context AI built during this session will be lost when session ends. The @@ -211,9 +211,9 @@ All the context AI built during this session will be lost when session ends. The
**[1/8] /trellis:start** - AI needs project context before touching code
**[2/8] python3 ./.trellis/scripts/task.py create "Fix bug" --slug fix-bug** - Track work for future reference
**[3/8] /trellis:before-frontend-dev** - Inject project-specific frontend knowledge
**[3/8] /trellis:before-dev** - Inject project-specific development guidelines
**[4/8] Investigate and fix the bug** - Actual development work
**[5/8] /trellis:check-frontend** - Re-verify code against guidelines
**[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
@ -228,9 +228,9 @@ All the context AI built during this session will be lost when session ends. The @@ -228,9 +228,9 @@ All the context AI built during this session will be lost when session ends. The
### Example 3: Code Review Fixes
**[1/6] /trellis:start** - Resume context from previous session
**[2/6] /trellis:before-backend-dev** - Re-inject guidelines before fixes
**[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-backend** - Verify fixes didn't introduce new issues
**[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
@ -238,16 +238,16 @@ All the context AI built during this session will be lost when session ends. The @@ -238,16 +238,16 @@ All the context AI built during this session will be lost when session ends. The
**[1/5] /trellis:start** - Clear baseline before major changes
**[2/5] Plan phases** - Break into verifiable chunks
**[3/5] Execute phase by phase with /check-* after each** - Incremental verification
**[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-backend-dev** - Guidelines might document known gotchas
**[2/6] /trellis:before-dev** - Guidelines might document known gotchas
**[3/6] Investigation** - Actual debugging work
**[4/6] /trellis:check-backend** - Verify debug changes don't break other things
**[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
@ -256,7 +256,7 @@ All the context AI built during this session will be lost when session ends. The @@ -256,7 +256,7 @@ All the context AI built during this session will be lost when session ends. The
## KEY RULES TO EMPHASIZE
1. **AI NEVER commits** - Human tests and approves. AI prepares, human validates.
2. **Guidelines before code** - /before-*-dev commands inject project knowledge.
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.

3
.claude/commands/trellis/parallel.md

@ -41,8 +41,7 @@ python3 ./.trellis/scripts/get_context.py @@ -41,8 +41,7 @@ python3 ./.trellis/scripts/get_context.py
### Step 3: Read Project Guidelines `[AI]`
```bash
cat .trellis/spec/frontend/index.md # Frontend guidelines index
cat .trellis/spec/backend/index.md # Backend guidelines index
python3 ./.trellis/scripts/get_context.py --mode packages # Discover available spec layers
cat .trellis/spec/guides/index.md # Thinking guides
```

5
.claude/commands/trellis/record-session.md

@ -31,7 +31,7 @@ python3 ./.trellis/scripts/add_session.py \ @@ -31,7 +31,7 @@ python3 ./.trellis/scripts/add_session.py \
--summary "Brief summary of what was done"
# Method 2: Pass detailed content via stdin
cat << 'EOF' | python3 ./.trellis/scripts/add_session.py --title "Title" --commit "hash"
cat << 'EOF' | python3 ./.trellis/scripts/add_session.py --stdin --title "Title" --commit "hash"
| Feature | Description |
|---------|-------------|
| New API | Added user authentication endpoint |
@ -46,6 +46,7 @@ EOF @@ -46,6 +46,7 @@ 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
@ -56,6 +57,6 @@ EOF @@ -56,6 +57,6 @@ EOF
| Command | Purpose |
|---------|---------|
| `python3 ./.trellis/scripts/get_context.py --mode record` | Get context for record-session |
| `python3 ./.trellis/scripts/add_session.py --title "..." --commit "..."` | **One-click add session (recommended)** |
| `python3 ./.trellis/scripts/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 |

12
.claude/commands/trellis/start.md

@ -40,10 +40,14 @@ This shows: developer identity, git status, current task (if any), active tasks. @@ -40,10 +40,14 @@ This shows: developer identity, git status, current task (if any), active tasks.
### Step 3: Read Guidelines Index
```bash
cat .trellis/spec/frontend/index.md # Frontend guidelines
cat .trellis/spec/backend/index.md # Backend guidelines
cat .trellis/spec/guides/index.md # Thinking guides
cat .trellis/spec/unit-test/index.md # Testing guidelines
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`).

34
.claude/hooks/inject-subagent-context.py

@ -339,8 +339,7 @@ def get_check_context(repo_root: str, task_dir: str) -> str: @@ -339,8 +339,7 @@ def get_check_context(repo_root: str, task_dir: str) -> str:
check_files = [
(".claude/commands/trellis/finish-work.md", "Finish work checklist"),
(".claude/commands/trellis/check-cross-layer.md", "Cross-layer check spec"),
(".claude/commands/trellis/check-backend.md", "Backend check spec"),
(".claude/commands/trellis/check-frontend.md", "Frontend check spec"),
(".claude/commands/trellis/check.md", "Code quality check spec"),
]
for file_path, description in check_files:
content = read_file_content(repo_root, file_path)
@ -432,8 +431,7 @@ def get_debug_context(repo_root: str, task_dir: str) -> str: @@ -432,8 +431,7 @@ def get_debug_context(repo_root: str, task_dir: str) -> str:
context_parts.append(f"=== {file_path} (Dev spec) ===\n{content}")
check_files = [
(".claude/commands/trellis/check-backend.md", "Backend check spec"),
(".claude/commands/trellis/check-frontend.md", "Frontend check spec"),
(".claude/commands/trellis/check.md", "Code quality check spec"),
(".claude/commands/trellis/check-cross-layer.md", "Cross-layer check spec"),
]
for file_path, description in check_files:
@ -603,24 +601,34 @@ def get_research_context(repo_root: str, task_dir: str | None) -> str: @@ -603,24 +601,34 @@ def get_research_context(repo_root: str, task_dir: str | None) -> str:
"""
context_parts = []
# 1. Project structure overview (uses constants for paths)
# 1. Project structure overview (dynamically discover spec directories)
spec_path = f"{DIR_WORKFLOW}/{DIR_SPEC}"
spec_root = Path(repo_root) / DIR_WORKFLOW / DIR_SPEC
# Build spec tree dynamically
tree_lines = [f"{spec_path}/"]
if spec_root.is_dir():
pkg_dirs = sorted(d for d in spec_root.iterdir() if d.is_dir())
for i, pkg_dir in enumerate(pkg_dirs):
is_last = i == len(pkg_dirs) - 1
prefix = "└── " if is_last else "├── "
layers = sorted(d.name for d in pkg_dir.iterdir() if d.is_dir())
layer_info = f" ({', '.join(layers)})" if layers else ""
tree_lines.append(f"{prefix}{pkg_dir.name}/{layer_info}")
spec_tree = "\n".join(tree_lines)
project_structure = f"""## Project Spec Directory Structure
```
{spec_path}/
shared/ # Cross-project common specs (TypeScript, code quality, git)
frontend/ # Frontend standards
backend/ # Backend standards
guides/ # Thinking guides (cross-layer, code reuse, etc.)
{DIR_WORKFLOW}/big-question/ # Known issues and pitfalls
{spec_tree}
```
To get structured package info, run: `python3 ./{DIR_WORKFLOW}/scripts/get_context.py --mode packages`
## Search Tips
- Spec files: `{spec_path}/**/*.md`
- Known issues: `{DIR_WORKFLOW}/big-question/`
- Code search: Use Glob and Grep tools
- Tech solutions: Use mcp__exa__web_search_exa or mcp__exa__get_code_context_exa"""

172
.claude/hooks/session-start.py

@ -119,6 +119,151 @@ def _get_task_status(trellis_dir: Path) -> str: @@ -119,6 +119,151 @@ def _get_task_status(trellis_dir: Path) -> str:
return f"Status: READY\nTask: {task_title}\nNext: Continue with implement or check"
def _load_trellis_config(trellis_dir: Path) -> tuple:
"""Load Trellis config for session-start decisions.
Returns:
(is_mono, packages_dict, spec_scope, task_pkg, default_pkg)
"""
scripts_dir = trellis_dir / "scripts"
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
try:
from common.config import get_default_package, get_packages, get_spec_scope, is_monorepo # type: ignore[import-not-found]
from common.paths import get_current_task # type: ignore[import-not-found]
repo_root = trellis_dir.parent
is_mono = is_monorepo(repo_root)
packages = get_packages(repo_root) or {}
scope = get_spec_scope(repo_root)
# Get active task's package
task_pkg = None
current = get_current_task(repo_root)
if current:
task_json = repo_root / current / "task.json"
if task_json.is_file():
try:
data = json.loads(task_json.read_text(encoding="utf-8"))
if isinstance(data, dict):
tp = data.get("package")
if isinstance(tp, str) and tp:
task_pkg = tp
except (json.JSONDecodeError, OSError):
pass
default_pkg = get_default_package(repo_root)
return is_mono, packages, scope, task_pkg, default_pkg
except Exception:
return False, {}, None, None, None
def _check_legacy_spec(trellis_dir: Path, is_mono: bool, packages: dict) -> str | None:
"""Check for legacy spec directory structure in monorepo.
Returns warning message if legacy structure detected, None otherwise.
"""
if not is_mono or not packages:
return None
spec_dir = trellis_dir / "spec"
if not spec_dir.is_dir():
return None
# Check for legacy flat spec dirs (spec/backend/, spec/frontend/ with index.md)
has_legacy = False
for legacy_name in ("backend", "frontend"):
legacy_dir = spec_dir / legacy_name
if legacy_dir.is_dir() and (legacy_dir / "index.md").is_file():
has_legacy = True
break
if not has_legacy:
return None
# Check which packages are missing spec/<pkg>/ directory
missing = [
name for name in sorted(packages.keys())
if not (spec_dir / name).is_dir()
]
if not missing:
return None # All packages have spec dirs
if len(missing) == len(packages):
return (
f"[!] Legacy spec structure detected: found `spec/backend/` or `spec/frontend/` "
f"but no package-scoped `spec/<package>/` directories.\n"
f"Monorepo packages: {', '.join(sorted(packages.keys()))}\n"
f"Please reorganize: `spec/backend/` -> `spec/<package>/backend/`"
)
return (
f"[!] Partial spec migration detected: packages {', '.join(missing)} "
f"still missing `spec/<pkg>/` directory.\n"
f"Please complete migration for all packages."
)
def _resolve_spec_scope(
is_mono: bool,
packages: dict,
scope,
task_pkg: str | None,
default_pkg: str | None,
) -> set | None:
"""Resolve which packages should have their specs injected.
Returns:
Set of package names to include, or None for full scan.
"""
if not is_mono or not packages:
return None # Single-repo: full scan
if scope is None:
return None # No scope configured: full scan
if isinstance(scope, str) and 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 # Fallback to full scan
if isinstance(scope, list):
valid = set()
for entry in scope:
if entry in packages:
valid.add(entry)
else:
print(
f"Warning: spec_scope contains unknown package: {entry}, ignoring",
file=sys.stderr,
)
if valid:
# Warn if active task is out of scope
if task_pkg and task_pkg not in valid:
print(
f"Warning: active task package '{task_pkg}' is out of configured spec_scope",
file=sys.stderr,
)
return valid
# All entries invalid: fallback chain
print(
"Warning: all spec_scope entries invalid, falling back to task/default/full",
file=sys.stderr,
)
if task_pkg and task_pkg in packages:
return {task_pkg}
if default_pkg and default_pkg in packages:
return {default_pkg}
return None # Full scan
return None # Unknown scope type: full scan
def main():
if should_skip_injection():
sys.exit(0)
@ -127,6 +272,10 @@ def main(): @@ -127,6 +272,10 @@ def main():
trellis_dir = project_dir / ".trellis"
claude_dir = project_dir / ".claude"
# Load config for scope filtering and legacy detection
is_mono, packages, scope_config, task_pkg, default_pkg = _load_trellis_config(trellis_dir)
allowed_pkgs = _resolve_spec_scope(is_mono, packages, scope_config, task_pkg, default_pkg)
output = StringIO()
output.write("""<session-context>
@ -136,6 +285,11 @@ Read and follow all instructions below carefully. @@ -136,6 +285,11 @@ Read and follow all instructions below carefully.
""")
# Legacy migration warning
legacy_warning = _check_legacy_spec(trellis_dir, is_mono, packages)
if legacy_warning:
output.write(f"<migration-warning>\n{legacy_warning}\n</migration-warning>\n\n")
output.write("<current-state>\n")
context_script = trellis_dir / "scripts" / "get_context.py"
output.write(run_script(context_script))
@ -155,13 +309,27 @@ Read and follow all instructions below carefully. @@ -155,13 +309,27 @@ Read and follow all instructions below carefully.
for sub in sorted(spec_dir.iterdir()):
if not sub.is_dir() or sub.name.startswith("."):
continue
# Always include guides/ regardless of scope
if sub.name == "guides":
index_file = sub / "index.md"
if index_file.is_file():
output.write(f"## {sub.name}\n")
output.write(read_file(index_file))
output.write("\n\n")
continue
index_file = sub / "index.md"
if index_file.is_file():
# Flat spec dir (single-repo layer like spec/backend/)
output.write(f"## {sub.name}\n")
output.write(read_file(index_file))
output.write("\n\n")
else:
# Check for nested package dirs (monorepo: spec/<pkg>/<layer>/index.md)
# Nested package dirs (monorepo: spec/<pkg>/<layer>/index.md)
# Apply scope filter
if allowed_pkgs is not None and sub.name not in allowed_pkgs:
continue
for nested in sorted(sub.iterdir()):
if not nested.is_dir():
continue
@ -180,7 +348,7 @@ Read and follow all instructions below carefully. @@ -180,7 +348,7 @@ Read and follow all instructions below carefully.
output.write(start_md)
output.write("\n</instructions>\n\n")
# R2: Check task status and inject structured tag
# Check task status and inject structured tag
task_status = _get_task_status(trellis_dir)
output.write(f"<task-status>\n{task_status}\n</task-status>\n\n")

23
.codex/agents/check.toml

@ -0,0 +1,23 @@ @@ -0,0 +1,23 @@
name = "check"
description = "Read-only Trellis reviewer focused on correctness, missing tests, and spec drift."
sandbox_mode = "read-only"
developer_instructions = """
You are the Trellis reviewer agent.
Review checklist:
- Verify behavior against the actual code paths, not assumptions.
- Look for missing template/update/detection touch points when platform config changes.
- Check whether tests should be added or updated.
- Check whether `.trellis/spec/` docs need sync after implementation.
- Prefer concrete findings over speculative warnings.
Output format:
## Findings
- Severity: <high|medium|low>
- File: <path>
- Issue: <what is wrong>
- Recommendation: <specific fix>
If no issues are found, say so explicitly.
"""

19
.codex/agents/implement.toml

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
name = "implement"
description = "Workspace-write Trellis implementer that follows specs and keeps generated templates in sync."
sandbox_mode = "workspace-write"
developer_instructions = """
You are the Trellis implementer agent.
Rules:
- Read before write. Follow `.trellis/spec/` guidance relevant to the task.
- Keep changes focused on the requested scope.
- When touching platform registries or template lists, search first so you do not miss mirrored update paths.
- If you modify `.trellis/scripts/`, keep `packages/cli/src/templates/trellis/scripts/` in sync.
- Do not make destructive git changes unless explicitly asked.
Before finishing, summarize:
- Files changed
- Tests/checks run
- Remaining risks or follow-ups
"""

26
.codex/agents/research.toml

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
name = "research"
description = "Read-only Trellis researcher for specs, code patterns, and affected files."
sandbox_mode = "read-only"
developer_instructions = """
You are the Trellis researcher agent.
Responsibilities:
- Read `.trellis/workflow.md`, relevant `.trellis/spec/` files, and target code before proposing changes.
- Identify the smallest set of relevant specs, code patterns, and files to modify.
- Call out cross-layer or cross-platform risks when they are real.
- Do not edit files.
Output format:
## Relevant Specs
- <path>: <why>
## Code Patterns Found
- <pattern>: <file>
## Files to Modify
- <path>: <change>
## Risks / Follow-ups
- <none or concrete note>
"""

5
.codex/config.toml

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
# Project-scoped Codex defaults for Trellis workflows.
# Codex loads this after ~/.codex/config.toml when you work in this project.
# Keep AGENTS.md as the primary project instruction file.
project_doc_fallback_filenames = ["AGENTS.md"]

16
.codex/hooks.json

@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "python3 .codex/hooks/session-start.py",
"timeout": 15,
"statusMessage": "Loading Trellis context..."
}
]
}
]
}
}

204
.codex/hooks/session-start.py

@ -0,0 +1,204 @@ @@ -0,0 +1,204 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Codex Session Start Hook - Inject Trellis context into Codex sessions.
Output format follows Codex hook protocol:
stdout JSON { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: "..." } }
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import warnings
from io import StringIO
from pathlib import Path
warnings.filterwarnings("ignore")
def should_skip_injection() -> bool:
return os.environ.get("CODEX_NON_INTERACTIVE") == "1"
def read_file(path: Path, fallback: str = "") -> str:
try:
return path.read_text(encoding="utf-8")
except (FileNotFoundError, PermissionError):
return fallback
def run_script(script_path: Path) -> str:
try:
env = os.environ.copy()
env["PYTHONIOENCODING"] = "utf-8"
cmd = [sys.executable, "-W", "ignore", str(script_path)]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=5,
cwd=str(script_path.parent.parent.parent),
env=env,
)
return result.stdout if result.returncode == 0 else "No context available"
except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError):
return "No context available"
def _get_task_status(trellis_dir: Path) -> str:
current_task_file = trellis_dir / ".current-task"
if not current_task_file.is_file():
return "Status: NO ACTIVE TASK\nNext: Describe what you want to work on"
task_ref = current_task_file.read_text(encoding="utf-8").strip()
if not task_ref:
return "Status: NO ACTIVE TASK\nNext: Describe what you want to work on"
if Path(task_ref).is_absolute():
task_dir = Path(task_ref)
elif task_ref.startswith(".trellis/"):
task_dir = trellis_dir.parent / task_ref
else:
task_dir = trellis_dir / "tasks" / task_ref
if not task_dir.is_dir():
return f"Status: STALE POINTER\nTask: {task_ref}\nNext: Task directory not found. Run: python3 ./.trellis/scripts/task.py finish"
task_json_path = task_dir / "task.json"
task_data: dict = {}
if task_json_path.is_file():
try:
task_data = json.loads(task_json_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, PermissionError):
pass
task_title = task_data.get("title", task_ref)
task_status = task_data.get("status", "unknown")
if task_status == "completed":
return f"Status: COMPLETED\nTask: {task_title}\nNext: Archive with `python3 ./.trellis/scripts/task.py archive {task_dir.name}` or start a new task"
has_context = False
for jsonl_name in ("implement.jsonl", "check.jsonl", "spec.jsonl"):
jsonl_path = task_dir / jsonl_name
if jsonl_path.is_file() and jsonl_path.stat().st_size > 0:
has_context = True
break
has_prd = (task_dir / "prd.md").is_file()
if not has_prd:
return f"Status: NOT READY\nTask: {task_title}\nMissing: prd.md not created\nNext: Write PRD, then research → init-context → start"
if not has_context:
return f"Status: NOT READY\nTask: {task_title}\nMissing: Context not configured (no jsonl files)\nNext: Complete Phase 2 (research → init-context → start) before implementing"
return f"Status: READY\nTask: {task_title}\nNext: Continue with implement or check"
def main() -> None:
if should_skip_injection():
sys.exit(0)
# Read hook input from stdin
try:
hook_input = json.loads(sys.stdin.read())
project_dir = Path(hook_input.get("cwd", ".")).resolve()
except (json.JSONDecodeError, KeyError):
project_dir = Path(".").resolve()
trellis_dir = project_dir / ".trellis"
codex_dir = project_dir / ".codex"
output = StringIO()
output.write("""<session-context>
You are starting a new session in a Trellis-managed project.
Read and follow all instructions below carefully.
</session-context>
""")
output.write("<current-state>\n")
context_script = trellis_dir / "scripts" / "get_context.py"
output.write(run_script(context_script))
output.write("\n</current-state>\n\n")
output.write("<workflow>\n")
workflow_content = read_file(trellis_dir / "workflow.md", "No workflow.md found")
output.write(workflow_content)
output.write("\n</workflow>\n\n")
output.write("<guidelines>\n")
output.write("**Note**: The guidelines below are index files — they list available guideline documents and their locations.\n")
output.write("During actual development, you MUST read the specific guideline files listed in each index's Pre-Development Checklist.\n\n")
spec_dir = trellis_dir / "spec"
if spec_dir.is_dir():
for sub in sorted(spec_dir.iterdir()):
if not sub.is_dir() or sub.name.startswith("."):
continue
if sub.name == "guides":
index_file = sub / "index.md"
if index_file.is_file():
output.write(f"## {sub.name}\n")
output.write(read_file(index_file))
output.write("\n\n")
continue
index_file = sub / "index.md"
if index_file.is_file():
output.write(f"## {sub.name}\n")
output.write(read_file(index_file))
output.write("\n\n")
else:
for nested in sorted(sub.iterdir()):
if not nested.is_dir():
continue
nested_index = nested / "index.md"
if nested_index.is_file():
output.write(f"## {sub.name}/{nested.name}\n")
output.write(read_file(nested_index))
output.write("\n\n")
output.write("</guidelines>\n\n")
# Inject start skill as instructions (Codex uses skills, not slash commands)
start_skill = codex_dir / "skills" / "start" / "SKILL.md"
if not start_skill.is_file():
start_skill = project_dir / ".agents" / "skills" / "start" / "SKILL.md"
if start_skill.is_file():
output.write("<instructions>\n")
output.write(read_file(start_skill))
output.write("\n</instructions>\n\n")
task_status = _get_task_status(trellis_dir)
output.write(f"<task-status>\n{task_status}\n</task-status>\n\n")
output.write("""<ready>
Context loaded. Steps 1-3 (workflow, context, guidelines) are already injected above do NOT re-read them.
Start from Step 4. Wait for user's first message, then follow <instructions> to handle their request.
If there is an active task, ask whether to continue it.
</ready>""")
context = output.getvalue()
result = {
"suppressOutput": True,
"systemMessage": f"Trellis context injected ({len(context)} chars)",
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": context,
},
}
print(json.dumps(result, ensure_ascii=False), flush=True)
if __name__ == "__main__":
main()

194
.codex/skills/parallel/SKILL.md

@ -0,0 +1,194 @@ @@ -0,0 +1,194 @@
---
name: parallel
description: "Multi-agent pipeline orchestrator that plans and dispatches parallel development tasks to worktree agents. Reads project context, configures task directories with PRDs and jsonl context files, and launches isolated coding agents. Use when multiple independent features need parallel development, orchestrating worktree agents, or managing multi-agent coding pipelines."
---
# 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**: find specs, inspect code structure, and reduce ambiguity before dispatch
---
## Operation Types
Operations in this document are categorized as:
| Marker | Meaning | Executor |
|--------|---------|----------|
| `[AI]` | Bash scripts or tool calls executed by AI | You (AI) |
| `[USER]` | Skills 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 codex
```
Plan Agent will:
1. Evaluate requirement validity (may reject if unclear/too large)
2. Analyze the codebase and specs
3. Create and configure task directory
4. Write `prd.md` with acceptance criteria
5. Output a ready-to-use task directory
After `plan.py` completes, start the worktree agent:
```bash
python3 ./.trellis/scripts/multi_agent/start.py "$TASK_DIR" --platform codex
```
### Option B: Manual Configuration (For simple or already-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
TASK_DIR=$(python3 ./.trellis/scripts/task.py create "<title>" --slug <task-name>)
```
#### Step 2: Configure Task
```bash
python3 ./.trellis/scripts/task.py init-context "$TASK_DIR" <dev_type>
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
```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" << 'END_PRD'
# Feature: <name>
## Requirements
- ...
## Acceptance Criteria
- ...
END_PRD
```
#### Step 5: Validate and Start
```bash
python3 ./.trellis/scripts/task.py validate "$TASK_DIR"
python3 ./.trellis/scripts/multi_agent/start.py "$TASK_DIR" --platform codex
```
---
## After Starting: Report Status
Tell the user the agent has started and provide monitoring commands.
---
## User Available Skills `[USER]`
The following skills are for users (not AI):
| Skill | Description |
|-------|-------------|
| `$parallel` | Start Multi-Agent Pipeline (this skill) |
| `$start` | Start normal development mode (single process) |
| `$record-session` | Record session progress |
| `$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 the 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 worktrees
- **Don't execute git commit** - the flow handles it in the worktree pipeline
- **Delegate complex analysis before dispatch** - find specs, inspect code structure, and reduce ambiguity
- **Prefer focused tasks** - parallelism works best when each worktree has a narrow scope

104
.trellis/.template-hashes.json

@ -1,55 +1,51 @@ @@ -1,55 +1,51 @@
{
".trellis/config.yaml": "fe1fba0961e589c6f49190f5e19d4edb0d5bf894dba8468f06882c6e1c5e2aa1",
".trellis/config.yaml": "cb5216826e1091bfa0d4ebae257d6f43fa60ca2bafbf3d9a6489b2c1a51a99df",
".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c",
".trellis/scripts/add_session.py": "7c869be8146e6f675bd95e424909ff301ea0a8f8fd82a4f056f6d320e755a406",
".trellis/scripts/add_session.py": "db88f720ac7e5a4ab34f7a95ebb5ac02de2704cb8722b6b417f2555710873f64",
".trellis/scripts/common/__init__.py": "301724230abcce6e9fc99054c12d21c30eea7bc3b330ae6350aa3b6158461273",
".trellis/scripts/common/cli_adapter.py": "66ef4f75470807b531490a6b6928604eb59781148fe3c5412f39e132ffab0850",
".trellis/scripts/common/config.py": "909257b442d7d1e7a2596996622c4f2f010d8c1343e1efd088ef8615d99554c7",
".trellis/scripts/common/developer.py": "69f6145c4c48953677de3ba06f487ba2a1675f4d66153346ab40594bb06a01c9",
".trellis/scripts/common/git_context.py": "f154d358c858f7bcfc21a03c9b909af3a8dfa20be37b2c5012d84b8e0588b493",
".trellis/scripts/common/paths.py": "058f333fb80c71c90ddc131742e8e64949c2f1ed07c1254d8f7232506d891ffc",
".trellis/scripts/common/phase.py": "f9bdd553c7a278b97736b04c066ed06d8baa2ef179ed8219befcf6c27afcc9cd",
".trellis/scripts/common/registry.py": "6c65db45a487ef839b0a4b5b20abe201547269c20c7257254293a89dc01b56dc",
".trellis/scripts/common/task_queue.py": "6de22c7731465ee52d2b5cd4853b191d3cf869bf259fbc93079b426ba1c3756c",
".trellis/scripts/common/task_utils.py": "e19c290d90f9a779db161aeb9fefda27852847fbc67d358d471530b8ede64131",
".trellis/scripts/common/cli_adapter.py": "40fd7a2feeb65a3700e7123420903225bd3562425915e5fc4002b0109924c36e",
".trellis/scripts/common/config.py": "1244bccc9d3e1e1c304f914ea913f2f4a0b6d54571e6845633d586c2139a07bf",
".trellis/scripts/common/developer.py": "f5f833123abe68890171b4da825a324216d24913f6b5ad9245afc556424ffd7b",
".trellis/scripts/common/git_context.py": "5a01f7209c4a85ec768c88e2cb2a02a40ff279ea26139a390d16bf7576efce9f",
".trellis/scripts/common/paths.py": "b1ee9b609939cfe2b09a87ab71fc2821e3c97b42d0774eeea87159b73e266caa",
".trellis/scripts/common/phase.py": "412b7096ef0e48b8a95a79060121a586e0d9d44f1b350d6ed818c6f84330bb01",
".trellis/scripts/common/registry.py": "e1f4480ec264734eb4ef6867edc42fce2e2535045e5cff835fb81e0443d673e6",
".trellis/scripts/common/task_queue.py": "0be61f713462b1fe4574927c82fc4704e678afe72dcb9813543aedf2f9e9e0c5",
".trellis/scripts/common/task_utils.py": "b8ef61441f426398b63c3dd8b934b91343bd24e21b3728b3b8563befce46c1c6",
".trellis/scripts/common/worktree.py": "434880e02dfa2e92f0c717ed2a28e4cdee681ea10c329a2438d533bdbc612408",
".trellis/scripts/create_bootstrap.py": "aa5dd1f39a77b2f4bb827fd14ce7a83fb51870e77f556fe508afce3f8eac0b4e",
".trellis/scripts/create_bootstrap.py": "33b40df671ba7828fd8d3ba8c019823a8b03e938797b1cae218c55d6c7ebe57a",
".trellis/scripts/get_context.py": "ca5bf9e90bdb1d75d3de182b95f820f9d108ab28793d29097b24fd71315adcf5",
".trellis/scripts/get_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f",
".trellis/scripts/init_developer.py": "f9e6c0d882406e81c8cd6b1c5abb204b0befc0069ff89cf650cd536a80f8c60e",
".trellis/scripts/multi_agent/__init__.py": "af6fceb4d9a64da04be03ba0f5a6daf71066503eca832b8b58d8a7d4b2844fa4",
".trellis/scripts/multi_agent/cleanup.py": "db50c4fbb32261905a8278c2760b33029f187963cd4e448938e57f3db3facd6c",
".trellis/scripts/multi_agent/create_pr.py": "6a2423aba5720a2150c32349faa957cdc59c6bb96511e56c79ca08d92d69c666",
".trellis/scripts/multi_agent/plan.py": "242b870b7667f730c910d629f16d44d5d3fd0a58f6451d9003c175fb2e77cee5",
".trellis/scripts/multi_agent/start.py": "32ed1a13405b7c71881b2507a79e1a3733bc3fcedbc92fcee0d733ce00d759d0",
".trellis/scripts/multi_agent/status.py": "5fc46b6d605c69b6044967a6b33ffb0c9d6f99dd919374572ac614222864a811",
".trellis/scripts/task.py": "ecf52885a698dc93af67fd693825a2f71163ab86b5c2abe76d8aa2e2caa44372",
".trellis/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": "d27ead3963b9c8200f2f255fbe74b48d9b3333034fd5fa2d9f3997cfc5c11988",
".trellis/scripts/multi_agent/status.py": "06b3d2c5a9f7bea884962ace3b25113a3b01bf50dc12ad2f473e4f0c914fff7e",
".trellis/scripts/task.py": "4479c24b705d724bb8757f010ab36a96310c1ba856d07e7015f5afdb301ac38b",
".trellis/workflow.md": "9b6d6e8027bd2cf32d9efd7ef77d6524c59fcaa4ad6052f72d028a07a5fd69a7",
".trellis/worktree.yaml": "c57de79e40d5f748f099625ed4a17d5f0afbf25cac598aced0b3c964e7b7c226",
".claude/agents/check.md": "7c7400e7ea8bf3f3f879bfa028fd5b4d41673e0150d44c52292161ba33612812",
".claude/agents/debug.md": "94be0b1cfbae4c64caee4775ef504f43acfcd4a80427a26d6f680ceaddcbee24",
".claude/agents/dispatch.md": "20e699a87aeb0b046c51d8485e433190916c645e21db9a06f9e468272738347e",
".claude/agents/implement.md": "d537797d3fa510afdeaa365d43ef897a261e71c9144ef6986b8574be8d09055c",
".claude/agents/dispatch.md": "90446e5b2bce1bc416856eb728361e21452ada9fb1cd05b1b29cd1a660f34c38",
".claude/agents/implement.md": "f506be6291311a9846104e55c46659746027c70e841cb04dda89b4069ad6722d",
".claude/agents/plan.md": "d796f689b8b8945d1809679d0c74475f419325b30f36ef0c59b7fae73386e90b",
".claude/agents/research.md": "086ae23120151b3591089a4de20fd54e6ae2b89038f5903ee9a52269cd7ded6a",
".claude/commands/trellis/before-backend-dev.md": "7e35444de2a5779ef39944f17f566ea21d2ed7f4994246f4cfe6ebf9a11dd3e3",
".claude/commands/trellis/before-frontend-dev.md": "a6225f9d123dbd4a7aec822652030cae50be3f5b308297015e04d42b23a27b2a",
".claude/commands/trellis/brainstorm.md": "7c7731eda092275a5d87f2569a69584f3c39b544a126a76e727a1e9d250c4a65",
".claude/commands/trellis/break-loop.md": "ba4dd4022dde1e4bbcfc1cc99e6a118e51b9db95bd962d88f1c29d0c9c433112",
".claude/commands/trellis/check-backend.md": "4e81a28d681ea770f780df55a212fd504ce21ee49b44ba16023b74b5c243cef3",
".claude/commands/trellis/check-cross-layer.md": "b9ab24515ead84330d6634f6ad912ca3547db3a36139d62c5688161824097d60",
".claude/commands/trellis/check-frontend.md": "5e8e3b682032ba0dd6bb843dd4826fff0159f78a7084964ccb119c6cf98b3d91",
".claude/commands/trellis/create-command.md": "c2825c7941b4ef4a3f3365c4c807ff138096a39aece3d051776f3c11a4e4857d",
".claude/commands/trellis/create-command.md": "9faa6e68e28ecaa4077dc651eee2a656ef4f4d090da865c891b4b07194a53b90",
".claude/commands/trellis/finish-work.md": "cc92cad9e94ce1cc4f29e3de16a640db7e9176e3ecfc9c19a566153671ca2168",
".claude/commands/trellis/integrate-skill.md": "3940442485341832257c595ddfb45582e2d60e5a4716f2bd15b7bce0498b130a",
".claude/commands/trellis/onboard.md": "a5dbd5db094b13fd006ec856efa53a688e209bcdc3ed1680b63b15f1e3293ab4",
".claude/commands/trellis/parallel.md": "f4c81fe1a468be214caf362263b14b6a6f40935497363109148cb7b19e644738",
".claude/commands/trellis/record-session.md": "0c4f61283c2f262c1f9c900d9207309107497d4ac848cca86eb62bc5b7189fe7",
".claude/commands/trellis/start.md": "2d4259d8d146d32c7b6c33dda36c14da76e1c3f1be35b27dc18e5eb5551c9276",
".claude/commands/trellis/onboard.md": "cf9591fcddc412ff80772bf441c8d94d7724e6713fdf38a04a3348ab8949e64e",
".claude/commands/trellis/parallel.md": "d2b76e732e625d3d843f97bed96ab5c4b2308aad4b64a93fa1f85553f567e256",
".claude/commands/trellis/record-session.md": "33b5626fcf03a57578f46133b2a14c6bbe19c4ef29652af3f828f24f448f5926",
".claude/commands/trellis/start.md": "34ecead84912a4338575f8648a9d449f89dfb4d4725416c889dac03586f98800",
".claude/commands/trellis/update-spec.md": "ff4d5a0405a763e61936f5b9df175fd25ea20ec5c20fa999855020ab78a919b6",
".claude/hooks/inject-subagent-context.py": "75ce4cc175a00f9afa5fe1c80298e29521359ad90a66701c3c1166aa588f3080",
".claude/hooks/inject-subagent-context.py": "4459f1075f7e16a08303b05973a73e61bcfd0e4382b8ddc10a55daf93140a9aa",
".claude/hooks/ralph-loop.py": "a367a5dd4f605730cf8157c61658e848176ae480be19029126ff9bbd90a37712",
".claude/hooks/session-start.py": "5c048949cbf8ac58c7c26ef51cd90bf91454574425f2158f4778c200b8098f53",
".claude/hooks/session-start.py": "55d2d2a725f1e51c13523b0e00e540a730643c460d3302adf7e2ae032528524f",
".claude/settings.json": "fdb7fcf660961b4b52f22f08e91f942a193e1a3f5ebbca9cbba21a157d1c359d",
".claude/settings.local.json": "a89e0521d76861b91e66c070fe12e19c41b7b240fdeb744ed8fdd2b5dd04cedf",
".claude/skills/gitnexus/gitnexus-cli/SKILL.md": "e28f79d4ea2473efa0f197611a75bc25428fb4296aeeb54d1e31de2e39ccd8ad",
@ -58,17 +54,37 @@ @@ -58,17 +54,37 @@
".claude/skills/gitnexus/gitnexus-guide/SKILL.md": "40b047beeb9a7c5d47f17426b4061d2c2562c85c11288fa7f8da376d910e4f91",
".claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md": "a4d6e8003c4a822c380b0e81b2fa0d642612236c734a9d51834e362c4be52f33",
".claude/skills/gitnexus/gitnexus-refactoring/SKILL.md": "6aab994ee0266063245245483ee3280645aca66c0c5e56730ffad3f32ecbbc5c",
".agents/skills/before-backend-dev/SKILL.md": "4537ccee0071353beee636a052c01642a27a87b6b0a73e7bc872b2501547fa64",
".agents/skills/before-frontend-dev/SKILL.md": "679c1708a4d9fbad5214db299a38366581684a9383cf51a5d8ac21f890d6ba0d",
".agents/skills/brainstorm/SKILL.md": "0cabc8e663a871dee6c8bbf7f149fe10f83f39835e66ad0a8d0867049aacb6f8",
".agents/skills/break-loop/SKILL.md": "b19a47854ca66bde4ee03a30603480b4af2c131d5d81d752d1d28d2ef5131172",
".agents/skills/check-backend/SKILL.md": "9b312cfd7a07ed036769b387d84d642cd5e20f06b88e7b6a4626705fa8beb6fa",
".agents/skills/check-cross-layer/SKILL.md": "bc72df11d79a8ee809f45eae120c1cce91ab997541ce30d665af9978c83843f6",
".agents/skills/check-frontend/SKILL.md": "27b75f9eea472ed104f39a65bb78ae559cfe8730c85e0742e55fd575a4a2f854",
".agents/skills/create-command/SKILL.md": "5c24ca19c1cec64486f1a147e1dd4a37200270cbf3d0987dc6536f7de85a78f2",
".agents/skills/finish-work/SKILL.md": "f3f77e3902021bb7d95452a6072ae3f67993bf7b7d0e172e33756a633b654bf2",
".agents/skills/integrate-skill/SKILL.md": "47b7374345d8a31f9df07c5e8e875ca4fdc30d0cc45860d77df893250e2d97fc",
".agents/skills/onboard/SKILL.md": "1808f578d21eae3cbcf650d6aa4cf35ac42bf466df740b830593c9bda212d51a",
".agents/skills/record-session/SKILL.md": "ce27e953630a71ef989c5582790e9c8a600a2614ec668b674816c1daac73ce0a",
".agents/skills/start/SKILL.md": "788e517f9e57c4ce68497c1cefabd51faa8253c681be7965915f21e6de9c5886"
".agents/skills/brainstorm/SKILL.md": "2fc7fee5ce4da294a29d459368aad3dcbb2e4234a000f7d5a6d6c4c6aa659cee",
".agents/skills/break-loop/SKILL.md": "c947b0fc673b5bb1e129d6dbe5fe4ac0a3d2843856a476f45e2e16b4a91f08ad",
".agents/skills/check-cross-layer/SKILL.md": "4de11e856524f2cc5d4ff78aa85c286a553e82714c3c4506da5ad00d32d76324",
".agents/skills/create-command/SKILL.md": "ede895ad28e53c960736043f03e105dc95d0038f2965860d1c32de63dc77ba88",
".agents/skills/finish-work/SKILL.md": "4ebb0255d8eefc39e0aa7b8d2df298f367dfa7b854af7a1b206a0d6182192160",
".agents/skills/integrate-skill/SKILL.md": "2d4da52f3f09fb8b92011f2019ad9e28a20054d577c212c9ed6f2bf156b59d52",
".agents/skills/onboard/SKILL.md": "ec6db142f763c81a3273be45b5d7726f695c32aaa5404e90dbd6e40aec92fb98",
".agents/skills/record-session/SKILL.md": "6707f3df209a4064d1617ea92807265829a62a0343b3da9fdeac289187730626",
".agents/skills/start/SKILL.md": "8853e4ddc1681e043dec34be76c7c6fd961a3d52cbd0a2320225d72440425639",
".trellis/scripts/common/io.py": "6480b181f2bc505323b28ed7a66963d7b7edc96251e83b4c8e7a45907cc721c8",
".trellis/scripts/common/log.py": "471df6895cfac80f995edebbf9974f6b7440634b7a688f28b8331c868bc0f3cf",
".trellis/scripts/common/git.py": "e14817be7de122d3a106f509c2825aeb9669d962ba73ba241642d2931cfdf1d6",
".trellis/scripts/common/types.py": "d623ada858d3cb55093c5c95c9ce182b99b5865b7441742297542fad3b21cc06",
".trellis/scripts/common/tasks.py": "eeefae693dadec54c8945394e288e90ed1e8f79545dfb2d4934a431496f5229d",
".trellis/scripts/common/task_context.py": "8aad7107a7f86e7ddc381a7358364a6176c61d619ee2094cb698c24a0d5ab214",
".trellis/scripts/common/task_store.py": "3100f3d063310e9e8cf1145d50b7cfb7550ff0b1feaa3d47c471d37d5101a148",
".trellis/scripts/common/session_context.py": "2389eff1a66b172783fcb714a79385114d9b29746133a3e0db732c3b5cb23898",
".trellis/scripts/common/packages_context.py": "efe158d7c99c2268851d0216fbb08de22836e418a8dbeb73575b8cc249eed7b7",
".trellis/scripts/multi_agent/status_display.py": "d432446644b07dcbea7fd3aeba1d31ae42a9c664e91eebbdab503fbb698bccac",
".trellis/scripts/multi_agent/status_monitor.py": "11ba35180a568aa4d14ccaee81cc213ff3d5ab83025f264ac57ca70385f11f4c",
".claude/commands/trellis/before-dev.md": "dd926596f3139c12d42469fb5147ac90724e3a7baca5591384f4f4bbdd530b54",
".claude/commands/trellis/check.md": "8b0d20b425b6030d13ac5aa0c876c5ec97cf7aca9b050f574f07f281ad25bd06",
".agents/skills/before-dev/SKILL.md": "8ba9c9703b6f12647e6c27ed6b37eb9eea3f2f077e9e3bf216bd67563f0071d6",
".agents/skills/check/SKILL.md": "f286aa393f99ac92d00559ef3c70fbafc95631eb28ffda66c3a494aedfbedce5",
".agents/skills/improve-ut/SKILL.md": "b63988e1b7de101dedc79cf7acba53f8d4bbcc05750aab19bbe23c74ee2e693e",
".codex/skills/parallel/SKILL.md": "b4f963df475b818e26a9edea718c630b289cb137c11994d8395535de6ab0931c",
".codex/agents/check.toml": "bd7f208dd61fe7d15550171fa468bf3c0ecd0b84626d2441777ff7261ef021ad",
".codex/agents/implement.toml": "24e14420227467ec9b5df59cec97640563a59d6f0092fb7be179d8554547f39b",
".codex/agents/research.toml": "eb7633412637869df29dd5b853458e869cba527f07ff4558838adce491a8837e",
".codex/hooks/session-start.py": "ae5598c680146a200e337d891988b850173efabb6640d0e429093a71f66e0098",
".codex/hooks.json": "886075b7aef39969e1c34d372ca25ca4d09ce3ebc65f740818513abcf47ad25e",
".codex/config.toml": "7543da7e1773a0f26bc9178716b34154fdc5fefd820fb184e7cb169f1910cc99",
".agents/skills/update-spec/SKILL.md": "7ccf7d29ea26d28b0bd8af94ea5b8bfcddaddd9f2797f3becedbe5f3a0a9e9a7"
}

2
.trellis/.version

@ -1 +1 @@ @@ -1 +1 @@
0.3.10
0.4.0-beta.8

20
.trellis/config.yaml

@ -31,3 +31,23 @@ max_journal_lines: 2000 @@ -31,3 +31,23 @@ max_journal_lines: 2000
# - "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

117
.trellis/scripts/add_session.py

@ -4,8 +4,19 @@ @@ -4,8 +4,19 @@
Add a new session to journal file and update index.md.
Usage:
python3 add_session.py --title "Title" --commit "hash" --summary "Summary"
echo "content" | python3 add_session.py --title "Title" --commit "hash"
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
@ -20,11 +31,21 @@ from pathlib import Path @@ -20,11 +31,21 @@ 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.config import get_session_commit_message, get_max_journal_lines
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,
)
# =============================================================================
@ -123,7 +144,9 @@ def generate_session_content( @@ -123,7 +144,9 @@ def generate_session_content(
commit: str,
summary: str,
extra_content: str,
today: str
today: str,
package: str | None = None,
branch: str | None = None,
) -> str:
"""Generate session content."""
if commit and commit != "-":
@ -135,12 +158,15 @@ def generate_session_content( @@ -135,12 +158,15 @@ def generate_session_content(
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}
**Task**: {title}{package_line}{branch_line}
### Summary
@ -175,7 +201,8 @@ def update_index( @@ -175,7 +201,8 @@ def update_index(
commit: str,
new_session: int,
active_file: str,
today: str
today: str,
branch: str | None = None,
) -> bool:
"""Update index.md with new session info."""
# Format commit for display
@ -254,10 +281,25 @@ def update_index( @@ -254,10 +281,25 @@ def update_index(
continue
if in_session_history:
new_lines.append(line)
if re.match(r"^\|\s*-", line) and not header_written:
new_lines.append(f"| {new_session} | {today} | {title} | {commit_display} |")
# 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)
@ -305,6 +347,8 @@ def add_session( @@ -305,6 +347,8 @@ def add_session(
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()
@ -330,7 +374,8 @@ def add_session( @@ -330,7 +374,8 @@ def add_session(
new_session = current_session + 1
session_content = generate_session_content(
new_session, title, commit, summary, extra_content, today
new_session, title, commit, summary, extra_content, today, package,
branch,
)
content_lines = len(session_content.splitlines())
@ -367,7 +412,16 @@ def add_session( @@ -367,7 +412,16 @@ def add_session(
# 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):
if not update_index(
index_file,
dev_dir,
title,
commit,
new_session,
active_file,
today,
branch,
):
return 1
print("", file=sys.stderr)
@ -400,8 +454,12 @@ def main() -> int: @@ -400,8 +454,12 @@ def main() -> int:
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()
@ -410,12 +468,47 @@ def main() -> int: @@ -410,12 +468,47 @@ def main() -> int:
content_path = Path(args.content_file)
if content_path.is_file():
extra_content = content_path.read_text(encoding="utf-8")
elif not sys.stdin.isatty():
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,
)

150
.trellis/scripts/common/cli_adapter.py

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
"""
CLI Adapter for Multi-Platform Support.
Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, and Qoder interfaces.
Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, Qoder, and CodeBuddy interfaces.
Supported platforms:
- claude: Claude Code (default)
@ -14,6 +14,7 @@ Supported platforms: @@ -14,6 +14,7 @@ Supported platforms:
- gemini: Gemini CLI
- antigravity: Antigravity (workflow-based)
- qoder: Qoder
- codebuddy: CodeBuddy
Usage:
from common.cli_adapter import CLIAdapter
@ -43,6 +44,7 @@ Platform = Literal[ @@ -43,6 +44,7 @@ Platform = Literal[
"gemini",
"antigravity",
"qoder",
"codebuddy",
]
@ -87,7 +89,7 @@ class CLIAdapter: @@ -87,7 +89,7 @@ class CLIAdapter:
"""Get platform-specific config directory name.
Returns:
Directory name ('.claude', '.opencode', '.cursor', '.iflow', '.agents', '.kilocode', '.kiro', '.gemini', '.agent', or '.qoder')
Directory name ('.claude', '.opencode', '.cursor', '.iflow', '.codex', '.kilocode', '.kiro', '.gemini', '.agent', '.qoder', or '.codebuddy')
"""
if self.platform == "opencode":
return ".opencode"
@ -96,7 +98,7 @@ class CLIAdapter: @@ -96,7 +98,7 @@ class CLIAdapter:
elif self.platform == "iflow":
return ".iflow"
elif self.platform == "codex":
return ".agents"
return ".codex"
elif self.platform == "kilo":
return ".kilocode"
elif self.platform == "kiro":
@ -107,6 +109,8 @@ class CLIAdapter: @@ -107,6 +109,8 @@ class CLIAdapter:
return ".agent"
elif self.platform == "qoder":
return ".qoder"
elif self.platform == "codebuddy":
return ".codebuddy"
else:
return ".claude"
@ -117,7 +121,7 @@ class CLIAdapter: @@ -117,7 +121,7 @@ class CLIAdapter:
project_root: Project root directory
Returns:
Path to config directory (.claude, .opencode, .cursor, .iflow, .agents, .kilocode, .kiro, .gemini, .agent, or .qoder)
Path to config directory (.claude, .opencode, .cursor, .iflow, .codex, .kilocode, .kiro, .gemini, .agent, .qoder, or .codebuddy)
"""
return project_root / self.config_dir_name
@ -129,9 +133,11 @@ class CLIAdapter: @@ -129,9 +133,11 @@ class CLIAdapter:
project_root: Project root directory
Returns:
Path to agent .md file
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:
@ -175,7 +181,7 @@ class CLIAdapter: @@ -175,7 +181,7 @@ class CLIAdapter:
"""Get relative path to a trellis command file.
Args:
name: Command name without extension (e.g., 'finish-work', 'check-backend')
name: Command name without extension (e.g., 'finish-work', 'check')
Returns:
Relative path string for use in JSONL entries
@ -227,6 +233,8 @@ class CLIAdapter: @@ -227,6 +233,8 @@ class CLIAdapter:
return {}
elif self.platform == "qoder":
return {}
elif self.platform == "codebuddy":
return {}
else:
return {"CLAUDE_NON_INTERACTIVE": "1"}
@ -278,12 +286,8 @@ class CLIAdapter: @@ -278,12 +286,8 @@ class CLIAdapter:
cmd.append(prompt)
elif self.platform == "iflow":
cmd = ["iflow", "-p"]
cmd.extend(["-y", "--agent", mapped_agent])
# iFlow doesn't support --session-id on creation
if verbose:
cmd.append("--verbose")
cmd.append(prompt)
cmd = ["iflow", "-y", "-p"]
cmd.append(f"${mapped_agent} {prompt}")
elif self.platform == "codex":
cmd = ["codex", "exec"]
cmd.append(prompt)
@ -298,6 +302,10 @@ class CLIAdapter: @@ -298,6 +302,10 @@ class CLIAdapter:
)
elif self.platform == "qoder":
cmd = ["qodercli", "-p", prompt]
elif self.platform == "codebuddy":
raise ValueError(
"CodeBuddy does not support non-interactive mode (no CLI agent)"
)
else: # claude
cmd = ["claude", "-p"]
@ -346,6 +354,10 @@ class CLIAdapter: @@ -346,6 +354,10 @@ class CLIAdapter:
)
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)"
)
else:
return ["claude", "--resume", session_id]
@ -410,6 +422,8 @@ class CLIAdapter: @@ -410,6 +422,8 @@ class CLIAdapter:
return "agy"
elif self.platform == "qoder":
return "qodercli"
elif self.platform == "codebuddy":
return "codebuddy"
else:
return "claude"
@ -417,9 +431,18 @@ class CLIAdapter: @@ -417,9 +431,18 @@ class CLIAdapter:
def supports_cli_agents(self) -> bool:
"""Check if platform supports running agents via CLI.
Claude Code, OpenCode, and iFlow support CLI agent execution.
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")
# =========================================================================
@ -465,7 +488,7 @@ def get_cli_adapter(platform: str = "claude") -> CLIAdapter: @@ -465,7 +488,7 @@ def get_cli_adapter(platform: str = "claude") -> CLIAdapter:
"""Get CLI adapter for the specified platform.
Args:
platform: Platform name ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', or 'qoder')
platform: Platform name ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'qoder', or 'codebuddy')
Returns:
CLIAdapter instance
@ -484,14 +507,41 @@ def get_cli_adapter(platform: str = "claude") -> CLIAdapter: @@ -484,14 +507,41 @@ def get_cli_adapter(platform: str = "claude") -> CLIAdapter:
"gemini",
"antigravity",
"qoder",
"codebuddy",
):
raise ValueError(
f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', or 'qoder')"
f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'qoder', or 'codebuddy')"
)
return CLIAdapter(platform=platform) # type: ignore
_ALL_PLATFORM_CONFIG_DIRS = (
".claude",
".cursor",
".iflow",
".opencode",
".agents",
".codex",
".kilocode",
".kiro",
".gemini",
".agent",
".qoder",
".codebuddy",
)
"""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.
@ -500,19 +550,20 @@ def detect_platform(project_root: Path) -> Platform: @@ -500,19 +550,20 @@ def detect_platform(project_root: Path) -> Platform:
2. .opencode directory exists opencode
3. .iflow directory exists iflow
4. .cursor directory exists (without .claude) cursor
5. .agents/skills exists and no other platform dirs codex
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. .qoder directory exists qoder
11. Default claude
10. .codebuddy directory exists codebuddy
11. .qoder directory exists qoder
12. Default claude
Args:
project_root: Project root directory
Returns:
Detected platform ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', or 'qoder')
Detected platform ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'qoder', 'codebuddy', or default 'claude')
"""
import os
@ -529,16 +580,15 @@ def detect_platform(project_root: Path) -> Platform: @@ -529,16 +580,15 @@ def detect_platform(project_root: Path) -> Platform:
"gemini",
"antigravity",
"qoder",
"codebuddy",
):
return env_platform # type: ignore
# Check for .opencode directory (OpenCode-specific)
# Note: .claude might exist in both platforms during migration
if (project_root / ".opencode").is_dir():
return "opencode"
# Check for .iflow directory (iFlow-specific)
# Note: .claude might exist in both platforms during migration
if (project_root / ".iflow").is_dir():
return "iflow"
@ -551,21 +601,11 @@ def detect_platform(project_root: Path) -> Platform: @@ -551,21 +601,11 @@ def detect_platform(project_root: Path) -> Platform:
if (project_root / ".gemini").is_dir():
return "gemini"
# Check for Codex skills directory only when no other platform config exists
other_platform_dirs_codex = (
".claude",
".cursor",
".iflow",
".opencode",
".kilocode",
".kiro",
".gemini",
".agent",
)
has_other_platform_config = any(
(project_root / directory).is_dir() for directory in other_platform_dirs_codex
)
if (project_root / ".agents" / "skills").is_dir() and not has_other_platform_config:
# 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)
@ -573,41 +613,23 @@ def detect_platform(project_root: Path) -> Platform: @@ -573,41 +613,23 @@ def detect_platform(project_root: Path) -> Platform:
return "kilo"
# Check for Kiro skills directory only when no other platform config exists
other_platform_dirs_kiro = (
".claude",
".cursor",
".iflow",
".opencode",
".agents",
".kilocode",
".gemini",
".agent",
)
has_other_platform_config = any(
(project_root / directory).is_dir() for directory in other_platform_dirs_kiro
)
if (project_root / ".kiro" / "skills").is_dir() and not has_other_platform_config:
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
other_platform_dirs_antigravity = (
".claude",
".cursor",
".iflow",
".opencode",
".agents",
".kilocode",
".kiro",
)
has_other_platform_config = any(
(project_root / directory).is_dir()
for directory in other_platform_dirs_antigravity
)
if (
project_root / ".agent" / "workflows"
).is_dir() and not has_other_platform_config:
).is_dir() and not _has_other_platform_dir(
project_root, {".agent", ".gemini"}
):
return "antigravity"
# 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"

192
.trellis/scripts/common/config.py

@ -7,6 +7,7 @@ Reads settings from .trellis/config.yaml with sensible defaults. @@ -7,6 +7,7 @@ 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
@ -20,6 +21,15 @@ DEFAULT_MAX_JOURNAL_LINES = 2000 @@ -20,6 +21,15 @@ 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()
@ -70,3 +80,185 @@ def get_hooks(event: str, repo_root: Path | None = None) -> list[str]: @@ -70,3 +80,185 @@ def get_hooks(event: str, repo_root: Path | None = None) -> list[str]:
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

4
.trellis/scripts/common/developer.py

@ -123,8 +123,8 @@ def init_developer(name: str, repo_root: Path | None = None) -> bool: @@ -123,8 +123,8 @@ def init_developer(name: str, repo_root: Path | None = None) -> bool:
## Session History
<!-- @@@auto:session-history -->
| # | Date | Title | Commits |
|---|------|-------|---------|
| # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------|
<!-- @@@/auto:session-history -->
---

31
.trellis/scripts/common/git.py

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

609
.trellis/scripts/common/git_context.py

@ -3,6 +3,8 @@ @@ -3,6 +3,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
@ -11,599 +13,29 @@ Provides: @@ -11,599 +13,29 @@ Provides:
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from .paths import (
DIR_SCRIPTS,
DIR_SPEC,
DIR_TASKS,
DIR_WORKFLOW,
DIR_WORKSPACE,
FILE_TASK_JSON,
count_lines,
get_active_journal_file,
get_current_task,
get_developer,
get_repo_root,
get_tasks_dir,
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,
)
# =============================================================================
# Helper Functions
# =============================================================================
def _run_git_command(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]:
"""Run a git command and return (returncode, stdout, stderr).
Uses UTF-8 encoding with -c i18n.logOutputEncoding=UTF-8 to ensure
consistent output across all platforms (Windows, macOS, Linux).
"""
try:
# Force git to output UTF-8 for consistent cross-platform behavior
git_args = ["git", "-c", "i18n.logOutputEncoding=UTF-8"] + args
result = subprocess.run(
git_args,
cwd=cwd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
return result.returncode, result.stdout, result.stderr
except Exception as e:
return 1, "", str(e)
def _read_json_file(path: Path) -> dict | None:
"""Read and parse a JSON file."""
try:
return json.loads(path.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
# =============================================================================
# JSON Output
# =============================================================================
def get_context_json(repo_root: Path | None = None) -> dict:
"""Get context as a dictionary.
Args:
repo_root: Repository root path. Defaults to auto-detected.
Returns:
Context dictionary.
"""
if repo_root is None:
repo_root = get_repo_root()
developer = get_developer(repo_root)
tasks_dir = get_tasks_dir(repo_root)
journal_file = get_active_journal_file(repo_root)
journal_lines = 0
journal_relative = ""
if journal_file and developer:
journal_lines = count_lines(journal_file)
journal_relative = (
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}"
)
# Git info
_, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root)
branch = branch_out.strip() or "unknown"
_, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root)
git_status_count = len([line for line in status_out.splitlines() if line.strip()])
is_clean = git_status_count == 0
# Recent commits
_, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root)
commits = []
for line in log_out.splitlines():
if line.strip():
parts = line.split(" ", 1)
if len(parts) >= 2:
commits.append({"hash": parts[0], "message": parts[1]})
elif len(parts) == 1:
commits.append({"hash": parts[0], "message": ""})
# Tasks
tasks = []
if tasks_dir.is_dir():
for d in tasks_dir.iterdir():
if d.is_dir() and d.name != "archive":
task_json_path = d / FILE_TASK_JSON
if task_json_path.is_file():
data = _read_json_file(task_json_path)
if data:
tasks.append(
{
"dir": d.name,
"name": data.get("name") or data.get("id") or "unknown",
"status": data.get("status", "unknown"),
"children": data.get("children", []),
"parent": data.get("parent"),
}
)
return {
"developer": developer or "",
"git": {
"branch": branch,
"isClean": is_clean,
"uncommittedChanges": git_status_count,
"recentCommits": commits,
},
"tasks": {
"active": tasks,
"directory": f"{DIR_WORKFLOW}/{DIR_TASKS}",
},
"journal": {
"file": journal_relative,
"lines": journal_lines,
"nearLimit": journal_lines > 1800,
},
}
def output_json(repo_root: Path | None = None) -> None:
"""Output context in JSON format.
Args:
repo_root: Repository root path. Defaults to auto-detected.
"""
context = get_context_json(repo_root)
print(json.dumps(context, indent=2, ensure_ascii=False))
# =============================================================================
# Text Output
# =============================================================================
def get_context_text(repo_root: Path | None = None) -> str:
"""Get context as formatted text.
Args:
repo_root: Repository root path. Defaults to auto-detected.
Returns:
Formatted text output.
"""
if repo_root is None:
repo_root = get_repo_root()
lines = []
lines.append("========================================")
lines.append("SESSION CONTEXT")
lines.append("========================================")
lines.append("")
developer = get_developer(repo_root)
# Developer section
lines.append("## DEVELOPER")
if not developer:
lines.append(
f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>"
)
return "\n".join(lines)
lines.append(f"Name: {developer}")
lines.append("")
# Git status
lines.append("## GIT STATUS")
_, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root)
branch = branch_out.strip() or "unknown"
lines.append(f"Branch: {branch}")
_, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root)
status_lines = [line for line in status_out.splitlines() if line.strip()]
status_count = len(status_lines)
if status_count == 0:
lines.append("Working directory: Clean")
else:
lines.append(f"Working directory: {status_count} uncommitted change(s)")
lines.append("")
lines.append("Changes:")
_, short_out, _ = _run_git_command(["status", "--short"], cwd=repo_root)
for line in short_out.splitlines()[:10]:
lines.append(line)
lines.append("")
# Recent commits
lines.append("## RECENT COMMITS")
_, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root)
if log_out.strip():
for line in log_out.splitlines():
lines.append(line)
else:
lines.append("(no commits)")
lines.append("")
# Current task
lines.append("## CURRENT TASK")
current_task = get_current_task(repo_root)
if current_task:
current_task_dir = repo_root / current_task
task_json_path = current_task_dir / FILE_TASK_JSON
lines.append(f"Path: {current_task}")
if task_json_path.is_file():
data = _read_json_file(task_json_path)
if data:
t_name = data.get("name") or data.get("id") or "unknown"
t_status = data.get("status", "unknown")
t_created = data.get("createdAt", "unknown")
t_desc = data.get("description", "")
lines.append(f"Name: {t_name}")
lines.append(f"Status: {t_status}")
lines.append(f"Created: {t_created}")
if t_desc:
lines.append(f"Description: {t_desc}")
# Check for prd.md
prd_file = current_task_dir / "prd.md"
if prd_file.is_file():
lines.append("")
lines.append("[!] This task has prd.md - read it for task details")
else:
lines.append("(none)")
lines.append("")
# Active tasks
lines.append("## ACTIVE TASKS")
tasks_dir = get_tasks_dir(repo_root)
task_count = 0
# Collect all task data for hierarchy display
all_task_data: dict[str, dict] = {}
if tasks_dir.is_dir():
for d in sorted(tasks_dir.iterdir()):
if d.is_dir() and d.name != "archive":
dir_name = d.name
t_json = d / FILE_TASK_JSON
status = "unknown"
assignee = "-"
children: list[str] = []
parent: str | None = None
if t_json.is_file():
data = _read_json_file(t_json)
if data:
status = data.get("status", "unknown")
assignee = data.get("assignee", "-")
children = data.get("children", [])
parent = data.get("parent")
all_task_data[dir_name] = {
"status": status,
"assignee": assignee,
"children": children,
"parent": parent,
}
def _children_progress(children_list: list[str]) -> str:
if not children_list:
return ""
done = 0
for c in children_list:
if c in all_task_data and all_task_data[c]["status"] in ("completed", "done"):
done += 1
return f" [{done}/{len(children_list)} done]"
def _print_task_tree(name: str, indent: int = 0) -> None:
nonlocal task_count
info = all_task_data[name]
progress = _children_progress(info["children"]) if info["children"] else ""
prefix = " " * indent
lines.append(f"{prefix}- {name}/ ({info['status']}){progress} @{info['assignee']}")
task_count += 1
for child in info["children"]:
if child in all_task_data:
_print_task_tree(child, indent + 1)
for dir_name in sorted(all_task_data.keys()):
if not all_task_data[dir_name]["parent"]:
_print_task_tree(dir_name)
if task_count == 0:
lines.append("(no active tasks)")
lines.append(f"Total: {task_count} active task(s)")
lines.append("")
# My tasks
lines.append("## MY TASKS (Assigned to me)")
my_task_count = 0
if tasks_dir.is_dir():
for d in sorted(tasks_dir.iterdir()):
if d.is_dir() and d.name != "archive":
t_json = d / FILE_TASK_JSON
if t_json.is_file():
data = _read_json_file(t_json)
if data:
assignee = data.get("assignee", "")
status = data.get("status", "planning")
if assignee == developer and status != "done":
title = data.get("title") or data.get("name") or "unknown"
priority = data.get("priority", "P2")
children_list = data.get("children", [])
progress = _children_progress(children_list) if children_list else ""
lines.append(f"- [{priority}] {title} ({status}){progress}")
my_task_count += 1
if my_task_count == 0:
lines.append("(no tasks assigned to you)")
lines.append("")
# Journal file
lines.append("## JOURNAL FILE")
journal_file = get_active_journal_file(repo_root)
if journal_file:
journal_lines = count_lines(journal_file)
relative = f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}"
lines.append(f"Active file: {relative}")
lines.append(f"Line count: {journal_lines} / 2000")
if journal_lines > 1800:
lines.append("[!] WARNING: Approaching 2000 line limit!")
else:
lines.append("No journal file found")
lines.append("")
# Paths
lines.append("## PATHS")
lines.append(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/")
lines.append(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/")
lines.append(f"Spec: {DIR_WORKFLOW}/{DIR_SPEC}/")
lines.append("")
lines.append("========================================")
return "\n".join(lines)
def get_context_record_json(repo_root: Path | None = None) -> dict:
"""Get record-mode context as a dictionary.
Focused on: my active tasks, git status, current task.
"""
if repo_root is None:
repo_root = get_repo_root()
developer = get_developer(repo_root)
tasks_dir = get_tasks_dir(repo_root)
# Git info
_, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root)
branch = branch_out.strip() or "unknown"
_, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root)
git_status_count = len([line for line in status_out.splitlines() if line.strip()])
_, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root)
commits = []
for line in log_out.splitlines():
if line.strip():
parts = line.split(" ", 1)
if len(parts) >= 2:
commits.append({"hash": parts[0], "message": parts[1]})
# My tasks
my_tasks = []
all_task_statuses: dict[str, str] = {}
if tasks_dir.is_dir():
for d in sorted(tasks_dir.iterdir()):
if d.is_dir() and d.name != "archive":
t_json = d / FILE_TASK_JSON
if t_json.is_file():
data = _read_json_file(t_json)
if data:
all_task_statuses[d.name] = data.get("status", "unknown")
if tasks_dir.is_dir():
for d in sorted(tasks_dir.iterdir()):
if d.is_dir() and d.name != "archive":
t_json = d / FILE_TASK_JSON
if t_json.is_file():
data = _read_json_file(t_json)
if data and data.get("assignee") == developer:
children_list = data.get("children", [])
done = sum(1 for c in children_list if all_task_statuses.get(c) in ("completed", "done"))
my_tasks.append({
"dir": d.name,
"title": data.get("title") or data.get("name") or "unknown",
"status": data.get("status", "unknown"),
"priority": data.get("priority", "P2"),
"children": children_list,
"childrenDone": done,
"parent": data.get("parent"),
"meta": data.get("meta", {}),
})
# Current task
current_task_info = None
current_task = get_current_task(repo_root)
if current_task:
task_json_path = (repo_root / current_task) / FILE_TASK_JSON
if task_json_path.is_file():
data = _read_json_file(task_json_path)
if data:
current_task_info = {
"path": current_task,
"name": data.get("name") or data.get("id") or "unknown",
"status": data.get("status", "unknown"),
}
return {
"developer": developer or "",
"git": {
"branch": branch,
"isClean": git_status_count == 0,
"uncommittedChanges": git_status_count,
"recentCommits": commits,
},
"myTasks": my_tasks,
"currentTask": current_task_info,
}
def get_context_text_record(repo_root: Path | None = None) -> str:
"""Get context as formatted text for record-session mode.
Focused output: MY ACTIVE TASKS first (with [!!!] emphasis),
then GIT STATUS, RECENT COMMITS, CURRENT TASK.
Args:
repo_root: Repository root path. Defaults to auto-detected.
Returns:
Formatted text output for record-session.
"""
if repo_root is None:
repo_root = get_repo_root()
lines: list[str] = []
lines.append("========================================")
lines.append("SESSION CONTEXT (RECORD MODE)")
lines.append("========================================")
lines.append("")
developer = get_developer(repo_root)
if not developer:
lines.append(
f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>"
)
return "\n".join(lines)
# MY ACTIVE TASKS — first and prominent
lines.append(f"## [!!!] MY ACTIVE TASKS (Assigned to {developer})")
lines.append("[!] Review whether any should be archived before recording this session.")
lines.append("")
tasks_dir = get_tasks_dir(repo_root)
my_task_count = 0
# Collect task data for children progress
all_task_statuses: dict[str, str] = {}
if tasks_dir.is_dir():
for d in sorted(tasks_dir.iterdir()):
if d.is_dir() and d.name != "archive":
t_json = d / FILE_TASK_JSON
if t_json.is_file():
data = _read_json_file(t_json)
if data:
all_task_statuses[d.name] = data.get("status", "unknown")
def _record_children_progress(children_list: list[str]) -> str:
if not children_list:
return ""
done = 0
for c in children_list:
if all_task_statuses.get(c) in ("completed", "done"):
done += 1
return f" [{done}/{len(children_list)} done]"
if tasks_dir.is_dir():
for d in sorted(tasks_dir.iterdir()):
if d.is_dir() and d.name != "archive":
t_json = d / FILE_TASK_JSON
if t_json.is_file():
data = _read_json_file(t_json)
if data:
assignee = data.get("assignee", "")
status = data.get("status", "planning")
if assignee == developer:
title = data.get("title") or data.get("name") or "unknown"
priority = data.get("priority", "P2")
children_list = data.get("children", [])
progress = _record_children_progress(children_list) if children_list else ""
lines.append(f"- [{priority}] {title} ({status}){progress}{d.name}")
my_task_count += 1
if my_task_count == 0:
lines.append("(no active tasks assigned to you)")
lines.append("")
# GIT STATUS
lines.append("## GIT STATUS")
_, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root)
branch = branch_out.strip() or "unknown"
lines.append(f"Branch: {branch}")
_, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root)
status_lines = [line for line in status_out.splitlines() if line.strip()]
status_count = len(status_lines)
if status_count == 0:
lines.append("Working directory: Clean")
else:
lines.append(f"Working directory: {status_count} uncommitted change(s)")
lines.append("")
lines.append("Changes:")
_, short_out, _ = _run_git_command(["status", "--short"], cwd=repo_root)
for line in short_out.splitlines()[:10]:
lines.append(line)
lines.append("")
# RECENT COMMITS
lines.append("## RECENT COMMITS")
_, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root)
if log_out.strip():
for line in log_out.splitlines():
lines.append(line)
else:
lines.append("(no commits)")
lines.append("")
# CURRENT TASK
lines.append("## CURRENT TASK")
current_task = get_current_task(repo_root)
if current_task:
current_task_dir = repo_root / current_task
task_json_path = current_task_dir / FILE_TASK_JSON
lines.append(f"Path: {current_task}")
if task_json_path.is_file():
data = _read_json_file(task_json_path)
if data:
t_name = data.get("name") or data.get("id") or "unknown"
t_status = data.get("status", "unknown")
lines.append(f"Name: {t_name}")
lines.append(f"Status: {t_status}")
else:
lines.append("(none)")
lines.append("")
lines.append("========================================")
return "\n".join(lines)
def output_text(repo_root: Path | None = None) -> None:
"""Output context in text format.
Args:
repo_root: Repository root path. Defaults to auto-detected.
"""
print(get_context_text(repo_root))
# Backward-compatible alias — external modules import this name
_run_git_command = run_git
# =============================================================================
# Main Entry
# =============================================================================
def main() -> None:
"""CLI entry point."""
import argparse
@ -618,9 +50,9 @@ def main() -> None: @@ -618,9 +50,9 @@ def main() -> None:
parser.add_argument(
"--mode",
"-m",
choices=["default", "record"],
choices=["default", "record", "packages"],
default="default",
help="Output mode: default (full context) or record (for record-session)",
help="Output mode: default (full context), record (for record-session), packages (package info only)",
)
args = parser.parse_args()
@ -630,6 +62,11 @@ def main() -> None: @@ -630,6 +62,11 @@ def main() -> None:
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()

37
.trellis/scripts/common/io.py

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

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

238
.trellis/scripts/common/packages_context.py

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

46
.trellis/scripts/common/paths.py

@ -333,6 +333,52 @@ def generate_task_date_prefix() -> str: @@ -333,6 +333,52 @@ def generate_task_date_prefix() -> str:
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)
# =============================================================================

99
.trellis/scripts/common/phase.py

@ -19,25 +19,39 @@ Provides: @@ -19,25 +19,39 @@ Provides:
from __future__ import annotations
import json
from pathlib import Path
from .io import read_json, write_json
def _read_json_file(path: Path) -> dict | None:
"""Read and parse a JSON file."""
try:
return json.loads(path.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
# =============================================================================
# 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 _write_json_file(path: Path, data: dict) -> bool:
"""Write dict to JSON file."""
try:
path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
return True
except (OSError, IOError):
return False
def _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
# =============================================================================
@ -53,7 +67,7 @@ def get_current_phase(task_json: Path) -> int: @@ -53,7 +67,7 @@ def get_current_phase(task_json: Path) -> int:
Returns:
Current phase number, or 0 if not found.
"""
data = _read_json_file(task_json)
data = read_json(task_json)
if not data:
return 0
return data.get("current_phase", 0) or 0
@ -68,14 +82,10 @@ def get_total_phases(task_json: Path) -> int: @@ -68,14 +82,10 @@ def get_total_phases(task_json: Path) -> int:
Returns:
Total phase count, or 0 if not found.
"""
data = _read_json_file(task_json)
data = read_json(task_json)
if not data:
return 0
next_action = data.get("next_action", [])
if isinstance(next_action, list):
return len(next_action)
return 0
return _total_phases(data)
def get_phase_action(task_json: Path, phase: int) -> str:
@ -88,16 +98,10 @@ def get_phase_action(task_json: Path, phase: int) -> str: @@ -88,16 +98,10 @@ def get_phase_action(task_json: Path, phase: int) -> str:
Returns:
Action name, or "unknown" if not found.
"""
data = _read_json_file(task_json)
data = read_json(task_json)
if not data:
return "unknown"
next_action = data.get("next_action", [])
if isinstance(next_action, list):
for item in next_action:
if isinstance(item, dict) and item.get("phase") == phase:
return item.get("action", "unknown")
return "unknown"
return _phase_action(data, phase)
def get_phase_info(task_json: Path) -> str:
@ -109,18 +113,18 @@ def get_phase_info(task_json: Path) -> str: @@ -109,18 +113,18 @@ def get_phase_info(task_json: Path) -> str:
Returns:
Formatted string like "1/4 (implement)".
"""
data = _read_json_file(task_json)
data = read_json(task_json)
if not data:
return "N/A"
current_phase = data.get("current_phase", 0) or 0
total_phases = get_total_phases(task_json)
action_name = get_phase_action(task_json, current_phase)
total = _total_phases(data)
action_name = _phase_action(data, current_phase)
if current_phase == 0 or current_phase is None:
return f"0/{total_phases} (pending)"
return f"0/{total} (pending)"
else:
return f"{current_phase}/{total_phases} ({action_name})"
return f"{current_phase}/{total} ({action_name})"
def set_phase(task_json: Path, phase: int) -> bool:
@ -133,12 +137,12 @@ def set_phase(task_json: Path, phase: int) -> bool: @@ -133,12 +137,12 @@ def set_phase(task_json: Path, phase: int) -> bool:
Returns:
True on success, False on error.
"""
data = _read_json_file(task_json)
data = read_json(task_json)
if not data:
return False
data["current_phase"] = phase
return _write_json_file(task_json, data)
return write_json(task_json, data)
def advance_phase(task_json: Path) -> bool:
@ -150,19 +154,19 @@ def advance_phase(task_json: Path) -> bool: @@ -150,19 +154,19 @@ def advance_phase(task_json: Path) -> bool:
Returns:
True on success, False on error or at final phase.
"""
data = _read_json_file(task_json)
data = read_json(task_json)
if not data:
return False
current = data.get("current_phase", 0) or 0
total = get_total_phases(task_json)
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_file(task_json, data)
return write_json(task_json, data)
def get_phase_for_action(task_json: Path, action: str) -> int:
@ -175,16 +179,10 @@ def get_phase_for_action(task_json: Path, action: str) -> int: @@ -175,16 +179,10 @@ def get_phase_for_action(task_json: Path, action: str) -> int:
Returns:
Phase number, or 0 if not found.
"""
data = _read_json_file(task_json)
data = read_json(task_json)
if not data:
return 0
next_action = data.get("next_action", [])
if isinstance(next_action, list):
for item in next_action:
if isinstance(item, dict) and item.get("action") == action:
return item.get("phase", 0)
return 0
return _phase_for_action(data, action)
def map_subagent_to_action(subagent_type: str) -> str:
@ -231,8 +229,11 @@ def is_current_action(task_json: Path, action: str) -> bool: @@ -231,8 +229,11 @@ def is_current_action(task_json: Path, action: str) -> bool:
Returns:
True if current phase matches the action.
"""
current = get_current_phase(task_json)
action_phase = get_phase_for_action(task_json, 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

113
.trellis/scripts/common/registry.py

@ -16,29 +16,35 @@ Provides: @@ -16,29 +16,35 @@ Provides:
from __future__ import annotations
import json
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
def _read_json_file(path: Path) -> dict | None:
"""Read and parse a JSON file."""
try:
return json.loads(path.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
# =============================================================================
# Internal Helpers
# =============================================================================
def _load_registry(
repo_root: Path | None = None,
) -> tuple[Path | None, dict | None]:
"""Load registry file and data in one step.
def _write_json_file(path: Path, data: dict) -> bool:
"""Write dict to JSON file."""
try:
path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
return True
except (OSError, IOError):
return False
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
# =============================================================================
@ -85,7 +91,7 @@ def _ensure_registry(repo_root: Path | None = None) -> Path | None: @@ -85,7 +91,7 @@ def _ensure_registry(repo_root: Path | None = None) -> Path | None:
agents_dir.mkdir(parents=True, exist_ok=True)
if not registry_file.exists():
_write_json_file(registry_file, {"agents": []})
write_json(registry_file, {"agents": []})
return registry_file
except (OSError, IOError):
@ -109,14 +115,7 @@ def registry_get_agent_by_id( @@ -109,14 +115,7 @@ def registry_get_agent_by_id(
Returns:
Agent dict, or None if not found.
"""
if repo_root is None:
repo_root = get_repo_root()
registry_file = registry_get_file(repo_root)
if not registry_file or not registry_file.is_file():
return None
data = _read_json_file(registry_file)
_, data = _load_registry(repo_root)
if not data:
return None
@ -140,14 +139,7 @@ def registry_get_agent_by_worktree( @@ -140,14 +139,7 @@ def registry_get_agent_by_worktree(
Returns:
Agent dict, or None if not found.
"""
if repo_root is None:
repo_root = get_repo_root()
registry_file = registry_get_file(repo_root)
if not registry_file or not registry_file.is_file():
return None
data = _read_json_file(registry_file)
_, data = _load_registry(repo_root)
if not data:
return None
@ -171,14 +163,7 @@ def registry_search_agent( @@ -171,14 +163,7 @@ def registry_search_agent(
Returns:
First matching agent dict, or None if not found.
"""
if repo_root is None:
repo_root = get_repo_root()
registry_file = registry_get_file(repo_root)
if not registry_file or not registry_file.is_file():
return None
data = _read_json_file(registry_file)
_, data = _load_registry(repo_root)
if not data:
return None
@ -207,9 +192,14 @@ def registry_get_task_dir( @@ -207,9 +192,14 @@ def registry_get_task_dir(
Returns:
Task directory path, or None if not found.
"""
agent = registry_get_agent_by_worktree(worktree_path, repo_root)
if agent:
return agent.get("task_dir")
_, 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
@ -227,21 +217,14 @@ def registry_remove_by_id(agent_id: str, repo_root: Path | None = None) -> bool: @@ -227,21 +217,14 @@ def registry_remove_by_id(agent_id: str, repo_root: Path | None = None) -> bool:
Returns:
True on success.
"""
if repo_root is None:
repo_root = get_repo_root()
registry_file = registry_get_file(repo_root)
if not registry_file or not registry_file.is_file():
registry_file, data = _load_registry(repo_root)
if not registry_file or not data:
return True # Nothing to remove
data = _read_json_file(registry_file)
if not data:
return True
agents = data.get("agents", [])
data["agents"] = [a for a in agents if a.get("id") != agent_id]
return _write_json_file(registry_file, data)
return write_json(registry_file, data)
def registry_remove_by_worktree(
@ -257,21 +240,14 @@ def registry_remove_by_worktree( @@ -257,21 +240,14 @@ def registry_remove_by_worktree(
Returns:
True on success.
"""
if repo_root is None:
repo_root = get_repo_root()
registry_file = registry_get_file(repo_root)
if not registry_file or not registry_file.is_file():
registry_file, data = _load_registry(repo_root)
if not registry_file or not data:
return True # Nothing to remove
data = _read_json_file(registry_file)
if not data:
return True
agents = data.get("agents", [])
data["agents"] = [a for a in agents if a.get("worktree_path") != worktree_path]
return _write_json_file(registry_file, data)
return write_json(registry_file, data)
def registry_add_agent(
@ -302,7 +278,7 @@ def registry_add_agent( @@ -302,7 +278,7 @@ def registry_add_agent(
if not registry_file:
return False
data = _read_json_file(registry_file)
data = read_json(registry_file)
if not data:
data = {"agents": []}
@ -324,7 +300,7 @@ def registry_add_agent( @@ -324,7 +300,7 @@ def registry_add_agent(
agents.append(new_agent)
data["agents"] = agents
return _write_json_file(registry_file, data)
return write_json(registry_file, data)
def registry_list_agents(repo_root: Path | None = None) -> list[dict]:
@ -336,14 +312,7 @@ def registry_list_agents(repo_root: Path | None = None) -> list[dict]: @@ -336,14 +312,7 @@ def registry_list_agents(repo_root: Path | None = None) -> list[dict]:
Returns:
List of agent dicts.
"""
if repo_root is None:
repo_root = get_repo_root()
registry_file = registry_get_file(repo_root)
if not registry_file or not registry_file.is_file():
return []
data = _read_json_file(registry_file)
_, data = _load_registry(repo_root)
if not data:
return []

562
.trellis/scripts/common/session_context.py

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

410
.trellis/scripts/common/task_context.py

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

125
.trellis/scripts/common/task_queue.py

@ -12,23 +12,32 @@ Provides: @@ -12,23 +12,32 @@ Provides:
from __future__ import annotations
import json
from pathlib import Path
from .paths import (
FILE_TASK_JSON,
get_repo_root,
get_developer,
get_tasks_dir,
)
from .tasks import iter_active_tasks
def _read_json_file(path: Path) -> dict | None:
"""Read and parse a JSON file."""
try:
return json.loads(path.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
# =============================================================================
# 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,
}
# =============================================================================
@ -54,41 +63,10 @@ def list_tasks_by_status( @@ -54,41 +63,10 @@ def list_tasks_by_status(
tasks_dir = get_tasks_dir(repo_root)
results = []
if not tasks_dir.is_dir():
return results
for d in tasks_dir.iterdir():
if not d.is_dir() or d.name == "archive":
continue
task_json = d / FILE_TASK_JSON
if not task_json.is_file():
continue
data = _read_json_file(task_json)
if not data:
for t in iter_active_tasks(tasks_dir):
if filter_status and t.status != filter_status:
continue
task_id = data.get("id", "")
title = data.get("title") or data.get("name", "")
priority = data.get("priority", "P2")
status = data.get("status", "planning")
assignee = data.get("assignee", "-")
# Apply filter
if filter_status and status != filter_status:
continue
results.append({
"priority": priority,
"id": task_id,
"title": title,
"status": status,
"assignee": assignee,
"dir": d.name,
"children": data.get("children", []),
"parent": data.get("parent"),
})
results.append(_task_to_dict(t))
return results
@ -126,46 +104,12 @@ def list_tasks_by_assignee( @@ -126,46 +104,12 @@ def list_tasks_by_assignee(
tasks_dir = get_tasks_dir(repo_root)
results = []
if not tasks_dir.is_dir():
return results
for d in tasks_dir.iterdir():
if not d.is_dir() or d.name == "archive":
continue
task_json = d / FILE_TASK_JSON
if not task_json.is_file():
for t in iter_active_tasks(tasks_dir):
if (t.assignee or "-") != assignee:
continue
data = _read_json_file(task_json)
if not data:
continue
task_assignee = data.get("assignee", "-")
# Apply assignee filter
if task_assignee != assignee:
if filter_status and t.status != filter_status:
continue
task_id = data.get("id", "")
title = data.get("title") or data.get("name", "")
priority = data.get("priority", "P2")
status = data.get("status", "planning")
# Apply status filter
if filter_status and status != filter_status:
continue
results.append({
"priority": priority,
"id": task_id,
"title": title,
"status": status,
"assignee": task_assignee,
"dir": d.name,
"children": data.get("children", []),
"parent": data.get("parent"),
})
results.append(_task_to_dict(t))
return results
@ -211,24 +155,9 @@ def get_task_stats(repo_root: Path | None = None) -> dict[str, int]: @@ -211,24 +155,9 @@ def get_task_stats(repo_root: Path | None = None) -> dict[str, int]:
tasks_dir = get_tasks_dir(repo_root)
stats = {"P0": 0, "P1": 0, "P2": 0, "P3": 0, "Total": 0}
if not tasks_dir.is_dir():
return stats
for d in tasks_dir.iterdir():
if not d.is_dir() or d.name == "archive":
continue
task_json = d / FILE_TASK_JSON
if not task_json.is_file():
continue
data = _read_json_file(task_json)
if not data:
continue
priority = data.get("priority", "P2")
if priority in stats:
stats[priority] += 1
for t in iter_active_tasks(tasks_dir):
if t.priority in stats:
stats[t.priority] += 1
stats["Total"] += 1
return stats

534
.trellis/scripts/common/task_store.py

@ -0,0 +1,534 @@ @@ -0,0 +1,534 @@
#!/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": "implement"},
{"phase": 2, "action": "check"},
{"phase": 3, "action": "finish"},
{"phase": 4, "action": "create-pr"},
],
"commit": None,
"pr_url": None,
"subtasks": [],
"children": [],
"parent": None,
"relatedFiles": [],
"notes": "",
"meta": {},
}
write_json(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

102
.trellis/scripts/common/task_utils.py

@ -3,9 +3,11 @@ @@ -3,9 +3,11 @@
Task utility functions.
Provides:
is_safe_task_path - Validate task path is safe to operate on
find_task_by_name - Find task directory by name
archive_task_dir - Archive task to monthly directory
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
@ -15,7 +17,7 @@ import sys @@ -15,7 +17,7 @@ import sys
from datetime import datetime
from pathlib import Path
from .paths import get_repo_root
from .paths import get_repo_root, get_tasks_dir
# =============================================================================
@ -163,13 +165,101 @@ def archive_task_complete( @@ -163,13 +165,101 @@ def archive_task_complete(
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()
# Absolute path
if target_dir.startswith("/"):
return Path(target_dir)
# Relative path (contains path separator or starts with .trellis)
if "/" in target_dir or target_dir.startswith(".trellis"):
return repo_root / target_dir
# Task name - try to find in tasks directory
tasks_dir = get_tasks_dir(repo_root)
found = find_task_by_name(target_dir, tasks_dir)
if found:
return found
# Fallback to treating as relative path
return repo_root / target_dir
# =============================================================================
# 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__":
from .paths import get_tasks_dir
repo = get_repo_root()
tasks = get_tasks_dir(repo)

109
.trellis/scripts/common/tasks.py

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

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

59
.trellis/scripts/create_bootstrap.py

@ -36,6 +36,7 @@ from common.paths import ( @@ -36,6 +36,7 @@ from common.paths import (
get_tasks_dir,
set_current_task,
)
from common.config import get_spec_base, resolve_package
# =============================================================================
@ -58,7 +59,7 @@ def write_prd_header() -> str: @@ -58,7 +59,7 @@ def write_prd_header() -> str:
Welcome to Trellis! This is your first task.
AI agents use `.trellis/spec/` to understand YOUR project's coding conventions.
**Empty templates = AI writes generic code that doesn't match your project style.**
**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.
@ -70,36 +71,36 @@ Fill in the guideline files based on your **existing codebase**. @@ -70,36 +71,36 @@ Fill in the guideline files based on your **existing codebase**.
"""
def write_prd_backend_section() -> str:
def write_prd_backend_section(spec_base: str) -> str:
"""Write PRD backend section."""
return """
return f"""
### 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 |
| `.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() -> str:
def write_prd_frontend_section(spec_base: str) -> str:
"""Write PRD frontend section."""
return """
return f"""
### 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 |
| `.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 |
"""
@ -168,17 +169,17 @@ After completing this task: @@ -168,17 +169,17 @@ After completing this task:
"""
def write_prd(task_dir: Path, project_type: str) -> None:
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()
content += write_prd_frontend_section(spec_base)
elif project_type == "backend":
content += write_prd_backend_section()
content += write_prd_backend_section(spec_base)
else: # fullstack
content += write_prd_backend_section()
content += write_prd_frontend_section()
content += write_prd_backend_section(spec_base)
content += write_prd_frontend_section(spec_base)
content += write_prd_footer()
@ -190,7 +191,7 @@ def write_prd(task_dir: Path, project_type: str) -> None: @@ -190,7 +191,7 @@ def write_prd(task_dir: Path, project_type: str) -> None:
# Task JSON
# =============================================================================
def write_task_json(task_dir: Path, developer: str, project_type: str) -> None:
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")
@ -200,20 +201,20 @@ def write_task_json(task_dir: Path, developer: str, project_type: str) -> None: @@ -200,20 +201,20 @@ def write_task_json(task_dir: Path, developer: str, project_type: str) -> None:
{"name": "Fill frontend guidelines", "status": "pending"},
{"name": "Add code examples", "status": "pending"},
]
related_files = [".trellis/spec/frontend/"]
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 = [".trellis/spec/backend/"]
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 = [".trellis/spec/backend/", ".trellis/spec/frontend/"]
related_files = [f".trellis/{spec_base}/backend/", f".trellis/{spec_base}/frontend/"]
task_data = {
"id": TASK_NAME,
@ -264,6 +265,10 @@ def main() -> int: @@ -264,6 +265,10 @@ def main() -> int:
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}"
@ -277,8 +282,8 @@ def main() -> int: @@ -277,8 +282,8 @@ def main() -> int:
task_dir.mkdir(parents=True, exist_ok=True)
# Write files
write_task_json(task_dir, developer, project_type)
write_prd(task_dir, project_type)
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)

91
.trellis/scripts/multi_agent/cleanup.py

@ -22,16 +22,17 @@ This script: @@ -22,16 +22,17 @@ This script:
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import sys
from pathlib import Path
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
import _bootstrap # noqa: F401 — adds parent scripts/ dir to sys.path
from common.git_context import _run_git_command
from common.paths import get_repo_root
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,
@ -44,38 +45,8 @@ from common.task_utils import ( @@ -44,38 +45,8 @@ from common.task_utils import (
is_safe_task_path,
)
# =============================================================================
# Colors
# =============================================================================
class Colors:
RED = "\033[0;31m"
GREEN = "\033[0;32m"
YELLOW = "\033[1;33m"
BLUE = "\033[0;34m"
NC = "\033[0m"
def log_info(msg: str) -> None:
print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}")
def log_success(msg: str) -> None:
print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}")
def log_warn(msg: str) -> None:
print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}")
def log_error(msg: str) -> None:
print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}")
# =============================================================================
# Helper Functions
# =============================================================================
# Colors, log_info, log_success, log_warn, log_error
# are now imported from common.log above.
def confirm(prompt: str, skip_confirm: bool) -> bool:
@ -91,6 +62,26 @@ def confirm(prompt: str, skip_confirm: bool) -> bool: @@ -91,6 +62,26 @@ def confirm(prompt: str, skip_confirm: bool) -> bool:
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
# =============================================================================
@ -110,8 +101,6 @@ def cmd_list(repo_root: Path) -> int: @@ -110,8 +101,6 @@ def cmd_list(repo_root: Path) -> int:
print(f"{Colors.BLUE}=== Registered Agents ==={Colors.NC}")
print()
import json
data = json.loads(registry_file.read_text(encoding="utf-8"))
agents = data.get("agents", [])
@ -165,10 +154,11 @@ def cleanup_registry_only(search: str, repo_root: Path, skip_confirm: bool) -> i @@ -165,10 +154,11 @@ def cleanup_registry_only(search: str, repo_root: Path, skip_confirm: bool) -> i
log_info("Aborted")
return 0
# Archive task directory if exists
# 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"])
@ -191,7 +181,7 @@ def cleanup_worktree( @@ -191,7 +181,7 @@ def cleanup_worktree(
) -> int:
"""Cleanup single worktree."""
# Find worktree path for branch
_, worktree_list, _ = _run_git_command(
_, worktree_list, _ = run_git(
["worktree", "list", "--porcelain"], cwd=repo_root
)
@ -223,7 +213,12 @@ def cleanup_worktree( @@ -223,7 +213,12 @@ def cleanup_worktree(
log_info("Aborted")
return 0
# 1. Archive task
# 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
@ -232,7 +227,7 @@ def cleanup_worktree( @@ -232,7 +227,7 @@ def cleanup_worktree(
# 3. Remove worktree
log_info("Removing worktree...")
ret, _, _ = _run_git_command(
ret, _, _ = run_git(
["worktree", "remove", worktree_path, "--force"], cwd=repo_root
)
if ret != 0:
@ -247,7 +242,7 @@ def cleanup_worktree( @@ -247,7 +242,7 @@ def cleanup_worktree(
# 4. Delete branch (optional)
if not keep_branch:
log_info("Deleting branch...")
ret, _, _ = _run_git_command(["branch", "-D", branch], cwd=repo_root)
ret, _, _ = run_git(["branch", "-D", branch], cwd=repo_root)
if ret != 0:
log_warn("Could not delete branch (may be checked out elsewhere)")
@ -258,7 +253,7 @@ def cleanup_worktree( @@ -258,7 +253,7 @@ def cleanup_worktree(
def cmd_merged(repo_root: Path, skip_confirm: bool, keep_branch: bool) -> int:
"""Cleanup merged worktrees."""
# Get main branch
_, head_out, _ = _run_git_command(
_, head_out, _ = run_git(
["symbolic-ref", "refs/remotes/origin/HEAD"], cwd=repo_root
)
main_branch = head_out.strip().replace("refs/remotes/origin/", "") or "main"
@ -267,7 +262,7 @@ def cmd_merged(repo_root: Path, skip_confirm: bool, keep_branch: bool) -> int: @@ -267,7 +262,7 @@ def cmd_merged(repo_root: Path, skip_confirm: bool, keep_branch: bool) -> int:
print()
# Get merged branches
_, merged_out, _ = _run_git_command(
_, merged_out, _ = run_git(
["branch", "--merged", main_branch], cwd=repo_root
)
merged_branches = []
@ -281,7 +276,7 @@ def cmd_merged(repo_root: Path, skip_confirm: bool, keep_branch: bool) -> int: @@ -281,7 +276,7 @@ def cmd_merged(repo_root: Path, skip_confirm: bool, keep_branch: bool) -> int:
return 0
# Get worktree list
_, worktree_list, _ = _run_git_command(["worktree", "list"], cwd=repo_root)
_, worktree_list, _ = run_git(["worktree", "list"], cwd=repo_root)
worktree_branches = []
for branch in merged_branches:
@ -310,7 +305,7 @@ def cmd_all(repo_root: Path, skip_confirm: bool, keep_branch: bool) -> int: @@ -310,7 +305,7 @@ def cmd_all(repo_root: Path, skip_confirm: bool, keep_branch: bool) -> int:
print()
# Get worktree list
_, worktree_list, _ = _run_git_command(
_, worktree_list, _ = run_git(
["worktree", "list", "--porcelain"], cwd=repo_root
)
@ -340,7 +335,7 @@ def cmd_all(repo_root: Path, skip_confirm: bool, keep_branch: bool) -> int: @@ -340,7 +335,7 @@ def cmd_all(repo_root: Path, skip_confirm: bool, keep_branch: bool) -> int:
# Get branch for each worktree
for wt in worktrees:
# Find branch name from worktree list
_, wt_list, _ = _run_git_command(["worktree", "list"], cwd=repo_root)
_, wt_list, _ = run_git(["worktree", "list"], cwd=repo_root)
for line in wt_list.splitlines():
if wt in line:
# Extract branch from [branch] format

381
.trellis/scripts/multi_agent/create_pr.py

@ -6,10 +6,11 @@ Usage: @@ -6,10 +6,11 @@ Usage:
python3 create_pr.py [task-dir] [--dry-run]
This script:
1. Stages and commits all changes (excluding workspace/)
2. Pushes to origin
3. Creates a Draft PR using `gh pr create`
4. Updates task.json with status="completed", pr_url, and current_phase
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.
@ -18,15 +19,16 @@ step after all implementation and checks are complete. @@ -18,15 +19,16 @@ step after all implementation and checks are complete.
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
import _bootstrap # noqa: F401 — adds parent scripts/ dir to sys.path
from common.git_context import _run_git_command
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,
@ -35,41 +37,277 @@ from common.paths import ( @@ -35,41 +37,277 @@ from common.paths import (
)
from common.phase import get_phase_for_action
# Colors, read_json, write_json
# are now imported from common.log and common.io above.
# =============================================================================
# Colors
# 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."
)
class Colors:
RED = "\033[0;31m"
GREEN = "\033[0;32m"
YELLOW = "\033[1;33m"
BLUE = "\033[0;34m"
NC = "\033[0m"
def _get_submodule_default_branch(submodule_abs: Path) -> str:
"""Get the default branch of a submodule repository.
# =============================================================================
# Helper Functions
# =============================================================================
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
def _read_json_file(path: Path) -> dict | None:
"""Read and parse a JSON file."""
try:
return json.loads(path.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
# --- 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")
def _write_json_file(path: Path, data: dict) -> bool:
"""Write dict to JSON file."""
try:
path.write_text(
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
# --- Push ---
ret, _, err = run_git(
["push", "-u", "origin", sub_branch], cwd=sub_abs
)
return True
except (OSError, IOError):
return False
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}")
# =============================================================================
@ -127,7 +365,7 @@ def main() -> int: @@ -127,7 +365,7 @@ def main() -> int:
print()
# Read task config
task_data = _read_json_file(task_json)
task_data = read_json(task_json)
if not task_data:
print(f"{Colors.RED}Error: Failed to read task.json{Colors.NC}")
return 1
@ -158,36 +396,66 @@ def main() -> int: @@ -158,36 +396,66 @@ def main() -> int:
print()
# Get current branch
_, branch_out, _ = _run_git_command(["branch", "--show-current"])
_, 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_command(["add", "-A"])
run_git(["add", "-A"])
# Exclude workspace and temp files
_run_git_command(["reset", f"{DIR_WORKFLOW}/workspace/"])
_run_git_command(["reset", ".agent-log", ".session-id"])
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_command(["diff", "--cached", "--quiet"])
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_command(
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_command(["reset", "HEAD"])
run_git(["reset", "HEAD"])
print(f"{Colors.RED}No changes to create PR{Colors.NC}")
return 1
@ -200,11 +468,11 @@ def main() -> int: @@ -200,11 +468,11 @@ def main() -> int:
if args.dry_run:
print(f"[DRY-RUN] Would commit with message: {commit_msg}")
print("[DRY-RUN] Staged files:")
_, staged_out, _ = _run_git_command(["diff", "--cached", "--name-only"])
_, staged_out, _ = run_git(["diff", "--cached", "--name-only"])
for line in staged_out.splitlines():
print(f" - {line}")
else:
_run_git_command(["commit", "-m", commit_msg])
run_git(["commit", "-m", commit_msg])
print(f"{Colors.GREEN}Committed: {commit_msg}{Colors.NC}")
# Push to remote
@ -212,7 +480,7 @@ def main() -> int: @@ -212,7 +480,7 @@ def main() -> int:
if args.dry_run:
print(f"[DRY-RUN] Would push to: origin/{current_branch}")
else:
ret, _, err = _run_git_command(["push", "-u", "origin", current_branch])
ret, _, err = run_git(["push", "-u", "origin", current_branch])
if ret != 0:
print(f"{Colors.RED}Failed to push: {err}{Colors.NC}")
return 1
@ -223,6 +491,9 @@ def main() -> int: @@ -223,6 +491,9 @@ def main() -> int:
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}")
@ -231,6 +502,8 @@ def main() -> int: @@ -231,6 +502,8 @@ def main() -> int:
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
@ -258,6 +531,12 @@ def main() -> int: @@ -258,6 +531,12 @@ def main() -> int:
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 = ""
@ -265,6 +544,10 @@ def main() -> int: @@ -265,6 +544,10 @@ def main() -> int:
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(
[
@ -298,6 +581,8 @@ def main() -> int: @@ -298,6 +581,8 @@ def main() -> int:
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
@ -308,19 +593,25 @@ def main() -> int: @@ -308,19 +593,25 @@ def main() -> int:
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_file(task_json, task_data)
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_command(["reset", "HEAD"])
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

41
.trellis/scripts/multi_agent/plan.py

@ -24,38 +24,14 @@ import subprocess @@ -24,38 +24,14 @@ import subprocess
import sys
from pathlib import Path
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
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
# =============================================================================
# Colors
# =============================================================================
class Colors:
RED = "\033[0;31m"
GREEN = "\033[0;32m"
YELLOW = "\033[1;33m"
BLUE = "\033[0;34m"
NC = "\033[0m"
def log_info(msg: str) -> None:
print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}")
def log_success(msg: str) -> None:
print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}")
def log_error(msg: str) -> None:
print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}")
# =============================================================================
# Constants
# =============================================================================
@ -77,7 +53,7 @@ def main() -> int: @@ -77,7 +53,7 @@ def main() -> int:
parser.add_argument("--requirement", "-r", required=True, help="Requirement description")
parser.add_argument(
"--platform", "-p",
choices=["claude", "cursor", "iflow", "opencode", "qoder"],
choices=["claude", "cursor", "iflow", "opencode", "codex", "qoder"],
default=DEFAULT_PLATFORM,
help="Platform to use (default: claude)"
)
@ -100,11 +76,12 @@ def main() -> int: @@ -100,11 +76,12 @@ def main() -> int:
project_root = get_repo_root()
# Check plan agent exists (path varies by platform)
plan_md = adapter.get_agent_path("plan", project_root)
if not plan_md.is_file():
log_error(f"plan agent not found at {plan_md}")
log_info(f"Platform: {platform}")
return 1
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)

196
.trellis/scripts/multi_agent/start.py

@ -21,7 +21,6 @@ Configuration: .trellis/worktree.yaml @@ -21,7 +21,6 @@ Configuration: .trellis/worktree.yaml
from __future__ import annotations
import json
import os
import shutil
import subprocess
@ -29,11 +28,12 @@ import sys @@ -29,11 +28,12 @@ import sys
import uuid
from pathlib import Path
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
import _bootstrap # noqa: F401 — adds parent scripts/ dir to sys.path
from common.cli_adapter import CLIAdapter, get_cli_adapter
from common.git_context import _run_git_command
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,
@ -44,6 +44,12 @@ from common.registry import ( @@ -44,6 +44,12 @@ 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,
@ -51,64 +57,121 @@ from common.worktree import ( @@ -51,64 +57,121 @@ from common.worktree import (
get_worktree_post_create_hooks,
)
# =============================================================================
# Colors
# =============================================================================
class Colors:
RED = "\033[0;31m"
GREEN = "\033[0;32m"
YELLOW = "\033[1;33m"
BLUE = "\033[0;34m"
NC = "\033[0m"
def log_info(msg: str) -> None:
print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}")
def log_success(msg: str) -> None:
print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}")
# Colors, log_info, log_success, log_warn, log_error, read_json, write_json
# are now imported from common.log and common.io above.
def log_warn(msg: str) -> None:
print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}")
# =============================================================================
# Constants
# =============================================================================
def log_error(msg: str) -> None:
print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}")
DEFAULT_PLATFORM = "claude"
# =============================================================================
# Helper Functions
# Submodule Init
# =============================================================================
def _read_json_file(path: Path) -> dict | None:
"""Read and parse a JSON file."""
try:
return json.loads(path.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
def _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
def _write_json_file(path: Path, data: dict) -> bool:
"""Write dict to JSON file."""
try:
path.write_text(
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
)
return True
except (OSError, IOError):
return False
# Resolve package: task.package -> default_package -> None
task_package = task_data.get("package")
package = None
# =============================================================================
# Constants
# =============================================================================
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)
)
DEFAULT_PLATFORM = "claude"
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"
)
# =============================================================================
@ -124,7 +187,7 @@ def main() -> int: @@ -124,7 +187,7 @@ def main() -> int:
parser.add_argument("task_dir", help="Task directory path")
parser.add_argument(
"--platform", "-p",
choices=["claude", "cursor", "iflow", "opencode", "qoder"],
choices=["claude", "cursor", "iflow", "opencode", "codex", "qoder"],
default=DEFAULT_PLATFORM,
help="Platform to use (default: claude)"
)
@ -155,11 +218,12 @@ def main() -> int: @@ -155,11 +218,12 @@ def main() -> int:
log_error(f"task.json not found at {task_json_path}")
return 1
dispatch_md = adapter.get_agent_path("dispatch", project_root)
if not dispatch_md.is_file():
log_error(f"dispatch.md not found at {dispatch_md}")
log_info(f"Platform: {platform}")
return 1
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():
@ -173,7 +237,7 @@ def main() -> int: @@ -173,7 +237,7 @@ def main() -> int:
print(f"{Colors.BLUE}=== Multi-Agent Pipeline: Start ==={Colors.NC}")
log_info(f"Task: {task_dir_abs}")
task_data = _read_json_file(task_json_path)
task_data = read_json(task_json_path)
if not task_data:
log_error("Failed to read task.json")
return 1
@ -222,7 +286,7 @@ def main() -> int: @@ -222,7 +286,7 @@ def main() -> int:
log_info("Step 1: Creating worktree...")
# Record current branch as base_branch (PR target)
_, base_branch_out, _ = _run_git_command(
_, base_branch_out, _ = run_git(
["branch", "--show-current"], cwd=project_root
)
base_branch = base_branch_out.strip()
@ -239,18 +303,18 @@ def main() -> int: @@ -239,18 +303,18 @@ def main() -> int:
worktree_path_obj.parent.mkdir(parents=True, exist_ok=True)
# Create branch if not exists
ret, _, _ = _run_git_command(
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_command(
ret, _, err = run_git(
["worktree", "add", worktree_path, branch], cwd=project_root
)
else:
log_info(f"Creating new branch: {branch}")
ret, _, err = _run_git_command(
ret, _, err = run_git(
["worktree", "add", "-b", branch, worktree_path], cwd=project_root
)
@ -263,7 +327,7 @@ def main() -> int: @@ -263,7 +327,7 @@ def main() -> int:
# Update task.json with worktree_path and base_branch
task_data["worktree_path"] = worktree_path
task_data["base_branch"] = base_branch
_write_json_file(task_json_path, task_data)
write_json(task_json_path, task_data)
# ----- Copy environment files -----
log_info("Copying environment files...")
@ -294,6 +358,9 @@ def main() -> int: @@ -294,6 +358,9 @@ def main() -> int:
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)
@ -315,6 +382,9 @@ def main() -> int: @@ -315,6 +382,9 @@ def main() -> int:
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
# =============================================================================
@ -334,7 +404,7 @@ def main() -> int: @@ -334,7 +404,7 @@ def main() -> int:
# Update task status
task_data["status"] = "in_progress"
_write_json_file(task_json_path, task_data)
write_json(task_json_path, task_data)
log_file = Path(worktree_path) / ".agent-log"
session_id_file = Path(worktree_path) / ".session-id"

765
.trellis/scripts/multi_agent/status.py

@ -10,774 +10,33 @@ Usage: @@ -10,774 +10,33 @@ Usage:
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 json
import os
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from common.cli_adapter import get_cli_adapter
from common.developer import ensure_developer
from common.paths import (
FILE_TASK_JSON,
get_repo_root,
get_tasks_dir,
)
from common.phase import get_phase_info
from common.task_queue import format_task_stats, get_task_stats
from common.worktree import get_agents_dir
# =============================================================================
# Colors
# =============================================================================
class Colors:
RED = "\033[0;31m"
GREEN = "\033[0;32m"
YELLOW = "\033[1;33m"
BLUE = "\033[0;34m"
CYAN = "\033[0;36m"
DIM = "\033[2m"
NC = "\033[0m"
# =============================================================================
# Helper Functions
# =============================================================================
def _read_json_file(path: Path) -> dict | None:
"""Read and parse a JSON file."""
try:
return json.loads(path.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
def is_running(pid: int | str | None) -> bool:
"""Check if PID is running."""
if not pid:
return False
try:
pid_int = int(pid)
os.kill(pid_int, 0)
return True
except (ProcessLookupError, ValueError, PermissionError, TypeError):
return False
def status_color(status: str) -> str:
"""Get status color."""
colors = {
"completed": Colors.GREEN,
"in_progress": Colors.BLUE,
"planning": Colors.YELLOW,
}
return colors.get(status, Colors.DIM)
def get_registry_file(repo_root: Path) -> Path | None:
"""Get registry file path."""
agents_dir = get_agents_dir(repo_root)
if agents_dir:
return agents_dir / "registry.json"
return None
def find_agent(search: str, repo_root: Path) -> dict | None:
"""Find agent by task name or ID."""
registry_file = get_registry_file(repo_root)
if not registry_file or not registry_file.is_file():
return None
data = _read_json_file(registry_file)
if not data:
return None
for agent in data.get("agents", []):
# Exact ID match
if agent.get("id") == search:
return agent
# Partial match on task_dir
task_dir = agent.get("task_dir", "")
if search in task_dir:
return agent
return None
def calc_elapsed(started: str | None) -> str:
"""Calculate elapsed time from ISO timestamp."""
if not started:
return "N/A"
try:
# Parse ISO format
if "+" in started:
started = started.split("+")[0]
if "T" in started:
start_dt = datetime.fromisoformat(started)
else:
return "N/A"
now = datetime.now()
elapsed = (now - start_dt).total_seconds()
if elapsed < 60:
return f"{int(elapsed)}s"
elif elapsed < 3600:
mins = int(elapsed // 60)
secs = int(elapsed % 60)
return f"{mins}m {secs}s"
else:
hours = int(elapsed // 3600)
mins = int((elapsed % 3600) // 60)
return f"{hours}h {mins}m"
except (ValueError, TypeError):
return "N/A"
def count_modified_files(worktree: str) -> int:
"""Count modified files in worktree."""
if not Path(worktree).is_dir():
return 0
try:
result = subprocess.run(
["git", "status", "--short"],
cwd=worktree,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
return len([line for line in result.stdout.splitlines() if line.strip()])
except Exception:
return 0
def tail_follow(file_path: Path) -> None:
"""Follow a file like 'tail -f', cross-platform compatible."""
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
# Seek to end of file
f.seek(0, 2)
while True:
line = f.readline()
if line:
print(line, end="", flush=True)
else:
time.sleep(0.1)
def get_last_tool(log_file: Path, platform: str = "claude") -> str | None:
"""Get the last tool call from agent log.
Supports both Claude Code and OpenCode log formats.
Claude Code format:
{"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Read"}]}}
OpenCode format:
{"type": "tool_use", "tool": "bash", "state": {"status": "completed"}}
"""
if not log_file.is_file():
return None
try:
lines = log_file.read_text(encoding="utf-8").splitlines()
for line in reversed(lines[-100:]):
try:
data = json.loads(line)
if platform == "opencode":
# OpenCode format: {"type": "tool_use", "tool": "bash", ...}
if data.get("type") == "tool_use":
return data.get("tool")
else:
# Claude Code format: {"type": "assistant", "message": {"content": [...]}}
if data.get("type") == "assistant":
content = data.get("message", {}).get("content", [])
for item in content:
if item.get("type") == "tool_use":
return item.get("name")
except json.JSONDecodeError:
continue
except Exception:
pass
return None
def get_last_message(log_file: Path, max_len: int = 100, platform: str = "claude") -> str | None:
"""Get the last assistant text from agent log.
Supports both Claude Code and OpenCode log formats.
Claude Code format:
{"type": "assistant", "message": {"content": [{"type": "text", "text": "..."}]}}
OpenCode format:
{"type": "text", "text": "..."}
"""
if not log_file.is_file():
return None
try:
lines = log_file.read_text(encoding="utf-8").splitlines()
for line in reversed(lines[-100:]):
try:
data = json.loads(line)
if platform == "opencode":
# OpenCode format: {"type": "text", "text": "..."}
if data.get("type") == "text":
text = data.get("text", "")
if text:
return text[:max_len]
else:
# Claude Code format: {"type": "assistant", "message": {"content": [...]}}
if data.get("type") == "assistant":
content = data.get("message", {}).get("content", [])
for item in content:
if item.get("type") == "text":
text = item.get("text", "")
if text:
return text[:max_len]
except json.JSONDecodeError:
continue
except Exception:
pass
return None
# =============================================================================
# Commands
# =============================================================================
def cmd_help() -> int:
"""Show help."""
print("""Multi-Agent Pipeline: Status Monitor
Usage:
python3 status.py Show summary of all tasks
python3 status.py -a <assignee> Filter tasks by assignee
python3 status.py --list List all worktrees and agents
python3 status.py --detail <task> Detailed task status
python3 status.py --progress <task> Quick progress view with recent activity
python3 status.py --watch <task> Watch agent log in real-time
python3 status.py --log <task> Show recent log entries
python3 status.py --registry Show agent registry
Examples:
python3 status.py -a taosu
python3 status.py --detail my-task
python3 status.py --progress my-task
python3 status.py --watch 01-16-worktree-support
python3 status.py --log worktree-support
""")
return 0
def cmd_list(repo_root: Path) -> int:
"""List worktrees and agents."""
print(f"{Colors.BLUE}=== Git Worktrees ==={Colors.NC}")
print()
subprocess.run(["git", "worktree", "list"], cwd=repo_root)
print()
print(f"{Colors.BLUE}=== Registered Agents ==={Colors.NC}")
print()
registry_file = get_registry_file(repo_root)
if not registry_file or not registry_file.is_file():
print(" (no registry found)")
return 0
data = _read_json_file(registry_file)
if not data or not data.get("agents"):
print(" (no agents registered)")
return 0
for agent in data["agents"]:
agent_id = agent.get("id", "?")
pid = agent.get("pid")
wt = agent.get("worktree_path", "?")
started = agent.get("started_at", "?")
if is_running(pid):
status_icon = f"{Colors.GREEN}{Colors.NC}"
else:
status_icon = f"{Colors.RED}{Colors.NC}"
import _bootstrap # noqa: F401 — adds parent scripts/ dir to sys.path
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()
from common.paths import get_repo_root
return 0
def cmd_summary(repo_root: Path, filter_assignee: str | None = None) -> int:
"""Show summary of all tasks."""
ensure_developer(repo_root)
tasks_dir = get_tasks_dir(repo_root)
if not tasks_dir.is_dir():
print("No tasks directory found")
return 0
registry_file = get_registry_file(repo_root)
# Count running agents
running_count = 0
total_agents = 0
if registry_file and registry_file.is_file():
data = _read_json_file(registry_file)
if data:
agents = data.get("agents", [])
total_agents = len(agents)
for agent in agents:
if is_running(agent.get("pid")):
running_count += 1
# Task queue stats
task_stats = get_task_stats(repo_root)
print(f"{Colors.BLUE}=== Multi-Agent Status ==={Colors.NC}")
print(
f" Agents: {Colors.GREEN}{running_count}{Colors.NC} running / {total_agents} registered"
)
print(f" Tasks: {format_task_stats(task_stats)}")
print()
# Process tasks
running_tasks = []
stopped_tasks = []
regular_tasks = []
registry_data = (
_read_json_file(registry_file)
if registry_file and registry_file.is_file()
else None
)
for d in sorted(tasks_dir.iterdir()):
if not d.is_dir() or d.name == "archive":
continue
name = d.name
task_json = d / FILE_TASK_JSON
status = "unknown"
assignee = "unassigned"
priority = "P2"
if task_json.is_file():
data = _read_json_file(task_json)
if data:
status = data.get("status", "unknown")
assignee = data.get("assignee", "unassigned")
priority = data.get("priority", "P2")
# Filter by assignee
if filter_assignee and assignee != filter_assignee:
continue
# Check agent status
agent_info = None
if registry_data:
for agent in registry_data.get("agents", []):
if name in agent.get("task_dir", ""):
agent_info = agent
break
if agent_info:
pid = agent_info.get("pid")
worktree = agent_info.get("worktree_path", "")
started = agent_info.get("started_at")
agent_platform = agent_info.get("platform", "claude")
if is_running(pid):
# Running agent
task_dir_rel = agent_info.get("task_dir", "")
worktree_task_json = Path(worktree) / task_dir_rel / "task.json"
phase_source = task_json
if worktree_task_json.is_file():
phase_source = worktree_task_json
phase_info_str = get_phase_info(phase_source)
elapsed = calc_elapsed(started)
modified = count_modified_files(worktree)
worktree_data = _read_json_file(phase_source)
branch = worktree_data.get("branch", "N/A") if worktree_data else "N/A"
log_file = Path(worktree) / ".agent-log"
last_tool = get_last_tool(log_file, platform=agent_platform)
running_tasks.append(
{
"name": name,
"priority": priority,
"assignee": assignee,
"phase_info": phase_info_str,
"elapsed": elapsed,
"branch": branch,
"modified": modified,
"last_tool": last_tool,
"pid": pid,
}
)
else:
# Stopped agent
task_dir_rel = agent_info.get("task_dir", "")
worktree_task_json = Path(worktree) / task_dir_rel / "task.json"
worktree_status = "unknown"
if worktree_task_json.is_file():
wt_data = _read_json_file(worktree_task_json)
if wt_data:
worktree_status = wt_data.get("status", "unknown")
session_id_file = Path(worktree) / ".session-id"
log_file = Path(worktree) / ".agent-log"
stopped_tasks.append(
{
"name": name,
"worktree": worktree,
"status": worktree_status,
"session_id_file": session_id_file,
"log_file": log_file,
"platform": agent_info.get("platform", "claude"),
}
)
else:
# Regular task
regular_tasks.append(
{
"name": name,
"status": status,
"priority": priority,
"assignee": assignee,
}
)
# Output running agents
if running_tasks:
print(f"{Colors.CYAN}Running Agents:{Colors.NC}")
for t in running_tasks:
priority_color = (
Colors.RED
if t["priority"] == "P0"
else (Colors.YELLOW if t["priority"] == "P1" else Colors.BLUE)
)
print(
f"{Colors.GREEN}{Colors.NC} {Colors.CYAN}{t['name']}{Colors.NC} {Colors.GREEN}[running]{Colors.NC} {priority_color}[{t['priority']}]{Colors.NC} @{t['assignee']}"
)
print(f" Phase: {t['phase_info']}")
print(f" Elapsed: {t['elapsed']}")
print(f" Branch: {Colors.DIM}{t['branch']}{Colors.NC}")
print(f" Modified: {t['modified']} file(s)")
if t["last_tool"]:
print(f" Activity: {Colors.YELLOW}{t['last_tool']}{Colors.NC}")
print(f" PID: {Colors.DIM}{t['pid']}{Colors.NC}")
print()
# Output stopped agents
if stopped_tasks:
print(f"{Colors.RED}Stopped Agents:{Colors.NC}")
for t in stopped_tasks:
if t["status"] == "completed":
print(
f"{Colors.GREEN}{Colors.NC} {t['name']} {Colors.GREEN}[completed]{Colors.NC}"
)
else:
if t["session_id_file"].is_file():
session_id = (
t["session_id_file"].read_text(encoding="utf-8").strip()
)
last_msg = get_last_message(t["log_file"], 150, platform=t.get("platform", "claude"))
print(
f"{Colors.RED}{Colors.NC} {t['name']} {Colors.RED}[stopped]{Colors.NC}"
)
if last_msg:
print(f'{Colors.DIM}"{last_msg}"{Colors.NC}')
# Use CLI adapter for platform-specific resume command
adapter = get_cli_adapter(t.get("platform", "claude"))
resume_cmd = adapter.get_resume_command_str(session_id, cwd=t["worktree"])
print(f"{Colors.YELLOW}{resume_cmd}{Colors.NC}")
else:
print(
f"{Colors.RED}{Colors.NC} {t['name']} {Colors.RED}[stopped]{Colors.NC} {Colors.DIM}(no session-id){Colors.NC}"
)
print()
# Separator
if (running_tasks or stopped_tasks) and regular_tasks:
print(f"{Colors.DIM}───────────────────────────────────────{Colors.NC}")
print()
# Output regular tasks grouped by assignee
if regular_tasks:
# Sort by assignee, priority, status
regular_tasks.sort(
key=lambda x: (
x["assignee"],
{"P0": 0, "P1": 1, "P2": 2, "P3": 3}.get(x["priority"], 2),
{"in_progress": 0, "planning": 1, "completed": 2}.get(x["status"], 1),
)
)
current_assignee = None
for t in regular_tasks:
if t["assignee"] != current_assignee:
if current_assignee is not None:
print()
print(f"{Colors.CYAN}@{t['assignee']}:{Colors.NC}")
current_assignee = t["assignee"]
color = status_color(t["status"])
priority_color = (
Colors.RED
if t["priority"] == "P0"
else (Colors.YELLOW if t["priority"] == "P1" else Colors.BLUE)
)
print(
f" {color}{Colors.NC} {t['name']} ({t['status']}) {priority_color}[{t['priority']}]{Colors.NC}"
)
if running_tasks:
print()
print(f"{Colors.DIM}─────────────────────────────────────{Colors.NC}")
print(f"{Colors.DIM}Use --progress <name> for quick activity view{Colors.NC}")
print(f"{Colors.DIM}Use --detail <name> for more info{Colors.NC}")
print()
return 0
def cmd_detail(target: str, repo_root: Path) -> int:
"""Show detailed task status."""
agent = find_agent(target, repo_root)
if not agent:
print(f"Agent not found: {target}")
return 1
agent_id = agent.get("id", "?")
pid = agent.get("pid")
worktree = agent.get("worktree_path", "?")
task_dir = agent.get("task_dir", "?")
started = agent.get("started_at", "?")
platform = agent.get("platform", "claude")
# Check for session-id
session_id = ""
session_id_file = Path(worktree) / ".session-id"
if session_id_file.is_file():
session_id = session_id_file.read_text(encoding="utf-8").strip()
print(f"{Colors.BLUE}=== Agent Detail: {agent_id} ==={Colors.NC}")
print()
print(f" ID: {agent_id}")
print(f" PID: {pid}")
print(f" Session: {session_id or 'N/A'}")
print(f" Worktree: {worktree}")
print(f" Task Dir: {task_dir}")
print(f" Started: {started}")
print()
# Status
if is_running(pid):
print(f" Status: {Colors.GREEN}Running{Colors.NC}")
else:
print(f" Status: {Colors.RED}Stopped{Colors.NC}")
if session_id:
print()
# Use CLI adapter for platform-specific resume command
adapter = get_cli_adapter(platform)
resume_cmd = adapter.get_resume_command_str(session_id, cwd=worktree)
print(f" {Colors.YELLOW}Resume:{Colors.NC} {resume_cmd}")
# Task info
task_json = repo_root / task_dir / "task.json"
if task_json.is_file():
print()
print(f"{Colors.BLUE}=== Task Info ==={Colors.NC}")
print()
data = _read_json_file(task_json)
if data:
print(f" Status: {data.get('status', 'unknown')}")
print(f" Branch: {data.get('branch', 'N/A')}")
print(f" Base Branch: {data.get('base_branch', 'N/A')}")
# Git changes
if Path(worktree).is_dir():
print()
print(f"{Colors.BLUE}=== Git Changes ==={Colors.NC}")
print()
result = subprocess.run(
["git", "status", "--short"],
cwd=worktree,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
changes = result.stdout.strip()
if changes:
for line in changes.splitlines()[:10]:
print(f" {line}")
total = len(changes.splitlines())
if total > 10:
print(f" ... and {total - 10} more")
else:
print(" (no changes)")
print()
return 0
def cmd_watch(target: str, repo_root: Path) -> int:
"""Watch agent log in real-time."""
agent = find_agent(target, repo_root)
if not agent:
print(f"Agent not found: {target}")
return 1
worktree = agent.get("worktree_path", "")
log_file = Path(worktree) / ".agent-log"
if not log_file.is_file():
print(f"Log file not found: {log_file}")
return 1
print(f"{Colors.BLUE}Watching:{Colors.NC} {log_file}")
print(f"{Colors.DIM}Press Ctrl+C to stop{Colors.NC}")
print()
try:
tail_follow(log_file)
except KeyboardInterrupt:
print() # Clean newline after Ctrl+C
return 0
def cmd_log(target: str, repo_root: Path) -> int:
"""Show recent log entries."""
agent = find_agent(target, repo_root)
if not agent:
print(f"Agent not found: {target}")
return 1
worktree = agent.get("worktree_path", "")
platform = agent.get("platform", "claude")
log_file = Path(worktree) / ".agent-log"
if not log_file.is_file():
print(f"Log file not found: {log_file}")
return 1
print(f"{Colors.BLUE}=== Recent Log: {target} ==={Colors.NC}")
print(f"{Colors.DIM}Platform: {platform}{Colors.NC}")
print()
lines = log_file.read_text(encoding="utf-8").splitlines()
for line in lines[-50:]:
try:
data = json.loads(line)
msg_type = data.get("type", "")
if platform == "opencode":
# OpenCode format
if msg_type == "text":
text = data.get("text", "")
if text:
display = text[:300]
if len(text) > 300:
display += "..."
print(f"{Colors.BLUE}[TEXT]{Colors.NC} {display}")
elif msg_type == "tool_use":
tool_name = data.get("tool", "unknown")
status = data.get("state", {}).get("status", "")
print(f"{Colors.YELLOW}[TOOL]{Colors.NC} {tool_name} ({status})")
elif msg_type == "step_start":
print(f"{Colors.CYAN}[STEP]{Colors.NC} Start")
elif msg_type == "step_finish":
reason = data.get("reason", "")
print(f"{Colors.CYAN}[STEP]{Colors.NC} Finish ({reason})")
elif msg_type == "error":
error_msg = data.get("message", "")
print(f"{Colors.RED}[ERROR]{Colors.NC} {error_msg}")
else:
# Claude Code format
if msg_type == "system":
subtype = data.get("subtype", "")
print(f"{Colors.CYAN}[SYSTEM]{Colors.NC} {subtype}")
elif msg_type == "user":
content = data.get("message", {}).get("content", "")
if content:
print(f"{Colors.GREEN}[USER]{Colors.NC} {content[:200]}")
elif msg_type == "assistant":
content = data.get("message", {}).get("content", [])
if content:
item = content[0]
text = item.get("text")
tool = item.get("name")
if text:
display = text[:300]
if len(text) > 300:
display += "..."
print(f"{Colors.BLUE}[ASSISTANT]{Colors.NC} {display}")
elif tool:
print(f"{Colors.YELLOW}[TOOL]{Colors.NC} {tool}")
elif msg_type == "result":
tool_name = data.get("tool", "unknown")
print(f"{Colors.DIM}[RESULT]{Colors.NC} {tool_name} completed")
except json.JSONDecodeError:
continue
return 0
def cmd_registry(repo_root: Path) -> int:
"""Show agent registry."""
registry_file = get_registry_file(repo_root)
print(f"{Colors.BLUE}=== Agent Registry ==={Colors.NC}")
print()
print(f"File: {registry_file}")
print()
if registry_file and registry_file.is_file():
data = _read_json_file(registry_file)
if data:
print(json.dumps(data, indent=2))
else:
print("(registry not found)")
return 0
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")

542
.trellis/scripts/multi_agent/status_display.py

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

225
.trellis/scripts/multi_agent/status_monitor.py

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

1025
.trellis/scripts/task.py

File diff suppressed because it is too large Load Diff

70
.trellis/tasks/04-07-migrate-to-0.4.0-beta.8/prd.md

@ -0,0 +1,70 @@ @@ -0,0 +1,70 @@
# Migration Task: Upgrade to v0.4.0-beta.8
**Created**: 2026-04-07
**From Version**: 0.3.10
**To Version**: 0.4.0-beta.8
**Assignee**: name=hechang27-sprt
initialized_at=2026-03-24T18:13:15.726010
## Status
- [ ] Review migration guide
- [ ] Update custom files
- [ ] Run `trellis update --migrate`
- [ ] Test workflows
---
## v0.4.0-beta.1 Migration Guide
## Command Consolidation
The following commands have been merged:
| Old (removed) | New (replacement) |
|---------------|-------------------|
| `before-backend-dev` | `before-dev` |
| `before-frontend-dev` | `before-dev` |
| `check-backend` | `check` |
| `check-frontend` | `check` |
### If you never customized these commands
No action needed. `trellis update` will:
1. Auto-delete the old files (hash-verified, only if unmodified)
2. Write the new unified `before-dev` and `check` commands
### If you customized the old commands
The old files will NOT be deleted (hash mismatch protection). You need to:
1. Open the new `before-dev` / `check` command file
2. Merge your customizations from the old backend/frontend variants into the unified file
3. Delete the old files manually once merged
### Affected paths per platform
- **Claude**: `.claude/commands/trellis/`
- **Cursor**: `.cursor/commands/`
- **iFlow**: `.iflow/commands/trellis/`
- **OpenCode**: `.opencode/commands/trellis/`
- **Gemini**: `.gemini/commands/trellis/`
- **Kilo**: `.kilocode/workflows/`
- **Codex**: `.agents/skills/`
- **Kiro**: `.kiro/skills/`
- **Qoder**: `.qoder/skills/`
## Python Scripts Refactoring
All `.trellis/scripts/` files have been restructured internally. **No action needed** — all entry paths (`python3 .trellis/scripts/task.py`, etc.) remain unchanged. `trellis update` will sync the new scripts automatically.
### AI Assistant Instructions
When helping with this migration:
When helping users migrate to v0.4.0-beta.1:
1. **Check for customized old commands**: Run `git diff` or compare file hashes to see if the user modified before-backend-dev, before-frontend-dev, check-backend, or check-frontend files.
2. **If customized**: Help merge their customizations into the new unified `before-dev` and `check` files. The new files use `python3 ./.trellis/scripts/get_context.py --mode packages` to auto-detect which specs to load, replacing the hardcoded backend/frontend split.
3. **If not customized**: Just run `trellis update` — safe-file-delete will handle cleanup automatically.
4. **Python scripts**: No user action needed. The refactoring preserves all entry paths. If the user has custom scripts that import from `.trellis/scripts/common/`, they may need to update imports (e.g., `from common.io import read_json` instead of inline `_read_json_file`).

40
.trellis/tasks/04-07-migrate-to-0.4.0-beta.8/task.json

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
{
"title": "Migrate to v0.4.0-beta.8",
"description": "Breaking change migration from v0.3.10 to v0.4.0-beta.8",
"status": "planning",
"dev_type": null,
"scope": "migration",
"priority": "P1",
"creator": "trellis-update",
"assignee": "name=hechang27-sprt\ninitialized_at=2026-03-24T18:13:15.726010",
"createdAt": "2026-04-07",
"completedAt": null,
"branch": null,
"base_branch": null,
"worktree_path": null,
"current_phase": 0,
"next_action": [
{
"phase": 1,
"action": "review-guide"
},
{
"phase": 2,
"action": "update-files"
},
{
"phase": 3,
"action": "run-migrate"
},
{
"phase": 4,
"action": "test"
}
],
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"meta": {}
}
Loading…
Cancel
Save