diff --git a/.agents/skills/before-backend-dev/SKILL.md b/.agents/skills/before-backend-dev/SKILL.md deleted file mode 100644 index 0615694..0000000 --- a/.agents/skills/before-backend-dev/SKILL.md +++ /dev/null @@ -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. diff --git a/.agents/skills/before-dev/SKILL.md b/.agents/skills/before-dev/SKILL.md new file mode 100644 index 0000000..53a2e0e --- /dev/null +++ b/.agents/skills/before-dev/SKILL.md @@ -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///index.md + ``` + Follow the **"Pre-Development Checklist"** section in the index. + +4. **Read the specific guideline files** listed in the Pre-Development Checklist that are relevant to your task. The index is NOT the goal — it points you to the actual guideline files (e.g., `error-handling.md`, `conventions.md`, `mock-strategies.md`). Read those files to understand the coding standards and patterns. + +5. **Always read shared guides**: + ```bash + cat .trellis/spec/guides/index.md + ``` + +6. Understand the coding standards and patterns you need to follow, then proceed with your development plan. + +This step is **mandatory** before writing any code. diff --git a/.agents/skills/before-frontend-dev/SKILL.md b/.agents/skills/before-frontend-dev/SKILL.md deleted file mode 100644 index b048b8d..0000000 --- a/.agents/skills/before-frontend-dev/SKILL.md +++ /dev/null @@ -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. diff --git a/.agents/skills/brainstorm/SKILL.md b/.agents/skills/brainstorm/SKILL.md index e26005d..19dacdf 100644 --- a/.agents/skills/brainstorm/SKILL.md +++ b/.agents/skills/brainstorm/SKILL.md @@ -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) diff --git a/.agents/skills/break-loop/SKILL.md b/.agents/skills/break-loop/SKILL.md index 0f5f4e1..2f98b34 100644 --- a/.agents/skills/break-loop/SKILL.md +++ b/.agents/skills/break-loop/SKILL.md @@ -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 diff --git a/.agents/skills/check-backend/SKILL.md b/.agents/skills/check-backend/SKILL.md deleted file mode 100644 index dce49bc..0000000 --- a/.agents/skills/check-backend/SKILL.md +++ /dev/null @@ -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 diff --git a/.agents/skills/check-cross-layer/SKILL.md b/.agents/skills/check-cross-layer/SKILL.md index 3a3d977..d5a53fb 100644 --- a/.agents/skills/check-cross-layer/SKILL.md +++ b/.agents/skills/check-cross-layer/SKILL.md @@ -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 diff --git a/.agents/skills/check-frontend/SKILL.md b/.agents/skills/check-frontend/SKILL.md deleted file mode 100644 index cdef3cb..0000000 --- a/.agents/skills/check-frontend/SKILL.md +++ /dev/null @@ -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 diff --git a/.agents/skills/check/SKILL.md b/.agents/skills/check/SKILL.md new file mode 100644 index 0000000..c4a2b71 --- /dev/null +++ b/.agents/skills/check/SKILL.md @@ -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///index.md + ``` + Follow the **"Quality Check"** section in the index. + +4. **Read the specific guideline files** referenced in the Quality Check section (e.g., `quality-guidelines.md`, `conventions.md`). The index is NOT the goal — it points you to the actual guideline files. Read those files and review your code against them. + +5. **Run lint and typecheck** for the affected package. + +6. **Report any violations** and fix them if found. diff --git a/.agents/skills/create-command/SKILL.md b/.agents/skills/create-command/SKILL.md index eed6daf..2c7d98d 100644 --- a/.agents/skills/create-command/SKILL.md +++ b/.agents/skills/create-command/SKILL.md @@ -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: | 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` | diff --git a/.agents/skills/finish-work/SKILL.md b/.agents/skills/finish-work/SKILL.md index 75ec368..3de784b 100644 --- a/.agents/skills/finish-work/SKILL.md +++ b/.agents/skills/finish-work/SKILL.md @@ -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 diff --git a/.agents/skills/improve-ut/SKILL.md b/.agents/skills/improve-ut/SKILL.md new file mode 100644 index 0000000..051a1fb --- /dev/null +++ b/.agents/skills/improve-ut/SKILL.md @@ -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 +- +``` diff --git a/.agents/skills/integrate-skill/SKILL.md b/.agents/skills/integrate-skill/SKILL.md index 4110788..b9f6da1 100644 --- a/.agents/skills/integrate-skill/SKILL.md +++ b/.agents/skills/integrate-skill/SKILL.md @@ -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 diff --git a/.agents/skills/onboard/SKILL.md b/.agents/skills/onboard/SKILL.md index 3621271..03d848c 100644 --- a/.agents/skills/onboard/SKILL.md +++ b/.agents/skills/onboard/SKILL.md @@ -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. --- -### $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 - 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 **[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 ### 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 ### 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 diff --git a/.agents/skills/record-session/SKILL.md b/.agents/skills/record-session/SKILL.md index 34dbbda..1340556 100644 --- a/.agents/skills/record-session/SKILL.md +++ b/.agents/skills/record-session/SKILL.md @@ -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 \ --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 **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 | 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 ` | Archive completed task (auto-commits) | | `python3 ./.trellis/scripts/task.py list` | List active tasks | diff --git a/.agents/skills/start/SKILL.md b/.agents/skills/start/SKILL.md index 0adfc87..a559240 100644 --- a/.agents/skills/start/SKILL.md +++ b/.agents/skills/start/SKILL.md @@ -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. ### 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///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`). diff --git a/.agents/skills/update-spec/SKILL.md b/.agents/skills/update-spec/SKILL.md index 435327b..63b0297 100644 --- a/.agents/skills/update-spec/SKILL.md +++ b/.agents/skills/update-spec/SKILL.md @@ -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 diff --git a/.claude/agents/dispatch.md b/.claude/agents/dispatch.md index 827c392..2bec15c 100644 --- a/.claude/agents/dispatch.md +++ b/.claude/agents/dispatch.md @@ -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" diff --git a/.claude/agents/implement.md b/.claude/agents/implement.md index 60eaa5d..62442b0 100644 --- a/.claude/agents/implement.md +++ b/.claude/agents/implement.md @@ -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///` +- Shared guides: `.trellis/spec/guides/` ### 2. Understand Requirements diff --git a/.claude/commands/trellis/before-backend-dev.md b/.claude/commands/trellis/before-backend-dev.md deleted file mode 100644 index 7dfcd36..0000000 --- a/.claude/commands/trellis/before-backend-dev.md +++ /dev/null @@ -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. diff --git a/.claude/commands/trellis/before-dev.md b/.claude/commands/trellis/before-dev.md new file mode 100644 index 0000000..d080765 --- /dev/null +++ b/.claude/commands/trellis/before-dev.md @@ -0,0 +1,29 @@ +Read the relevant development guidelines before starting your task. + +Execute these steps: + +1. **Discover packages and their spec layers**: + ```bash + python3 ./.trellis/scripts/get_context.py --mode packages + ``` + +2. **Identify which specs apply** to your task based on: + - Which package you're modifying (e.g., `cli/`, `docs-site/`) + - What type of work (backend, frontend, unit-test, docs, etc.) + +3. **Read the spec index** for each relevant module: + ```bash + cat .trellis/spec///index.md + ``` + Follow the **"Pre-Development Checklist"** section in the index. + +4. **Read the specific guideline files** listed in the Pre-Development Checklist that are relevant to your task. The index is NOT the goal — it points you to the actual guideline files (e.g., `error-handling.md`, `conventions.md`, `mock-strategies.md`). Read those files to understand the coding standards and patterns. + +5. **Always read shared guides**: + ```bash + cat .trellis/spec/guides/index.md + ``` + +6. Understand the coding standards and patterns you need to follow, then proceed with your development plan. + +This step is **mandatory** before writing any code. diff --git a/.claude/commands/trellis/before-frontend-dev.md b/.claude/commands/trellis/before-frontend-dev.md deleted file mode 100644 index 9687edc..0000000 --- a/.claude/commands/trellis/before-frontend-dev.md +++ /dev/null @@ -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. diff --git a/.claude/commands/trellis/check-backend.md b/.claude/commands/trellis/check-backend.md deleted file mode 100644 index 886f5c9..0000000 --- a/.claude/commands/trellis/check-backend.md +++ /dev/null @@ -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 diff --git a/.claude/commands/trellis/check-frontend.md b/.claude/commands/trellis/check-frontend.md deleted file mode 100644 index 3771ae3..0000000 --- a/.claude/commands/trellis/check-frontend.md +++ /dev/null @@ -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 diff --git a/.claude/commands/trellis/check.md b/.claude/commands/trellis/check.md new file mode 100644 index 0000000..8e3e272 --- /dev/null +++ b/.claude/commands/trellis/check.md @@ -0,0 +1,25 @@ +Check if the code you just wrote follows the development guidelines. + +Execute these steps: + +1. **Identify changed files**: + ```bash + git diff --name-only HEAD + ``` + +2. **Determine which spec modules apply** based on the changed file paths: + ```bash + python3 ./.trellis/scripts/get_context.py --mode packages + ``` + +3. **Read the spec index** for each relevant module: + ```bash + cat .trellis/spec///index.md + ``` + Follow the **"Quality Check"** section in the index. + +4. **Read the specific guideline files** referenced in the Quality Check section (e.g., `quality-guidelines.md`, `conventions.md`). The index is NOT the goal — it points you to the actual guideline files. Read those files and review your code against them. + +5. **Run lint and typecheck** for the affected package. + +6. **Report any violations** and fix them if found. diff --git a/.claude/commands/trellis/create-command.md b/.claude/commands/trellis/create-command.md index 121d37f..42397f4 100644 --- a/.claude/commands/trellis/create-command.md +++ b/.claude/commands/trellis/create-command.md @@ -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` | diff --git a/.claude/commands/trellis/onboard.md b/.claude/commands/trellis/onboard.md index 732f80d..2d4dabf 100644 --- a/.claude/commands/trellis/onboard.md +++ b/.claude/commands/trellis/onboard.md @@ -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 - 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 **[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 ### 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 **[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 ## 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. diff --git a/.claude/commands/trellis/parallel.md b/.claude/commands/trellis/parallel.md index 3db5c3e..0be6dc6 100644 --- a/.claude/commands/trellis/parallel.md +++ b/.claude/commands/trellis/parallel.md @@ -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 ``` diff --git a/.claude/commands/trellis/record-session.md b/.claude/commands/trellis/record-session.md index 4a7e6ff..8bea4f9 100644 --- a/.claude/commands/trellis/record-session.md +++ b/.claude/commands/trellis/record-session.md @@ -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 **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 | 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 ` | Archive completed task (auto-commits) | | `python3 ./.trellis/scripts/task.py list` | List active tasks | diff --git a/.claude/commands/trellis/start.md b/.claude/commands/trellis/start.md index 39fd44f..1815359 100644 --- a/.claude/commands/trellis/start.md +++ b/.claude/commands/trellis/start.md @@ -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///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`). diff --git a/.claude/hooks/inject-subagent-context.py b/.claude/hooks/inject-subagent-context.py index 95e7f5c..7cc1d6f 100644 --- a/.claude/hooks/inject-subagent-context.py +++ b/.claude/hooks/inject-subagent-context.py @@ -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: 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: """ 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""" diff --git a/.claude/hooks/session-start.py b/.claude/hooks/session-start.py index eeee0c1..5088281 100644 --- a/.claude/hooks/session-start.py +++ b/.claude/hooks/session-start.py @@ -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// 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//` directories.\n" + f"Monorepo packages: {', '.join(sorted(packages.keys()))}\n" + f"Please reorganize: `spec/backend/` -> `spec//backend/`" + ) + return ( + f"[!] Partial spec migration detected: packages {', '.join(missing)} " + f"still missing `spec//` 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(): 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(""" @@ -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"\n{legacy_warning}\n\n\n") + output.write("\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. 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///index.md) + # Nested package dirs (monorepo: spec///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. output.write(start_md) output.write("\n\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"\n{task_status}\n\n\n") diff --git a/.codex/agents/check.toml b/.codex/agents/check.toml new file mode 100644 index 0000000..b5d80e0 --- /dev/null +++ b/.codex/agents/check.toml @@ -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: +- File: +- Issue: +- Recommendation: + +If no issues are found, say so explicitly. +""" diff --git a/.codex/agents/implement.toml b/.codex/agents/implement.toml new file mode 100644 index 0000000..16db983 --- /dev/null +++ b/.codex/agents/implement.toml @@ -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 +""" diff --git a/.codex/agents/research.toml b/.codex/agents/research.toml new file mode 100644 index 0000000..b504a69 --- /dev/null +++ b/.codex/agents/research.toml @@ -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 +- : + +## Code Patterns Found +- : + +## Files to Modify +- : + +## Risks / Follow-ups +- +""" diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..e4ec8e3 --- /dev/null +++ b/.codex/config.toml @@ -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"] diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..bc23807 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 .codex/hooks/session-start.py", + "timeout": 15, + "statusMessage": "Loading Trellis context..." + } + ] + } + ] + } +} diff --git a/.codex/hooks/session-start.py b/.codex/hooks/session-start.py new file mode 100644 index 0000000..244d22d --- /dev/null +++ b/.codex/hooks/session-start.py @@ -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(""" +You are starting a new session in a Trellis-managed project. +Read and follow all instructions below carefully. + + +""") + + output.write("\n") + context_script = trellis_dir / "scripts" / "get_context.py" + output.write(run_script(context_script)) + output.write("\n\n\n") + + output.write("\n") + workflow_content = read_file(trellis_dir / "workflow.md", "No workflow.md found") + output.write(workflow_content) + output.write("\n\n\n") + + output.write("\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("\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("\n") + output.write(read_file(start_skill)) + output.write("\n\n\n") + + task_status = _get_task_status(trellis_dir) + output.write(f"\n{task_status}\n\n\n") + + output.write(""" +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 to handle their request. +If there is an active task, ask whether to continue it. +""") + + 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() diff --git a/.codex/skills/parallel/SKILL.md b/.codex/skills/parallel/SKILL.md new file mode 100644 index 0000000..4f56843 --- /dev/null +++ b/.codex/skills/parallel/SKILL.md @@ -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 "" \ + --type "" \ + --requirement "" \ + --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 "" --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 diff --git a/.trellis/.template-hashes.json b/.trellis/.template-hashes.json index ac25b39..7da8aa3 100644 --- a/.trellis/.template-hashes.json +++ b/.trellis/.template-hashes.json @@ -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 @@ ".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" } \ No newline at end of file diff --git a/.trellis/.version b/.trellis/.version index 81de5c5..f624f28 100644 --- a/.trellis/.version +++ b/.trellis/.version @@ -1 +1 @@ -0.3.10 \ No newline at end of file +0.4.0-beta.8 \ No newline at end of file diff --git a/.trellis/config.yaml b/.trellis/config.yaml index 7d18551..17c7d59 100644 --- a/.trellis/config.yaml +++ b/.trellis/config.yaml @@ -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 diff --git a/.trellis/scripts/add_session.py b/.trellis/scripts/add_session.py index 71606e5..c1bcf4c 100644 --- a/.trellis/scripts/add_session.py +++ b/.trellis/scripts/add_session.py @@ -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 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( 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( 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( 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( 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( 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( 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( # 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: 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: 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, ) diff --git a/.trellis/scripts/common/cli_adapter.py b/.trellis/scripts/common/cli_adapter.py index ce3323b..483e62e 100644 --- a/.trellis/scripts/common/cli_adapter.py +++ b/.trellis/scripts/common/cli_adapter.py @@ -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: - gemini: Gemini CLI - antigravity: Antigravity (workflow-based) - qoder: Qoder +- codebuddy: CodeBuddy Usage: from common.cli_adapter import CLIAdapter @@ -43,6 +44,7 @@ Platform = Literal[ "gemini", "antigravity", "qoder", + "codebuddy", ] @@ -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: 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: return ".agent" elif self.platform == "qoder": return ".qoder" + elif self.platform == "codebuddy": + return ".codebuddy" else: return ".claude" @@ -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: 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: """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: return {} elif self.platform == "qoder": return {} + elif self.platform == "codebuddy": + return {} else: return {"CLAUDE_NON_INTERACTIVE": "1"} @@ -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: ) 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: ) 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: return "agy" elif self.platform == "qoder": return "qodercli" + elif self.platform == "codebuddy": + return "codebuddy" else: return "claude" @@ -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: """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: "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: 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: "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: 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: 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" diff --git a/.trellis/scripts/common/config.py b/.trellis/scripts/common/config.py index 601ab32..ea1bdc3 100644 --- a/.trellis/scripts/common/config.py +++ b/.trellis/scripts/common/config.py @@ -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 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]: if isinstance(commands, list): return [str(c) for c in commands] return [] + + +# ============================================================================= +# Monorepo / Packages +# ============================================================================= + + +def get_packages(repo_root: Path | None = None) -> dict[str, dict] | None: + """Get monorepo package declarations. + + Returns: + Dict mapping package name to its config (path, type, etc.), + or None if not configured (single-repo mode). + + Example return: + {"cli": {"path": "packages/cli"}, "docs-site": {"path": "docs-site", "type": "submodule"}} + """ + config = _load_config(repo_root) + packages = config.get("packages") + if not isinstance(packages, dict): + return None + # Ensure each value is a dict (filter out scalar entries) + filtered = {k: v for k, v in packages.items() if isinstance(v, dict)} + if not filtered: + return None + return filtered + + +def get_default_package(repo_root: Path | None = None) -> str | None: + """Get the default package name from config. + + Returns: + Package name string, or None if not configured. + """ + config = _load_config(repo_root) + value = config.get("default_package") + return str(value) if value else None + + +def get_submodule_packages(repo_root: Path | None = None) -> dict[str, str]: + """Get packages that are git submodules. + + Returns: + Dict mapping package name to its path for submodule-type packages. + Empty dict if none configured. + + Example return: + {"docs-site": "docs-site"} + """ + packages = get_packages(repo_root) + if packages is None: + return {} + return { + name: cfg.get("path", name) + for name, cfg in packages.items() + if cfg.get("type") == "submodule" + } + + +def get_git_packages(repo_root: Path | None = None) -> dict[str, str]: + """Get packages that have their own independent git repository. + + These are sub-directories with their own .git (not submodules), + marked with ``git: true`` in config.yaml. + + Returns: + Dict mapping package name to its path for git-repo packages. + Empty dict if none configured. + + Example config:: + + packages: + backend: + path: iqs + git: true + + Example return:: + + {"backend": "iqs"} + """ + packages = get_packages(repo_root) + if packages is None: + return {} + return { + name: cfg.get("path", name) + for name, cfg in packages.items() + if _is_true_config_value(cfg.get("git")) + } + + +def is_monorepo(repo_root: Path | None = None) -> bool: + """Check if the project is configured as a monorepo (has packages in config).""" + return get_packages(repo_root) is not None + + +def get_spec_base(package: str | None = None, repo_root: Path | None = None) -> str: + """Get the spec directory base path relative to .trellis/. + + Single-repo: returns "spec" + Monorepo with package: returns "spec/<package>" + Monorepo without package: returns "spec" (caller should specify package) + """ + if package and is_monorepo(repo_root): + return f"spec/{package}" + return "spec" + + +def validate_package(package: str, repo_root: Path | None = None) -> bool: + """Check if a package name is valid in this project. + + Single-repo (no packages configured): always returns True. + Monorepo: returns True only if package exists in config.yaml packages. + """ + packages = get_packages(repo_root) + if packages is None: + return True # Single-repo, no validation needed + return package in packages + + +def resolve_package( + task_package: str | None = None, + repo_root: Path | None = None, +) -> str | None: + """Resolve package from inferred sources with validation. + + Checks in order: task_package → default_package. + Invalid inferred values print a warning to stderr and are skipped. + + Returns: + Resolved package name, or None if no valid package found. + + Note: + CLI --package should be validated separately by the caller + (fail-fast with available packages list on error). + """ + packages = get_packages(repo_root) + if packages is None: + return None # Single-repo, no package needed + + # Try task_package (guard against non-string values from malformed JSON) + if task_package and isinstance(task_package, str): + if task_package in packages: + return task_package + print( + f"Warning: task.json package '{task_package}' not found in config, skipping", + file=sys.stderr, + ) + + # Try default_package + default = get_default_package(repo_root) + if default: + if default in packages: + return default + print( + f"Warning: default_package '{default}' not found in config, skipping", + file=sys.stderr, + ) + + return None + + +def get_spec_scope(repo_root: Path | None = None) -> list[str] | str | None: + """Get session.spec_scope configuration. + + Returns: + list[str]: Package names to include in spec scanning. + str: "active_task" to use current task's package. + None: No scope configured (scan all packages). + """ + config = _load_config(repo_root) + session = config.get("session") + if not isinstance(session, dict): + return None + + scope = session.get("spec_scope") + if scope is None: + return None + if isinstance(scope, str): + return scope # e.g. "active_task" + if isinstance(scope, list): + return [str(s) for s in scope] + return None diff --git a/.trellis/scripts/common/developer.py b/.trellis/scripts/common/developer.py index 7f3cf0c..c203a31 100644 --- a/.trellis/scripts/common/developer.py +++ b/.trellis/scripts/common/developer.py @@ -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 --> --- diff --git a/.trellis/scripts/common/git.py b/.trellis/scripts/common/git.py new file mode 100644 index 0000000..c4bf29f --- /dev/null +++ b/.trellis/scripts/common/git.py @@ -0,0 +1,31 @@ +""" +Git command execution utility. + +Single source of truth for running git commands across all Trellis scripts. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def run_git(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]: + """Run a git command and return (returncode, stdout, stderr). + + Uses UTF-8 encoding with -c i18n.logOutputEncoding=UTF-8 to ensure + consistent output across all platforms (Windows, macOS, Linux). + """ + try: + git_args = ["git", "-c", "i18n.logOutputEncoding=UTF-8"] + args + result = subprocess.run( + git_args, + cwd=cwd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + return result.returncode, result.stdout, result.stderr + except Exception as e: + return 1, "", str(e) diff --git a/.trellis/scripts/common/git_context.py b/.trellis/scripts/common/git_context.py index 39b9ff5..397c6d2 100644 --- a/.trellis/scripts/common/git_context.py +++ b/.trellis/scripts/common/git_context.py @@ -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: 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: 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: 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() diff --git a/.trellis/scripts/common/io.py b/.trellis/scripts/common/io.py new file mode 100644 index 0000000..44288f4 --- /dev/null +++ b/.trellis/scripts/common/io.py @@ -0,0 +1,37 @@ +""" +JSON file I/O utilities. + +Provides read_json and write_json as the single source of truth +for JSON file operations across all Trellis scripts. +""" + +from __future__ import annotations + +import json +from pathlib import Path + + +def read_json(path: Path) -> dict | None: + """Read and parse a JSON file. + + Returns None if the file doesn't exist, is invalid JSON, or can't be read. + """ + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + + +def write_json(path: Path, data: dict) -> bool: + """Write dict to JSON file with pretty formatting. + + Returns True on success, False on error. + """ + try: + path.write_text( + json.dumps(data, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + return True + except (OSError, IOError): + return False diff --git a/.trellis/scripts/common/log.py b/.trellis/scripts/common/log.py new file mode 100644 index 0000000..839c643 --- /dev/null +++ b/.trellis/scripts/common/log.py @@ -0,0 +1,45 @@ +""" +Terminal output utilities: colors and structured logging. + +Single source of truth for Colors and log_* functions +used across all Trellis scripts. +""" + +from __future__ import annotations + + +class Colors: + """ANSI color codes for terminal output.""" + + RED = "\033[0;31m" + GREEN = "\033[0;32m" + YELLOW = "\033[1;33m" + BLUE = "\033[0;34m" + CYAN = "\033[0;36m" + DIM = "\033[2m" + NC = "\033[0m" # No Color / Reset + + +def colored(text: str, color: str) -> str: + """Apply ANSI color to text.""" + return f"{color}{text}{Colors.NC}" + + +def log_info(msg: str) -> None: + """Print info-level message with [INFO] prefix.""" + print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}") + + +def log_success(msg: str) -> None: + """Print success message with [SUCCESS] prefix.""" + print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}") + + +def log_warn(msg: str) -> None: + """Print warning message with [WARN] prefix.""" + print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}") + + +def log_error(msg: str) -> None: + """Print error message with [ERROR] prefix.""" + print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}") diff --git a/.trellis/scripts/common/packages_context.py b/.trellis/scripts/common/packages_context.py new file mode 100644 index 0000000..e7d4e8c --- /dev/null +++ b/.trellis/scripts/common/packages_context.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +""" +Package discovery and context output. + +Provides: + get_packages_info - Get structured package info + get_packages_section - Build PACKAGES text section + get_context_packages_text - Full packages text output (--mode packages) + get_context_packages_json - Full packages JSON output (--mode packages --json) +""" + +from __future__ import annotations + +from pathlib import Path + +from .config import _is_true_config_value, get_default_package, get_packages, get_spec_scope +from .paths import ( + DIR_SPEC, + DIR_WORKFLOW, + get_current_task, + get_repo_root, +) +from .tasks import load_task + + +# ============================================================================= +# Internal Helpers +# ============================================================================= + +def _scan_spec_layers(spec_dir: Path, package: str | None = None) -> list[str]: + """Scan spec directory for available layers (subdirectories). + + For monorepo: scans spec/<package>/ + For single-repo: scans spec/ + """ + target = spec_dir / package if package else spec_dir + if not target.is_dir(): + return [] + return sorted( + d.name for d in target.iterdir() if d.is_dir() and d.name != "guides" + ) + + +def _get_active_task_package(repo_root: Path) -> str | None: + """Get the package field from the active task's task.json.""" + current = get_current_task(repo_root) + if not current: + return None + ct = load_task(repo_root / current) + return ct.package if ct and ct.package else None + + +def _resolve_scope_set( + packages: dict, + spec_scope, + task_pkg: str | None, + default_pkg: str | None, +) -> set | None: + """Resolve spec_scope to a set of allowed package names, or None for full scan.""" + if not packages: + return None + + if spec_scope is None: + return None + + if isinstance(spec_scope, str) and spec_scope == "active_task": + if task_pkg and task_pkg in packages: + return {task_pkg} + if default_pkg and default_pkg in packages: + return {default_pkg} + return None + + if isinstance(spec_scope, list): + valid = {e for e in spec_scope if e in packages} + if valid: + return valid + # All invalid: fallback + if task_pkg and task_pkg in packages: + return {task_pkg} + if default_pkg and default_pkg in packages: + return {default_pkg} + return None + + return None + + +# ============================================================================= +# Public Functions +# ============================================================================= + +def get_packages_info(repo_root: Path) -> list[dict]: + """Get structured package info for monorepo projects. + + Returns list of dicts with keys: name, path, type, default, specLayers, + isSubmodule, isGitRepo. + Returns empty list for single-repo projects. + """ + packages = get_packages(repo_root) + if not packages: + return [] + + default_pkg = get_default_package(repo_root) + spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC + result = [] + + for pkg_name, pkg_config in packages.items(): + pkg_path = pkg_config.get("path", pkg_name) if isinstance(pkg_config, dict) else str(pkg_config) + pkg_type = pkg_config.get("type", "local") if isinstance(pkg_config, dict) else "local" + pkg_git = pkg_config.get("git", False) if isinstance(pkg_config, dict) else False + layers = _scan_spec_layers(spec_dir, pkg_name) + + result.append({ + "name": pkg_name, + "path": pkg_path, + "type": pkg_type, + "default": pkg_name == default_pkg, + "specLayers": layers, + "isSubmodule": pkg_type == "submodule", + "isGitRepo": _is_true_config_value(pkg_git), + }) + + return result + + +def get_packages_section(repo_root: Path) -> str: + """Build the PACKAGES section for text output.""" + spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC + pkg_info = get_packages_info(repo_root) + + lines: list[str] = [] + lines.append("## PACKAGES") + + if not pkg_info: + lines.append("(single-repo mode)") + layers = _scan_spec_layers(spec_dir) + if layers: + lines.append(f"Spec layers: {', '.join(layers)}") + return "\n".join(lines) + + default_pkg = get_default_package(repo_root) + + for pkg in pkg_info: + layers_str = f" [{', '.join(pkg['specLayers'])}]" if pkg["specLayers"] else "" + submodule_tag = " (submodule)" if pkg["isSubmodule"] else "" + git_repo_tag = " (git repo)" if pkg["isGitRepo"] else "" + default_tag = " *" if pkg["default"] else "" + lines.append( + f"- {pkg['name']:<16} {pkg['path']:<20}{layers_str}{submodule_tag}{git_repo_tag}{default_tag}" + ) + + if default_pkg: + lines.append(f"Default package: {default_pkg}") + + return "\n".join(lines) + + +def get_context_packages_text(repo_root: Path | None = None) -> str: + """Get packages context as formatted text (for --mode packages).""" + if repo_root is None: + repo_root = get_repo_root() + + pkg_info = get_packages_info(repo_root) + lines: list[str] = [] + + if not pkg_info: + spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC + lines.append("Single-repo project (no packages configured)") + lines.append("") + layers = _scan_spec_layers(spec_dir) + if layers: + lines.append(f"Spec layers: {', '.join(layers)}") + return "\n".join(lines) + + # Resolve scope for annotations + packages_dict = get_packages(repo_root) or {} + default_pkg = get_default_package(repo_root) + spec_scope = get_spec_scope(repo_root) + task_pkg = _get_active_task_package(repo_root) + scope_set = _resolve_scope_set(packages_dict, spec_scope, task_pkg, default_pkg) + + lines.append("## PACKAGES") + lines.append("") + for pkg in pkg_info: + default_tag = " (default)" if pkg["default"] else "" + type_tag = f" [{pkg['type']}]" if pkg["type"] != "local" else "" + git_tag = " [git repo]" if pkg["isGitRepo"] else "" + + # Scope annotation + scope_tag = "" + if scope_set is not None and pkg["name"] not in scope_set: + scope_tag = " (out of scope)" + + lines.append(f"### {pkg['name']}{default_tag}{type_tag}{git_tag}{scope_tag}") + lines.append(f"Path: {pkg['path']}") + if pkg["specLayers"]: + lines.append(f"Spec layers: {', '.join(pkg['specLayers'])}") + for layer in pkg["specLayers"]: + lines.append(f" - .trellis/spec/{pkg['name']}/{layer}/index.md") + else: + lines.append("Spec: not configured") + lines.append("") + + # Also show shared guides + guides_dir = repo_root / DIR_WORKFLOW / DIR_SPEC / "guides" + if guides_dir.is_dir(): + lines.append("### Shared Guides (always included)") + lines.append("Path: .trellis/spec/guides/index.md") + lines.append("") + + return "\n".join(lines) + + +def get_context_packages_json(repo_root: Path | None = None) -> dict: + """Get packages context as a dictionary (for --mode packages --json).""" + if repo_root is None: + repo_root = get_repo_root() + + pkg_info = get_packages_info(repo_root) + + if not pkg_info: + spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC + layers = _scan_spec_layers(spec_dir) + return { + "mode": "single-repo", + "specLayers": layers, + } + + default_pkg = get_default_package(repo_root) + spec_scope = get_spec_scope(repo_root) + task_pkg = _get_active_task_package(repo_root) + + return { + "mode": "monorepo", + "packages": pkg_info, + "defaultPackage": default_pkg, + "specScope": spec_scope, + "activeTaskPackage": task_pkg, + } diff --git a/.trellis/scripts/common/paths.py b/.trellis/scripts/common/paths.py index dcbb66b..84bedbc 100644 --- a/.trellis/scripts/common/paths.py +++ b/.trellis/scripts/common/paths.py @@ -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) # ============================================================================= diff --git a/.trellis/scripts/common/phase.py b/.trellis/scripts/common/phase.py index c3a8039..cf7670e 100644 --- a/.trellis/scripts/common/phase.py +++ b/.trellis/scripts/common/phase.py @@ -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: 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: 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: 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: 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: 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: 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: 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: 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 diff --git a/.trellis/scripts/common/registry.py b/.trellis/scripts/common/registry.py index 7f2bc6f..73e523d 100644 --- a/.trellis/scripts/common/registry.py +++ b/.trellis/scripts/common/registry.py @@ -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: 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( 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( 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( 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( 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: 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( 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( 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( 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]: 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 [] diff --git a/.trellis/scripts/common/session_context.py b/.trellis/scripts/common/session_context.py new file mode 100644 index 0000000..52c6a4a --- /dev/null +++ b/.trellis/scripts/common/session_context.py @@ -0,0 +1,562 @@ +#!/usr/bin/env python3 +""" +Session context generation (default + record modes). + +Provides: + get_context_json - JSON output for default mode + get_context_text - Text output for default mode + get_context_record_json - JSON for record mode + get_context_text_record - Text for record mode + output_json - Print JSON + output_text - Print text +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from .config import get_git_packages +from .git import run_git +from .packages_context import get_packages_section +from .tasks import iter_active_tasks, load_task, get_all_statuses, children_progress +from .paths import ( + DIR_SCRIPTS, + DIR_SPEC, + DIR_TASKS, + DIR_WORKFLOW, + DIR_WORKSPACE, + count_lines, + get_active_journal_file, + get_current_task, + get_developer, + get_repo_root, + get_tasks_dir, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + +def _collect_package_git_info(repo_root: Path) -> list[dict]: + """Collect git status and recent commits for packages with independent git repos. + + Only packages marked with ``git: true`` in config.yaml are included. + + Returns: + List of dicts with keys: name, path, branch, isClean, + uncommittedChanges, recentCommits. + Empty list if no git-repo packages are configured. + """ + git_pkgs = get_git_packages(repo_root) + if not git_pkgs: + return [] + + result = [] + for pkg_name, pkg_path in git_pkgs.items(): + pkg_dir = repo_root / pkg_path + if not (pkg_dir / ".git").exists(): + continue + + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=pkg_dir) + branch = branch_out.strip() or "unknown" + + _, status_out, _ = run_git(["status", "--porcelain"], cwd=pkg_dir) + changes = len([l for l in status_out.splitlines() if l.strip()]) + + _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=pkg_dir) + commits = [] + for line in log_out.splitlines(): + if line.strip(): + parts = line.split(" ", 1) + if len(parts) >= 2: + commits.append({"hash": parts[0], "message": parts[1]}) + elif len(parts) == 1: + commits.append({"hash": parts[0], "message": ""}) + + result.append({ + "name": pkg_name, + "path": pkg_path, + "branch": branch, + "isClean": changes == 0, + "uncommittedChanges": changes, + "recentCommits": commits, + }) + + return result + + +def _append_package_git_context(lines: list[str], package_git_info: list[dict]) -> None: + """Append Git status and recent commits for package repositories.""" + for pkg in package_git_info: + lines.append(f"## GIT STATUS ({pkg['name']}: {pkg['path']})") + lines.append(f"Branch: {pkg['branch']}") + if pkg["isClean"]: + lines.append("Working directory: Clean") + else: + lines.append( + f"Working directory: {pkg['uncommittedChanges']} uncommitted change(s)" + ) + lines.append("") + lines.append(f"## RECENT COMMITS ({pkg['name']}: {pkg['path']})") + if pkg["recentCommits"]: + for commit in pkg["recentCommits"]: + lines.append(f"{commit['hash']} {commit['message']}") + else: + lines.append("(no commits)") + lines.append("") + + +# ============================================================================= +# JSON Output +# ============================================================================= + +def get_context_json(repo_root: Path | None = None) -> dict: + """Get context as a dictionary. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Context dictionary. + """ + if repo_root is None: + repo_root = get_repo_root() + + developer = get_developer(repo_root) + tasks_dir = get_tasks_dir(repo_root) + journal_file = get_active_journal_file(repo_root) + + journal_lines = 0 + journal_relative = "" + if journal_file and developer: + journal_lines = count_lines(journal_file) + journal_relative = ( + f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}" + ) + + # Git info + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) + branch = branch_out.strip() or "unknown" + + _, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root) + git_status_count = len([line for line in status_out.splitlines() if line.strip()]) + is_clean = git_status_count == 0 + + # Recent commits + _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root) + commits = [] + for line in log_out.splitlines(): + if line.strip(): + parts = line.split(" ", 1) + if len(parts) >= 2: + commits.append({"hash": parts[0], "message": parts[1]}) + elif len(parts) == 1: + commits.append({"hash": parts[0], "message": ""}) + + # Tasks + tasks = [ + { + "dir": t.dir_name, + "name": t.name, + "status": t.status, + "children": list(t.children), + "parent": t.parent, + } + for t in iter_active_tasks(tasks_dir) + ] + + # Package git repos (independent sub-repositories) + pkg_git_info = _collect_package_git_info(repo_root) + + result = { + "developer": developer or "", + "git": { + "branch": branch, + "isClean": is_clean, + "uncommittedChanges": git_status_count, + "recentCommits": commits, + }, + "tasks": { + "active": tasks, + "directory": f"{DIR_WORKFLOW}/{DIR_TASKS}", + }, + "journal": { + "file": journal_relative, + "lines": journal_lines, + "nearLimit": journal_lines > 1800, + }, + } + + if pkg_git_info: + result["packageGit"] = pkg_git_info + + return result + + +def output_json(repo_root: Path | None = None) -> None: + """Output context in JSON format. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + """ + context = get_context_json(repo_root) + print(json.dumps(context, indent=2, ensure_ascii=False)) + + +# ============================================================================= +# Text Output +# ============================================================================= + +def get_context_text(repo_root: Path | None = None) -> str: + """Get context as formatted text. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Formatted text output. + """ + if repo_root is None: + repo_root = get_repo_root() + + lines = [] + lines.append("========================================") + lines.append("SESSION CONTEXT") + lines.append("========================================") + lines.append("") + + developer = get_developer(repo_root) + + # Developer section + lines.append("## DEVELOPER") + if not developer: + lines.append( + f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>" + ) + return "\n".join(lines) + + lines.append(f"Name: {developer}") + lines.append("") + + # Git status + lines.append("## GIT STATUS") + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) + branch = branch_out.strip() or "unknown" + lines.append(f"Branch: {branch}") + + _, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root) + status_lines = [line for line in status_out.splitlines() if line.strip()] + status_count = len(status_lines) + + if status_count == 0: + lines.append("Working directory: Clean") + else: + lines.append(f"Working directory: {status_count} uncommitted change(s)") + lines.append("") + lines.append("Changes:") + _, short_out, _ = run_git(["status", "--short"], cwd=repo_root) + for line in short_out.splitlines()[:10]: + lines.append(line) + lines.append("") + + # Recent commits + lines.append("## RECENT COMMITS") + _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root) + if log_out.strip(): + for line in log_out.splitlines(): + lines.append(line) + else: + lines.append("(no commits)") + lines.append("") + + # Package git repos — independent sub-repositories + _append_package_git_context(lines, _collect_package_git_info(repo_root)) + + # Current task + lines.append("## CURRENT TASK") + current_task = get_current_task(repo_root) + if current_task: + current_task_dir = repo_root / current_task + lines.append(f"Path: {current_task}") + + ct = load_task(current_task_dir) + if ct: + lines.append(f"Name: {ct.name}") + lines.append(f"Status: {ct.status}") + lines.append(f"Created: {ct.raw.get('createdAt', 'unknown')}") + if ct.description: + lines.append(f"Description: {ct.description}") + + # Check for prd.md + prd_file = current_task_dir / "prd.md" + if prd_file.is_file(): + lines.append("") + lines.append("[!] This task has prd.md - read it for task details") + else: + lines.append("(none)") + lines.append("") + + # Active tasks + lines.append("## ACTIVE TASKS") + tasks_dir = get_tasks_dir(repo_root) + task_count = 0 + + # Collect all task data for hierarchy display + all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)} + all_statuses = {name: t.status for name, t in all_tasks.items()} + + def _print_task_tree(name: str, indent: int = 0) -> None: + nonlocal task_count + t = all_tasks[name] + progress = children_progress(t.children, all_statuses) + prefix = " " * indent + lines.append(f"{prefix}- {name}/ ({t.status}){progress} @{t.assignee or '-'}") + task_count += 1 + for child in t.children: + if child in all_tasks: + _print_task_tree(child, indent + 1) + + for dir_name in sorted(all_tasks.keys()): + if not all_tasks[dir_name].parent: + _print_task_tree(dir_name) + + if task_count == 0: + lines.append("(no active tasks)") + lines.append(f"Total: {task_count} active task(s)") + lines.append("") + + # My tasks + lines.append("## MY TASKS (Assigned to me)") + my_task_count = 0 + + for t in all_tasks.values(): + if t.assignee == developer and t.status != "done": + progress = children_progress(t.children, all_statuses) + lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress}") + my_task_count += 1 + + if my_task_count == 0: + lines.append("(no tasks assigned to you)") + lines.append("") + + # Journal file + lines.append("## JOURNAL FILE") + journal_file = get_active_journal_file(repo_root) + if journal_file: + journal_lines = count_lines(journal_file) + relative = f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}" + lines.append(f"Active file: {relative}") + lines.append(f"Line count: {journal_lines} / 2000") + if journal_lines > 1800: + lines.append("[!] WARNING: Approaching 2000 line limit!") + else: + lines.append("No journal file found") + lines.append("") + + # Packages + packages_text = get_packages_section(repo_root) + if packages_text: + lines.append(packages_text) + lines.append("") + + # Paths + lines.append("## PATHS") + lines.append(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/") + lines.append(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/") + lines.append(f"Spec: {DIR_WORKFLOW}/{DIR_SPEC}/") + lines.append("") + + lines.append("========================================") + + return "\n".join(lines) + + +# ============================================================================= +# Record Mode +# ============================================================================= + +def get_context_record_json(repo_root: Path | None = None) -> dict: + """Get record-mode context as a dictionary. + + Focused on: my active tasks, git status, current task. + """ + if repo_root is None: + repo_root = get_repo_root() + + developer = get_developer(repo_root) + tasks_dir = get_tasks_dir(repo_root) + + # Git info + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) + branch = branch_out.strip() or "unknown" + + _, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root) + git_status_count = len([line for line in status_out.splitlines() if line.strip()]) + + _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root) + commits = [] + for line in log_out.splitlines(): + if line.strip(): + parts = line.split(" ", 1) + if len(parts) >= 2: + commits.append({"hash": parts[0], "message": parts[1]}) + + # My tasks (single pass — collect statuses and filter by assignee) + all_tasks_list = list(iter_active_tasks(tasks_dir)) + all_statuses = {t.dir_name: t.status for t in all_tasks_list} + + my_tasks = [] + for t in all_tasks_list: + if t.assignee == developer: + done = sum( + 1 for c in t.children + if all_statuses.get(c) in ("completed", "done") + ) + my_tasks.append({ + "dir": t.dir_name, + "title": t.title, + "status": t.status, + "priority": t.priority, + "children": list(t.children), + "childrenDone": done, + "parent": t.parent, + "meta": t.meta, + }) + + # Current task + current_task_info = None + current_task = get_current_task(repo_root) + if current_task: + ct = load_task(repo_root / current_task) + if ct: + current_task_info = { + "path": current_task, + "name": ct.name, + "status": ct.status, + } + + # Package git repos + pkg_git_info = _collect_package_git_info(repo_root) + + result = { + "developer": developer or "", + "git": { + "branch": branch, + "isClean": git_status_count == 0, + "uncommittedChanges": git_status_count, + "recentCommits": commits, + }, + "myTasks": my_tasks, + "currentTask": current_task_info, + } + + if pkg_git_info: + result["packageGit"] = pkg_git_info + + return result + + +def get_context_text_record(repo_root: Path | None = None) -> str: + """Get context as formatted text for record-session mode. + + Focused output: MY ACTIVE TASKS first (with [!!!] emphasis), + then GIT STATUS, RECENT COMMITS, CURRENT TASK. + """ + if repo_root is None: + repo_root = get_repo_root() + + lines: list[str] = [] + lines.append("========================================") + lines.append("SESSION CONTEXT (RECORD MODE)") + lines.append("========================================") + lines.append("") + + developer = get_developer(repo_root) + if not developer: + lines.append( + f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>" + ) + return "\n".join(lines) + + # MY ACTIVE TASKS — first and prominent + lines.append(f"## [!!!] MY ACTIVE TASKS (Assigned to {developer})") + lines.append("[!] Review whether any should be archived before recording this session.") + lines.append("") + + tasks_dir = get_tasks_dir(repo_root) + my_task_count = 0 + + # Single pass — collect all tasks and filter by assignee + all_statuses = get_all_statuses(tasks_dir) + + for t in iter_active_tasks(tasks_dir): + if t.assignee == developer: + progress = children_progress(t.children, all_statuses) + lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress} — {t.dir_name}") + my_task_count += 1 + + if my_task_count == 0: + lines.append("(no active tasks assigned to you)") + lines.append("") + + # GIT STATUS + lines.append("## GIT STATUS") + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) + branch = branch_out.strip() or "unknown" + lines.append(f"Branch: {branch}") + + _, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root) + status_lines = [line for line in status_out.splitlines() if line.strip()] + status_count = len(status_lines) + + if status_count == 0: + lines.append("Working directory: Clean") + else: + lines.append(f"Working directory: {status_count} uncommitted change(s)") + lines.append("") + lines.append("Changes:") + _, short_out, _ = run_git(["status", "--short"], cwd=repo_root) + for line in short_out.splitlines()[:10]: + lines.append(line) + lines.append("") + + # RECENT COMMITS + lines.append("## RECENT COMMITS") + _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root) + if log_out.strip(): + for line in log_out.splitlines(): + lines.append(line) + else: + lines.append("(no commits)") + lines.append("") + + # Package git repos — independent sub-repositories + _append_package_git_context(lines, _collect_package_git_info(repo_root)) + + # CURRENT TASK + lines.append("## CURRENT TASK") + current_task = get_current_task(repo_root) + if current_task: + lines.append(f"Path: {current_task}") + ct = load_task(repo_root / current_task) + if ct: + lines.append(f"Name: {ct.name}") + lines.append(f"Status: {ct.status}") + else: + lines.append("(none)") + lines.append("") + + lines.append("========================================") + + return "\n".join(lines) + + +def output_text(repo_root: Path | None = None) -> None: + """Output context in text format. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + """ + print(get_context_text(repo_root)) diff --git a/.trellis/scripts/common/task_context.py b/.trellis/scripts/common/task_context.py new file mode 100644 index 0000000..283c139 --- /dev/null +++ b/.trellis/scripts/common/task_context.py @@ -0,0 +1,410 @@ +#!/usr/bin/env python3 +""" +Task JSONL context management. + +Provides: + cmd_init_context - Initialize JSONL context files for a task + cmd_add_context - Add entry to JSONL context file + cmd_validate - Validate JSONL context files + cmd_list_context - List JSONL context entries +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from .cli_adapter import get_cli_adapter_auto +from .config import ( + get_packages, + is_monorepo, + resolve_package, + validate_package, +) +from .io import read_json, write_json +from .log import Colors, colored +from .paths import ( + DIR_SPEC, + DIR_WORKFLOW, + FILE_TASK_JSON, + get_repo_root, +) +from .task_utils import resolve_task_dir + + +# ============================================================================= +# JSONL Default Content Generators +# ============================================================================= + +def get_implement_base() -> list[dict]: + """Get base implement context entries.""" + return [ + {"file": f"{DIR_WORKFLOW}/workflow.md", "reason": "Project workflow and conventions"}, + ] + + +def get_implement_backend(package: str | None = None) -> list[dict]: + """Get backend implement context entries.""" + spec_base = f"{DIR_SPEC}/{package}" if package else DIR_SPEC + return [ + {"file": f"{DIR_WORKFLOW}/{spec_base}/backend/index.md", "reason": "Backend development guide"}, + ] + + +def get_implement_frontend(package: str | None = None) -> list[dict]: + """Get frontend implement context entries.""" + spec_base = f"{DIR_SPEC}/{package}" if package else DIR_SPEC + return [ + {"file": f"{DIR_WORKFLOW}/{spec_base}/frontend/index.md", "reason": "Frontend development guide"}, + ] + + +def get_check_context(repo_root: Path) -> list[dict]: + """Get check context entries.""" + adapter = get_cli_adapter_auto(repo_root) + + entries = [ + {"file": adapter.get_trellis_command_path("finish-work"), "reason": "Finish work checklist"}, + {"file": adapter.get_trellis_command_path("check"), "reason": "Code quality check spec"}, + ] + + return entries + + +def get_debug_context(repo_root: Path) -> list[dict]: + """Get debug context entries.""" + adapter = get_cli_adapter_auto(repo_root) + + entries: list[dict] = [ + {"file": adapter.get_trellis_command_path("check"), "reason": "Code quality check spec"}, + ] + + return entries + + +def _write_jsonl(path: Path, entries: list[dict]) -> None: + """Write entries to JSONL file.""" + lines = [json.dumps(entry, ensure_ascii=False) for entry in entries] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +# ============================================================================= +# Command: init-context +# ============================================================================= + +def cmd_init_context(args: argparse.Namespace) -> int: + """Initialize JSONL context files for a task.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + dev_type = args.type + + if not dev_type: + print(colored("Error: Missing arguments", Colors.RED)) + print("Usage: python3 task.py init-context <task-dir> <dev_type>") + print(" dev_type: backend | frontend | fullstack | test | docs") + return 1 + + if not target_dir.is_dir(): + print(colored(f"Error: Directory not found: {target_dir}", Colors.RED)) + return 1 + + # Resolve package: --package CLI → task.json.package → default_package + cli_package: str | None = getattr(args, "package", None) + package: str | None = None + if not is_monorepo(repo_root): + # Single-repo: ignore --package, no package prefix + if cli_package: + print(colored("Warning: --package ignored in single-repo project", Colors.YELLOW), file=sys.stderr) + elif cli_package: + if not validate_package(cli_package, repo_root): + packages = get_packages(repo_root) + available = ", ".join(sorted(packages.keys())) if packages else "(none)" + print(colored(f"Error: unknown package '{cli_package}'. Available: {available}", Colors.RED), file=sys.stderr) + return 1 + package = cli_package + else: + # Read task.json.package as inferred source + task_json_path = target_dir / FILE_TASK_JSON + task_pkg_value = None + if task_json_path.is_file(): + task_data = read_json(task_json_path) + if isinstance(task_data, dict): + task_pkg_value = task_data.get("package") + # Only pass string values to resolve_package (guard against malformed JSON) + task_package = task_pkg_value if isinstance(task_pkg_value, str) else None + package = resolve_package(task_package=task_package, repo_root=repo_root) + + # Monorepo fallback prohibition + if package is None: + packages = get_packages(repo_root) + available = ", ".join(sorted(packages.keys())) if packages else "(none)" + print(colored( + f"Error: monorepo project requires --package (or set default_package in config.yaml). Available: {available}", + Colors.RED, + ), file=sys.stderr) + return 1 + + print(colored("=== Initializing Agent Context Files ===", Colors.BLUE)) + print(f"Target dir: {target_dir}") + print(f"Dev type: {dev_type}") + if package: + print(f"Package: {package}") + print() + + # implement.jsonl + print(colored("Creating implement.jsonl...", Colors.CYAN)) + implement_entries = get_implement_base() + if dev_type in ("backend", "test"): + implement_entries.extend(get_implement_backend(package)) + elif dev_type == "frontend": + implement_entries.extend(get_implement_frontend(package)) + elif dev_type == "fullstack": + implement_entries.extend(get_implement_backend(package)) + implement_entries.extend(get_implement_frontend(package)) + + implement_file = target_dir / "implement.jsonl" + _write_jsonl(implement_file, implement_entries) + print(f" {colored('✓', Colors.GREEN)} {len(implement_entries)} entries") + + # check.jsonl + print(colored("Creating check.jsonl...", Colors.CYAN)) + check_entries = get_check_context(repo_root) + check_file = target_dir / "check.jsonl" + _write_jsonl(check_file, check_entries) + print(f" {colored('✓', Colors.GREEN)} {len(check_entries)} entries") + + # debug.jsonl + print(colored("Creating debug.jsonl...", Colors.CYAN)) + debug_entries = get_debug_context(repo_root) + debug_file = target_dir / "debug.jsonl" + _write_jsonl(debug_file, debug_entries) + print(f" {colored('✓', Colors.GREEN)} {len(debug_entries)} entries") + + # Update task.json dev_type and package + task_json_path = target_dir / FILE_TASK_JSON + if task_json_path.is_file(): + task_data = read_json(task_json_path) + if isinstance(task_data, dict): + task_data["dev_type"] = dev_type + task_data["package"] = package # Always sync to match resolved value + write_json(task_json_path, task_data) + + print() + print(colored("✓ All context files created", Colors.GREEN)) + print() + + # Show what was auto-injected + all_injected = [e["file"] for e in implement_entries] + print(colored("Auto-injected (defaults only):", Colors.YELLOW)) + for f in all_injected: + print(f" - {f}") + print() + + # Scan spec directory for available spec files the AI should consider + spec_base = repo_root / DIR_WORKFLOW / DIR_SPEC + if package: + spec_base = spec_base / package + available_specs: list[str] = [] + if spec_base.is_dir(): + for md_file in sorted(spec_base.rglob("*.md")): + rel = str(md_file.relative_to(repo_root)) + if rel not in all_injected: + available_specs.append(rel) + + if available_specs: + print(colored("Available spec files (not yet injected):", Colors.BLUE)) + for spec in available_specs: + print(f" - {spec}") + print() + + print(colored("Next steps:", Colors.BLUE)) + print(" 1. Review the spec files above and add relevant ones for your task:") + print(f" python3 task.py add-context <dir> implement <spec-path> \"<reason>\"") + print(" 2. Set as current: python3 task.py start <dir>") + + return 0 + + +# ============================================================================= +# Command: add-context +# ============================================================================= + +def cmd_add_context(args: argparse.Namespace) -> int: + """Add entry to JSONL context file.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + + jsonl_name = args.file + path = args.path + reason = args.reason or "Added manually" + + if not target_dir.is_dir(): + print(colored(f"Error: Directory not found: {target_dir}", Colors.RED)) + return 1 + + # Support shorthand + if not jsonl_name.endswith(".jsonl"): + jsonl_name = f"{jsonl_name}.jsonl" + + jsonl_file = target_dir / jsonl_name + full_path = repo_root / path + + entry_type = "file" + if full_path.is_dir(): + entry_type = "directory" + if not path.endswith("/"): + path = f"{path}/" + elif not full_path.is_file(): + print(colored(f"Error: Path not found: {path}", Colors.RED)) + return 1 + + # Check if already exists + if jsonl_file.is_file(): + content = jsonl_file.read_text(encoding="utf-8") + if f'"{path}"' in content: + print(colored(f"Warning: Entry already exists for {path}", Colors.YELLOW)) + return 0 + + # Add entry + entry: dict + if entry_type == "directory": + entry = {"file": path, "type": "directory", "reason": reason} + else: + entry = {"file": path, "reason": reason} + + with jsonl_file.open("a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + + print(colored(f"Added {entry_type}: {path}", Colors.GREEN)) + return 0 + + +# ============================================================================= +# Command: validate +# ============================================================================= + +def cmd_validate(args: argparse.Namespace) -> int: + """Validate JSONL context files.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + + if not target_dir.is_dir(): + print(colored("Error: task directory required", Colors.RED)) + return 1 + + print(colored("=== Validating Context Files ===", Colors.BLUE)) + print(f"Target dir: {target_dir}") + print() + + total_errors = 0 + for jsonl_name in ["implement.jsonl", "check.jsonl", "debug.jsonl"]: + jsonl_file = target_dir / jsonl_name + errors = _validate_jsonl(jsonl_file, repo_root) + total_errors += errors + + print() + if total_errors == 0: + print(colored("✓ All validations passed", Colors.GREEN)) + return 0 + else: + print(colored(f"✗ Validation failed ({total_errors} errors)", Colors.RED)) + return 1 + + +def _validate_jsonl(jsonl_file: Path, repo_root: Path) -> int: + """Validate a single JSONL file.""" + file_name = jsonl_file.name + errors = 0 + + if not jsonl_file.is_file(): + print(f" {colored(f'{file_name}: not found (skipped)', Colors.YELLOW)}") + return 0 + + line_num = 0 + for line in jsonl_file.read_text(encoding="utf-8").splitlines(): + line_num += 1 + if not line.strip(): + continue + + try: + data = json.loads(line) + except json.JSONDecodeError: + print(f" {colored(f'{file_name}:{line_num}: Invalid JSON', Colors.RED)}") + errors += 1 + continue + + file_path = data.get("file") + entry_type = data.get("type", "file") + + if not file_path: + print(f" {colored(f'{file_name}:{line_num}: Missing file field', Colors.RED)}") + errors += 1 + continue + + full_path = repo_root / file_path + if entry_type == "directory": + if not full_path.is_dir(): + print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}") + errors += 1 + else: + if not full_path.is_file(): + print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}") + errors += 1 + + if errors == 0: + print(f" {colored(f'{file_name}: ✓ ({line_num} entries)', Colors.GREEN)}") + else: + print(f" {colored(f'{file_name}: ✗ ({errors} errors)', Colors.RED)}") + + return errors + + +# ============================================================================= +# Command: list-context +# ============================================================================= + +def cmd_list_context(args: argparse.Namespace) -> int: + """List JSONL context entries.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + + if not target_dir.is_dir(): + print(colored("Error: task directory required", Colors.RED)) + return 1 + + print(colored("=== Context Files ===", Colors.BLUE)) + print() + + for jsonl_name in ["implement.jsonl", "check.jsonl", "debug.jsonl"]: + jsonl_file = target_dir / jsonl_name + if not jsonl_file.is_file(): + continue + + print(colored(f"[{jsonl_name}]", Colors.CYAN)) + + count = 0 + for line in jsonl_file.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + + try: + data = json.loads(line) + except json.JSONDecodeError: + continue + + count += 1 + file_path = data.get("file", "?") + entry_type = data.get("type", "file") + reason = data.get("reason", "-") + + if entry_type == "directory": + print(f" {colored(f'{count}.', Colors.GREEN)} [DIR] {file_path}") + else: + print(f" {colored(f'{count}.', Colors.GREEN)} {file_path}") + print(f" {colored('→', Colors.YELLOW)} {reason}") + + print() + + return 0 diff --git a/.trellis/scripts/common/task_queue.py b/.trellis/scripts/common/task_queue.py index 70378a1..f7485e2 100644 --- a/.trellis/scripts/common/task_queue.py +++ b/.trellis/scripts/common/task_queue.py @@ -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( 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( 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]: 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 diff --git a/.trellis/scripts/common/task_store.py b/.trellis/scripts/common/task_store.py new file mode 100644 index 0000000..c20e77d --- /dev/null +++ b/.trellis/scripts/common/task_store.py @@ -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 diff --git a/.trellis/scripts/common/task_utils.py b/.trellis/scripts/common/task_utils.py index 84df2fa..c1639da 100644 --- a/.trellis/scripts/common/task_utils.py +++ b/.trellis/scripts/common/task_utils.py @@ -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 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( 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) diff --git a/.trellis/scripts/common/tasks.py b/.trellis/scripts/common/tasks.py new file mode 100644 index 0000000..47d78c2 --- /dev/null +++ b/.trellis/scripts/common/tasks.py @@ -0,0 +1,109 @@ +""" +Task data access layer. + +Single source of truth for loading and iterating task directories. +Replaces scattered task.json parsing across 9+ files. + +Provides: + load_task — Load a single task by directory path + iter_active_tasks — Iterate all non-archived tasks (sorted) + get_all_statuses — Get {dir_name: status} map for children progress +""" + +from __future__ import annotations + +from collections.abc import Iterator +from pathlib import Path + +from .io import read_json +from .paths import FILE_TASK_JSON +from .types import TaskInfo + + +def load_task(task_dir: Path) -> TaskInfo | None: + """Load task from a directory containing task.json. + + Args: + task_dir: Absolute path to the task directory. + + Returns: + TaskInfo if task.json exists and is valid, None otherwise. + """ + task_json = task_dir / FILE_TASK_JSON + if not task_json.is_file(): + return None + + data = read_json(task_json) + if not data: + return None + + return TaskInfo( + dir_name=task_dir.name, + directory=task_dir, + title=data.get("title") or data.get("name") or "unknown", + status=data.get("status", "unknown"), + assignee=data.get("assignee", ""), + priority=data.get("priority", "P2"), + children=tuple(data.get("children", [])), + parent=data.get("parent"), + package=data.get("package"), + raw=data, + ) + + +def iter_active_tasks(tasks_dir: Path) -> Iterator[TaskInfo]: + """Iterate all active (non-archived) tasks, sorted by directory name. + + Skips the "archive" directory and directories without valid task.json. + + Args: + tasks_dir: Path to the tasks directory. + + Yields: + TaskInfo for each valid task. + """ + if not tasks_dir.is_dir(): + return + + for d in sorted(tasks_dir.iterdir()): + if not d.is_dir() or d.name == "archive": + continue + info = load_task(d) + if info is not None: + yield info + + +def get_all_statuses(tasks_dir: Path) -> dict[str, str]: + """Get a {dir_name: status} mapping for all active tasks. + + Useful for computing children progress without loading full TaskInfo. + + Args: + tasks_dir: Path to the tasks directory. + + Returns: + Dict mapping directory names to status strings. + """ + return {t.dir_name: t.status for t in iter_active_tasks(tasks_dir)} + + +def children_progress( + children: tuple[str, ...] | list[str], + all_statuses: dict[str, str], +) -> str: + """Format children progress string like " [2/3 done]". + + Args: + children: List of child directory names. + all_statuses: Status map from get_all_statuses(). + + Returns: + Formatted string, or "" if no children. + """ + if not children: + return "" + done = sum( + 1 for c in children + if all_statuses.get(c) in ("completed", "done") + ) + return f" [{done}/{len(children)} done]" diff --git a/.trellis/scripts/common/types.py b/.trellis/scripts/common/types.py new file mode 100644 index 0000000..f78b7df --- /dev/null +++ b/.trellis/scripts/common/types.py @@ -0,0 +1,112 @@ +""" +Core type definitions for Trellis task data. + +Provides: + TaskData — TypedDict for task.json shape (read-path type hints only) + TaskInfo — Frozen dataclass for loaded task (the public API type) + AgentRecord — TypedDict for registry.json agent entries +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TypedDict + + +# ============================================================================= +# task.json shape (TypedDict — used only for read-path type hints) +# ============================================================================= + +class TaskData(TypedDict, total=False): + """Shape of task.json on disk. + + Used only for type annotations when reading task.json. + Writes must use the original dict to avoid losing unknown fields. + """ + + id: str + name: str + title: str + description: str + status: str + dev_type: str + scope: str | None + package: str | None + priority: str + creator: str + assignee: str + createdAt: str + completedAt: str | None + branch: str | None + base_branch: str | None + worktree_path: str | None + current_phase: int + next_action: list[dict] + commit: str | None + pr_url: str | None + subtasks: list[str] + children: list[str] + parent: str | None + relatedFiles: list[str] + notes: str + meta: dict + + +# ============================================================================= +# Loaded task object (frozen dataclass — the public API type) +# ============================================================================= + +@dataclass(frozen=True) +class TaskInfo: + """Immutable view of a loaded task. + + Created by load_task() / iter_active_tasks(). + Contains the commonly accessed fields; the original dict + is preserved in `raw` for write-back and uncommon field access. + """ + + dir_name: str + directory: Path + title: str + status: str + assignee: str + priority: str + children: tuple[str, ...] + parent: str | None + package: str | None + raw: dict # original dict — use for writes and uncommon fields + + @property + def name(self) -> str: + """Task name (id or name field).""" + return self.raw.get("name") or self.raw.get("id") or self.dir_name + + @property + def description(self) -> str: + return self.raw.get("description", "") + + @property + def branch(self) -> str | None: + return self.raw.get("branch") + + @property + def meta(self) -> dict: + return self.raw.get("meta", {}) + + +# ============================================================================= +# registry.json agent entry +# ============================================================================= + +class AgentRecord(TypedDict, total=False): + """Shape of an agent entry in registry.json.""" + + id: str + pid: int + task_dir: str + worktree_path: str + branch: str + platform: str + started_at: str + status: str diff --git a/.trellis/scripts/create_bootstrap.py b/.trellis/scripts/create_bootstrap.py index 201146f..b295b7d 100644 --- a/.trellis/scripts/create_bootstrap.py +++ b/.trellis/scripts/create_bootstrap.py @@ -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: 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**. """ -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: """ -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: # 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: {"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: 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: 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) diff --git a/.trellis/scripts/multi_agent/cleanup.py b/.trellis/scripts/multi_agent/cleanup.py index f81e370..0ed2588 100644 --- a/.trellis/scripts/multi_agent/cleanup.py +++ b/.trellis/scripts/multi_agent/cleanup.py @@ -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 ( 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: 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: 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 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( ) -> 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( 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( # 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( # 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( 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: 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: 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: 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: # 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 diff --git a/.trellis/scripts/multi_agent/create_pr.py b/.trellis/scripts/multi_agent/create_pr.py index 54df3db..0028548 100644 --- a/.trellis/scripts/multi_agent/create_pr.py +++ b/.trellis/scripts/multi_agent/create_pr.py @@ -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. 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 ( ) 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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 diff --git a/.trellis/scripts/multi_agent/plan.py b/.trellis/scripts/multi_agent/plan.py index 7ce5e6f..7a76ba8 100644 --- a/.trellis/scripts/multi_agent/plan.py +++ b/.trellis/scripts/multi_agent/plan.py @@ -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: 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: 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) diff --git a/.trellis/scripts/multi_agent/start.py b/.trellis/scripts/multi_agent/start.py index 40c2747..1f75e34 100644 --- a/.trellis/scripts/multi_agent/start.py +++ b/.trellis/scripts/multi_agent/start.py @@ -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 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 ( 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 ( 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: 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: 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: 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: 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: 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: # 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: 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: 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: # 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" diff --git a/.trellis/scripts/multi_agent/status.py b/.trellis/scripts/multi_agent/status.py index e83ac60..cba7890 100644 --- a/.trellis/scripts/multi_agent/status.py +++ b/.trellis/scripts/multi_agent/status.py @@ -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") diff --git a/.trellis/scripts/multi_agent/status_display.py b/.trellis/scripts/multi_agent/status_display.py new file mode 100644 index 0000000..6b15d86 --- /dev/null +++ b/.trellis/scripts/multi_agent/status_display.py @@ -0,0 +1,542 @@ +#!/usr/bin/env python3 +""" +Multi-Agent Pipeline: Status display and formatting. + +Provides: + cmd_help - Show help text + cmd_list - List worktrees and agents + cmd_summary - Summary of all tasks with agent status + cmd_detail - Detailed single-agent status + cmd_registry - Dump agent registry + +Also exports shared utilities used by status_monitor: + is_running, find_agent, get_registry_file, calc_elapsed, count_modified_files +""" + +from __future__ import annotations + +import json +import os +import subprocess +from datetime import datetime +from pathlib import Path + +from common.cli_adapter import get_cli_adapter +from common.io import read_json +from common.log import Colors +from common.developer import ensure_developer +from common.paths import ( + get_repo_root, + get_tasks_dir, +) +from common.phase import get_phase_info +from common.task_queue import format_task_stats, get_task_stats +from common.tasks import iter_active_tasks +from common.worktree import get_agents_dir + + +# ============================================================================= +# Shared Utilities +# ============================================================================= + +def is_running(pid: int | str | None) -> bool: + """Check if PID is running.""" + if not pid: + return False + try: + pid_int = int(pid) + os.kill(pid_int, 0) + return True + except (ProcessLookupError, ValueError, PermissionError, TypeError): + return False + + +def status_color(status: str) -> str: + """Get status color.""" + colors = { + "completed": Colors.GREEN, + "in_progress": Colors.BLUE, + "planning": Colors.YELLOW, + } + return colors.get(status, Colors.DIM) + + +def get_registry_file(repo_root: Path) -> Path | None: + """Get registry file path.""" + agents_dir = get_agents_dir(repo_root) + if agents_dir: + return agents_dir / "registry.json" + return None + + +def find_agent(search: str, repo_root: Path) -> dict | None: + """Find agent by task name or ID.""" + registry_file = get_registry_file(repo_root) + if not registry_file or not registry_file.is_file(): + return None + + data = read_json(registry_file) + if not data: + return None + + for agent in data.get("agents", []): + # Exact ID match + if agent.get("id") == search: + return agent + # Partial match on task_dir + task_dir = agent.get("task_dir", "") + if search in task_dir: + return agent + + return None + + +def calc_elapsed(started: str | None) -> str: + """Calculate elapsed time from ISO timestamp.""" + if not started: + return "N/A" + + try: + # Parse ISO format + if "+" in started: + started = started.split("+")[0] + if "T" in started: + start_dt = datetime.fromisoformat(started) + else: + return "N/A" + + now = datetime.now() + elapsed = (now - start_dt).total_seconds() + + if elapsed < 60: + return f"{int(elapsed)}s" + elif elapsed < 3600: + mins = int(elapsed // 60) + secs = int(elapsed % 60) + return f"{mins}m {secs}s" + else: + hours = int(elapsed // 3600) + mins = int((elapsed % 3600) // 60) + return f"{hours}h {mins}m" + except (ValueError, TypeError): + return "N/A" + + +def count_modified_files(worktree: str) -> int: + """Count modified files in worktree.""" + if not Path(worktree).is_dir(): + return 0 + + try: + result = subprocess.run( + ["git", "status", "--short"], + cwd=worktree, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + return len([line for line in result.stdout.splitlines() if line.strip()]) + except Exception: + return 0 + + +# ============================================================================= +# Commands +# ============================================================================= + +def cmd_help() -> int: + """Show help.""" + print("""Multi-Agent Pipeline: Status Monitor + +Usage: + python3 status.py Show summary of all tasks + python3 status.py -a <assignee> Filter tasks by assignee + python3 status.py --list List all worktrees and agents + python3 status.py --detail <task> Detailed task status + python3 status.py --progress <task> Quick progress view with recent activity + python3 status.py --watch <task> Watch agent log in real-time + python3 status.py --log <task> Show recent log entries + python3 status.py --registry Show agent registry + +Examples: + python3 status.py -a taosu + python3 status.py --detail my-task + python3 status.py --progress my-task + python3 status.py --watch 01-16-worktree-support + python3 status.py --log worktree-support +""") + return 0 + + +def cmd_list(repo_root: Path) -> int: + """List worktrees and agents.""" + print(f"{Colors.BLUE}=== Git Worktrees ==={Colors.NC}") + print() + + subprocess.run(["git", "worktree", "list"], cwd=repo_root) + print() + + print(f"{Colors.BLUE}=== Registered Agents ==={Colors.NC}") + print() + + registry_file = get_registry_file(repo_root) + if not registry_file or not registry_file.is_file(): + print(" (no registry found)") + return 0 + + data = read_json(registry_file) + if not data or not data.get("agents"): + print(" (no agents registered)") + return 0 + + for agent in data["agents"]: + agent_id = agent.get("id", "?") + pid = agent.get("pid") + wt = agent.get("worktree_path", "?") + started = agent.get("started_at", "?") + + if is_running(pid): + status_icon = f"{Colors.GREEN}●{Colors.NC}" + else: + status_icon = f"{Colors.RED}○{Colors.NC}" + + print(f" {status_icon} {agent_id} (PID: {pid})") + print(f" {Colors.DIM}Worktree: {wt}{Colors.NC}") + print(f" {Colors.DIM}Started: {started}{Colors.NC}") + print() + + return 0 + + +def cmd_summary(repo_root: Path, filter_assignee: str | None = None) -> int: + """Show summary of all tasks.""" + # Import lazily to avoid circular import at module level + from .status_monitor import get_last_tool, get_last_message + + ensure_developer(repo_root) + + tasks_dir = get_tasks_dir(repo_root) + if not tasks_dir.is_dir(): + print("No tasks directory found") + return 0 + + registry_file = get_registry_file(repo_root) + + # Count running agents + running_count = 0 + total_agents = 0 + + if registry_file and registry_file.is_file(): + data = read_json(registry_file) + if data: + agents = data.get("agents", []) + total_agents = len(agents) + for agent in agents: + if is_running(agent.get("pid")): + running_count += 1 + + # Task queue stats + task_stats = get_task_stats(repo_root) + + print(f"{Colors.BLUE}=== Multi-Agent Status ==={Colors.NC}") + print( + f" Agents: {Colors.GREEN}{running_count}{Colors.NC} running / {total_agents} registered" + ) + print(f" Tasks: {format_task_stats(task_stats)}") + print() + + # Process tasks + running_tasks = [] + stopped_tasks = [] + regular_tasks = [] + + registry_data = ( + read_json(registry_file) + if registry_file and registry_file.is_file() + else None + ) + + for t in iter_active_tasks(tasks_dir): + name = t.dir_name + status = t.status + assignee = t.assignee or "unassigned" + priority = t.priority + + # Filter by assignee + if filter_assignee and assignee != filter_assignee: + continue + + # Check agent status + agent_info = None + if registry_data: + for agent in registry_data.get("agents", []): + if name in agent.get("task_dir", ""): + agent_info = agent + break + + if agent_info: + pid = agent_info.get("pid") + worktree = agent_info.get("worktree_path", "") + started = agent_info.get("started_at") + agent_platform = agent_info.get("platform", "claude") + + if is_running(pid): + # Running agent + task_dir_rel = agent_info.get("task_dir", "") + worktree_task_json = Path(worktree) / task_dir_rel / "task.json" + phase_source = t.directory / "task.json" + if worktree_task_json.is_file(): + phase_source = worktree_task_json + + phase_info_str = get_phase_info(phase_source) + elapsed = calc_elapsed(started) + modified = count_modified_files(worktree) + + worktree_data = read_json(phase_source) + branch = worktree_data.get("branch", "N/A") if worktree_data else "N/A" + + log_file = Path(worktree) / ".agent-log" + last_tool = get_last_tool(log_file, platform=agent_platform) + + running_tasks.append( + { + "name": name, + "priority": priority, + "assignee": assignee, + "phase_info": phase_info_str, + "elapsed": elapsed, + "branch": branch, + "modified": modified, + "last_tool": last_tool, + "pid": pid, + } + ) + else: + # Stopped agent + task_dir_rel = agent_info.get("task_dir", "") + worktree_task_json = Path(worktree) / task_dir_rel / "task.json" + worktree_status = "unknown" + + if worktree_task_json.is_file(): + wt_data = read_json(worktree_task_json) + if wt_data: + worktree_status = wt_data.get("status", "unknown") + + session_id_file = Path(worktree) / ".session-id" + log_file = Path(worktree) / ".agent-log" + + stopped_tasks.append( + { + "name": name, + "worktree": worktree, + "status": worktree_status, + "session_id_file": session_id_file, + "log_file": log_file, + "platform": agent_info.get("platform", "claude"), + } + ) + else: + # Regular task + regular_tasks.append( + { + "name": name, + "status": status, + "priority": priority, + "assignee": assignee, + } + ) + + # Output running agents + if running_tasks: + print(f"{Colors.CYAN}Running Agents:{Colors.NC}") + for t in running_tasks: + priority_color = ( + Colors.RED + if t["priority"] == "P0" + else (Colors.YELLOW if t["priority"] == "P1" else Colors.BLUE) + ) + print( + f"{Colors.GREEN}▶{Colors.NC} {Colors.CYAN}{t['name']}{Colors.NC} {Colors.GREEN}[running]{Colors.NC} {priority_color}[{t['priority']}]{Colors.NC} @{t['assignee']}" + ) + print(f" Phase: {t['phase_info']}") + print(f" Elapsed: {t['elapsed']}") + print(f" Branch: {Colors.DIM}{t['branch']}{Colors.NC}") + print(f" Modified: {t['modified']} file(s)") + if t["last_tool"]: + print(f" Activity: {Colors.YELLOW}{t['last_tool']}{Colors.NC}") + print(f" PID: {Colors.DIM}{t['pid']}{Colors.NC}") + print() + + # Output stopped agents + if stopped_tasks: + print(f"{Colors.RED}Stopped Agents:{Colors.NC}") + for t in stopped_tasks: + if t["status"] == "completed": + print( + f"{Colors.GREEN}✓{Colors.NC} {t['name']} {Colors.GREEN}[completed]{Colors.NC}" + ) + else: + if t["session_id_file"].is_file(): + session_id = ( + t["session_id_file"].read_text(encoding="utf-8").strip() + ) + last_msg = get_last_message(t["log_file"], 150, platform=t.get("platform", "claude")) + print( + f"{Colors.RED}○{Colors.NC} {t['name']} {Colors.RED}[stopped]{Colors.NC}" + ) + if last_msg: + print(f'{Colors.DIM}"{last_msg}"{Colors.NC}') + # Use CLI adapter for platform-specific resume command + adapter = get_cli_adapter(t.get("platform", "claude")) + resume_cmd = adapter.get_resume_command_str(session_id, cwd=t["worktree"]) + print(f"{Colors.YELLOW}{resume_cmd}{Colors.NC}") + else: + print( + f"{Colors.RED}○{Colors.NC} {t['name']} {Colors.RED}[stopped]{Colors.NC} {Colors.DIM}(no session-id){Colors.NC}" + ) + print() + + # Separator + if (running_tasks or stopped_tasks) and regular_tasks: + print(f"{Colors.DIM}───────────────────────────────────────{Colors.NC}") + print() + + # Output regular tasks grouped by assignee + if regular_tasks: + # Sort by assignee, priority, status + regular_tasks.sort( + key=lambda x: ( + x["assignee"], + {"P0": 0, "P1": 1, "P2": 2, "P3": 3}.get(x["priority"], 2), + {"in_progress": 0, "planning": 1, "completed": 2}.get(x["status"], 1), + ) + ) + + current_assignee = None + for t in regular_tasks: + if t["assignee"] != current_assignee: + if current_assignee is not None: + print() + print(f"{Colors.CYAN}@{t['assignee']}:{Colors.NC}") + current_assignee = t["assignee"] + + color = status_color(t["status"]) + priority_color = ( + Colors.RED + if t["priority"] == "P0" + else (Colors.YELLOW if t["priority"] == "P1" else Colors.BLUE) + ) + print( + f" {color}●{Colors.NC} {t['name']} ({t['status']}) {priority_color}[{t['priority']}]{Colors.NC}" + ) + + if running_tasks: + print() + print(f"{Colors.DIM}─────────────────────────────────────{Colors.NC}") + print(f"{Colors.DIM}Use --progress <name> for quick activity view{Colors.NC}") + print(f"{Colors.DIM}Use --detail <name> for more info{Colors.NC}") + + print() + return 0 + + +def cmd_detail(target: str, repo_root: Path) -> int: + """Show detailed task status.""" + agent = find_agent(target, repo_root) + if not agent: + print(f"Agent not found: {target}") + return 1 + + agent_id = agent.get("id", "?") + pid = agent.get("pid") + worktree = agent.get("worktree_path", "?") + task_dir = agent.get("task_dir", "?") + started = agent.get("started_at", "?") + platform = agent.get("platform", "claude") + + # Check for session-id + session_id = "" + session_id_file = Path(worktree) / ".session-id" + if session_id_file.is_file(): + session_id = session_id_file.read_text(encoding="utf-8").strip() + + print(f"{Colors.BLUE}=== Agent Detail: {agent_id} ==={Colors.NC}") + print() + print(f" ID: {agent_id}") + print(f" PID: {pid}") + print(f" Session: {session_id or 'N/A'}") + print(f" Worktree: {worktree}") + print(f" Task Dir: {task_dir}") + print(f" Started: {started}") + print() + + # Status + if is_running(pid): + print(f" Status: {Colors.GREEN}Running{Colors.NC}") + else: + print(f" Status: {Colors.RED}Stopped{Colors.NC}") + if session_id: + print() + # Use CLI adapter for platform-specific resume command + adapter = get_cli_adapter(platform) + resume_cmd = adapter.get_resume_command_str(session_id, cwd=worktree) + print(f" {Colors.YELLOW}Resume:{Colors.NC} {resume_cmd}") + + # Task info + task_json = repo_root / task_dir / "task.json" + if task_json.is_file(): + print() + print(f"{Colors.BLUE}=== Task Info ==={Colors.NC}") + print() + data = read_json(task_json) + if data: + print(f" Status: {data.get('status', 'unknown')}") + print(f" Branch: {data.get('branch', 'N/A')}") + print(f" Base Branch: {data.get('base_branch', 'N/A')}") + + # Git changes + if Path(worktree).is_dir(): + print() + print(f"{Colors.BLUE}=== Git Changes ==={Colors.NC}") + print() + + result = subprocess.run( + ["git", "status", "--short"], + cwd=worktree, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + changes = result.stdout.strip() + if changes: + for line in changes.splitlines()[:10]: + print(f" {line}") + total = len(changes.splitlines()) + if total > 10: + print(f" ... and {total - 10} more") + else: + print(" (no changes)") + + print() + return 0 + + +def cmd_registry(repo_root: Path) -> int: + """Show agent registry.""" + registry_file = get_registry_file(repo_root) + + print(f"{Colors.BLUE}=== Agent Registry ==={Colors.NC}") + print() + print(f"File: {registry_file}") + print() + + if registry_file and registry_file.is_file(): + data = read_json(registry_file) + if data: + print(json.dumps(data, indent=2)) + else: + print("(registry not found)") + + return 0 diff --git a/.trellis/scripts/multi_agent/status_monitor.py b/.trellis/scripts/multi_agent/status_monitor.py new file mode 100644 index 0000000..d92087f --- /dev/null +++ b/.trellis/scripts/multi_agent/status_monitor.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +""" +Multi-Agent Pipeline: Process monitoring and log parsing. + +Provides: + tail_follow - Follow a file like 'tail -f' + get_last_tool - Get last tool call from agent log + get_last_message - Get last assistant text from agent log + cmd_watch - Watch agent log in real-time + cmd_log - Show recent log entries +""" + +from __future__ import annotations + +import json +import time +from pathlib import Path + +from common.log import Colors + +from .status_display import find_agent + + +# ============================================================================= +# Log Parsing +# ============================================================================= + +def tail_follow(file_path: Path) -> None: + """Follow a file like 'tail -f', cross-platform compatible.""" + with open(file_path, "r", encoding="utf-8", errors="replace") as f: + # Seek to end of file + f.seek(0, 2) + + while True: + line = f.readline() + if line: + print(line, end="", flush=True) + else: + time.sleep(0.1) + + +def get_last_tool(log_file: Path, platform: str = "claude") -> str | None: + """Get the last tool call from agent log. + + Supports both Claude Code and OpenCode log formats. + + Claude Code format: + {"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Read"}]}} + + OpenCode format: + {"type": "tool_use", "tool": "bash", "state": {"status": "completed"}} + """ + if not log_file.is_file(): + return None + + try: + lines = log_file.read_text(encoding="utf-8").splitlines() + for line in reversed(lines[-100:]): + try: + data = json.loads(line) + + if platform == "opencode": + # OpenCode format: {"type": "tool_use", "tool": "bash", ...} + if data.get("type") == "tool_use": + return data.get("tool") + else: + # Claude Code format: {"type": "assistant", "message": {"content": [...]}} + if data.get("type") == "assistant": + content = data.get("message", {}).get("content", []) + for item in content: + if item.get("type") == "tool_use": + return item.get("name") + except json.JSONDecodeError: + continue + except Exception: + pass + return None + + +def get_last_message(log_file: Path, max_len: int = 100, platform: str = "claude") -> str | None: + """Get the last assistant text from agent log. + + Supports both Claude Code and OpenCode log formats. + + Claude Code format: + {"type": "assistant", "message": {"content": [{"type": "text", "text": "..."}]}} + + OpenCode format: + {"type": "text", "text": "..."} + """ + if not log_file.is_file(): + return None + + try: + lines = log_file.read_text(encoding="utf-8").splitlines() + for line in reversed(lines[-100:]): + try: + data = json.loads(line) + + if platform == "opencode": + # OpenCode format: {"type": "text", "text": "..."} + if data.get("type") == "text": + text = data.get("text", "") + if text: + return text[:max_len] + else: + # Claude Code format: {"type": "assistant", "message": {"content": [...]}} + if data.get("type") == "assistant": + content = data.get("message", {}).get("content", []) + for item in content: + if item.get("type") == "text": + text = item.get("text", "") + if text: + return text[:max_len] + except json.JSONDecodeError: + continue + except Exception: + pass + return None + + +# ============================================================================= +# Commands +# ============================================================================= + +def cmd_watch(target: str, repo_root: Path) -> int: + """Watch agent log in real-time.""" + agent = find_agent(target, repo_root) + if not agent: + print(f"Agent not found: {target}") + return 1 + + worktree = agent.get("worktree_path", "") + log_file = Path(worktree) / ".agent-log" + + if not log_file.is_file(): + print(f"Log file not found: {log_file}") + return 1 + + print(f"{Colors.BLUE}Watching:{Colors.NC} {log_file}") + print(f"{Colors.DIM}Press Ctrl+C to stop{Colors.NC}") + print() + + try: + tail_follow(log_file) + except KeyboardInterrupt: + print() # Clean newline after Ctrl+C + return 0 + + +def cmd_log(target: str, repo_root: Path) -> int: + """Show recent log entries.""" + agent = find_agent(target, repo_root) + if not agent: + print(f"Agent not found: {target}") + return 1 + + worktree = agent.get("worktree_path", "") + platform = agent.get("platform", "claude") + log_file = Path(worktree) / ".agent-log" + + if not log_file.is_file(): + print(f"Log file not found: {log_file}") + return 1 + + print(f"{Colors.BLUE}=== Recent Log: {target} ==={Colors.NC}") + print(f"{Colors.DIM}Platform: {platform}{Colors.NC}") + print() + + lines = log_file.read_text(encoding="utf-8").splitlines() + for line in lines[-50:]: + try: + data = json.loads(line) + msg_type = data.get("type", "") + + if platform == "opencode": + # OpenCode format + if msg_type == "text": + text = data.get("text", "") + if text: + display = text[:300] + if len(text) > 300: + display += "..." + print(f"{Colors.BLUE}[TEXT]{Colors.NC} {display}") + elif msg_type == "tool_use": + tool_name = data.get("tool", "unknown") + status = data.get("state", {}).get("status", "") + print(f"{Colors.YELLOW}[TOOL]{Colors.NC} {tool_name} ({status})") + elif msg_type == "step_start": + print(f"{Colors.CYAN}[STEP]{Colors.NC} Start") + elif msg_type == "step_finish": + reason = data.get("reason", "") + print(f"{Colors.CYAN}[STEP]{Colors.NC} Finish ({reason})") + elif msg_type == "error": + error_msg = data.get("message", "") + print(f"{Colors.RED}[ERROR]{Colors.NC} {error_msg}") + else: + # Claude Code format + if msg_type == "system": + subtype = data.get("subtype", "") + print(f"{Colors.CYAN}[SYSTEM]{Colors.NC} {subtype}") + elif msg_type == "user": + content = data.get("message", {}).get("content", "") + if content: + print(f"{Colors.GREEN}[USER]{Colors.NC} {content[:200]}") + elif msg_type == "assistant": + content = data.get("message", {}).get("content", []) + if content: + item = content[0] + text = item.get("text") + tool = item.get("name") + if text: + display = text[:300] + if len(text) > 300: + display += "..." + print(f"{Colors.BLUE}[ASSISTANT]{Colors.NC} {display}") + elif tool: + print(f"{Colors.YELLOW}[TOOL]{Colors.NC} {tool}") + elif msg_type == "result": + tool_name = data.get("tool", "unknown") + print(f"{Colors.DIM}[RESULT]{Colors.NC} {tool_name} completed") + except json.JSONDecodeError: + continue + + return 0 diff --git a/.trellis/scripts/task.py b/.trellis/scripts/task.py index 29f614c..2788abb 100644 --- a/.trellis/scripts/task.py +++ b/.trellis/scripts/task.py @@ -4,8 +4,8 @@ Task Management Script for Multi-Agent Pipeline. Usage: - python3 task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] - python3 task.py init-context <dir> <type> # Initialize jsonl files + python3 task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] [--package <pkg>] + python3 task.py init-context <dir> <type> [--package <pkg>] # Initialize jsonl files python3 task.py add-context <dir> <file> <path> [reason] # Add jsonl entry python3 task.py validate <dir> # Validate jsonl files python3 task.py list-context <dir> # List jsonl entries @@ -24,31 +24,14 @@ Usage: from __future__ import annotations -import sys - -# IMPORTANT: Force stdout to use UTF-8 on Windows -# This fixes UnicodeEncodeError when outputting non-ASCII characters -if sys.platform == "win32": - import io as _io - if hasattr(sys.stdout, "reconfigure"): - sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] - elif hasattr(sys.stdout, "detach"): - sys.stdout = _io.TextIOWrapper(sys.stdout.detach(), encoding="utf-8", errors="replace") # type: ignore[union-attr] - import argparse -import json -import re import sys -from datetime import datetime from pathlib import Path -from common.cli_adapter import get_cli_adapter_auto -from common.git_context import _run_git_command +from common.log import Colors, colored from common.paths import ( DIR_WORKFLOW, DIR_TASKS, - DIR_SPEC, - DIR_ARCHIVE, FILE_TASK_JSON, get_repo_root, get_developer, @@ -56,585 +39,26 @@ from common.paths import ( get_current_task, set_current_task, clear_current_task, - generate_task_date_prefix, ) -from common.task_utils import ( - find_task_by_name, - archive_task_complete, +from common.task_utils import resolve_task_dir, run_task_hooks +from common.tasks import iter_active_tasks, children_progress + +# Import command handlers from split modules (also re-exports for plan.py compatibility) +from common.task_store import ( + cmd_create, + cmd_archive, + cmd_set_branch, + cmd_set_base_branch, + cmd_set_scope, + cmd_add_subtask, + cmd_remove_subtask, +) +from common.task_context import ( + cmd_init_context, + cmd_add_context, + cmd_validate, + cmd_list_context, ) -from common.config import get_hooks - - -# ============================================================================= -# Colors -# ============================================================================= - -class Colors: - RED = "\033[0;31m" - GREEN = "\033[0;32m" - YELLOW = "\033[1;33m" - BLUE = "\033[0;34m" - CYAN = "\033[0;36m" - NC = "\033[0m" - - -def colored(text: str, color: str) -> str: - """Apply color to text.""" - return f"{color}{text}{Colors.NC}" - - -# ============================================================================= -# Lifecycle Hooks -# ============================================================================= - -def _run_hooks(event: str, task_json_path: Path, repo_root: Path) -> None: - """Run lifecycle hooks for an event. - - Args: - event: Event name (e.g. "after_create"). - task_json_path: Absolute path to the task's task.json. - repo_root: Repository root for cwd and config lookup. - """ - import os - import subprocess - - commands = get_hooks(event, repo_root) - if not commands: - return - - env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)} - - for cmd in commands: - try: - result = subprocess.run( - cmd, - shell=True, - cwd=repo_root, - env=env, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - if result.returncode != 0: - print( - colored(f"[WARN] Hook failed ({event}): {cmd}", Colors.YELLOW), - file=sys.stderr, - ) - if result.stderr.strip(): - print(f" {result.stderr.strip()}", file=sys.stderr) - except Exception as e: - print( - colored(f"[WARN] Hook error ({event}): {cmd} — {e}", Colors.YELLOW), - file=sys.stderr, - ) - - -# ============================================================================= -# Helper Functions -# ============================================================================= - -def _read_json_file(path: Path) -> dict | None: - """Read and parse a JSON file.""" - try: - return json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return None - - -def _write_json_file(path: Path, data: dict) -> bool: - """Write dict to JSON file.""" - try: - path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") - return True - except (OSError, IOError): - return False - - -def _slugify(title: str) -> str: - """Convert title to slug (only works with ASCII).""" - result = title.lower() - result = re.sub(r"[^a-z0-9]", "-", result) - result = re.sub(r"-+", "-", result) - result = result.strip("-") - return result - - -def _resolve_task_dir(target_dir: str, repo_root: Path) -> Path: - """Resolve task directory to absolute path. - - Supports: - - Absolute path: /path/to/task - - Relative path: .trellis/tasks/01-31-my-task - - Task name: my-task (uses find_task_by_name for lookup) - """ - if not target_dir: - return Path() - - # Absolute path - if target_dir.startswith("/"): - return Path(target_dir) - - # Relative path (contains path separator or starts with .trellis) - if "/" in target_dir or target_dir.startswith(".trellis"): - return repo_root / target_dir - - # Task name - try to find in tasks directory - tasks_dir = get_tasks_dir(repo_root) - found = find_task_by_name(target_dir, tasks_dir) - if found: - return found - - # Fallback to treating as relative path - return repo_root / target_dir - - -# ============================================================================= -# JSONL Default Content Generators -# ============================================================================= - -def get_implement_base() -> list[dict]: - """Get base implement context entries.""" - return [ - {"file": f"{DIR_WORKFLOW}/workflow.md", "reason": "Project workflow and conventions"}, - ] - - -def get_implement_backend() -> list[dict]: - """Get backend implement context entries.""" - return [ - {"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/backend/index.md", "reason": "Backend development guide"}, - ] - - -def get_implement_frontend() -> list[dict]: - """Get frontend implement context entries.""" - return [ - {"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/frontend/index.md", "reason": "Frontend development guide"}, - ] - - -def get_check_context(dev_type: str, repo_root: Path) -> list[dict]: - """Get check context entries.""" - adapter = get_cli_adapter_auto(repo_root) - - entries = [ - {"file": adapter.get_trellis_command_path("finish-work"), "reason": "Finish work checklist"}, - ] - - if dev_type in ("backend", "fullstack"): - entries.append({"file": adapter.get_trellis_command_path("check-backend"), "reason": "Backend check spec"}) - if dev_type in ("frontend", "fullstack"): - entries.append({"file": adapter.get_trellis_command_path("check-frontend"), "reason": "Frontend check spec"}) - - return entries - - -def get_debug_context(dev_type: str, repo_root: Path) -> list[dict]: - """Get debug context entries.""" - adapter = get_cli_adapter_auto(repo_root) - - entries: list[dict] = [] - - if dev_type in ("backend", "fullstack"): - entries.append({"file": adapter.get_trellis_command_path("check-backend"), "reason": "Backend check spec"}) - if dev_type in ("frontend", "fullstack"): - entries.append({"file": adapter.get_trellis_command_path("check-frontend"), "reason": "Frontend check spec"}) - - return entries - - -def _write_jsonl(path: Path, entries: list[dict]) -> None: - """Write entries to JSONL file.""" - lines = [json.dumps(entry, ensure_ascii=False) for entry in entries] - path.write_text("\n".join(lines) + "\n", encoding="utf-8") - - -# ============================================================================= -# Task Operations -# ============================================================================= - -def ensure_tasks_dir(repo_root: Path) -> Path: - """Ensure tasks directory exists.""" - tasks_dir = get_tasks_dir(repo_root) - archive_dir = tasks_dir / "archive" - - if not tasks_dir.exists(): - tasks_dir.mkdir(parents=True) - print(colored(f"Created tasks directory: {tasks_dir}", Colors.GREEN), file=sys.stderr) - - if not archive_dir.exists(): - archive_dir.mkdir(parents=True) - - return tasks_dir - - -# ============================================================================= -# Command: create -# ============================================================================= - -def cmd_create(args: argparse.Namespace) -> int: - """Create a new task.""" - repo_root = get_repo_root() - - if not args.title: - print(colored("Error: title is required", Colors.RED), file=sys.stderr) - return 1 - - # Default assignee to current developer - assignee = args.assignee - if not assignee: - assignee = get_developer(repo_root) - if not assignee: - print(colored("Error: No developer set. Run init_developer.py first or use --assignee", Colors.RED), file=sys.stderr) - return 1 - - ensure_tasks_dir(repo_root) - - # Get current developer as creator - creator = get_developer(repo_root) or assignee - - # Generate slug if not provided - slug = args.slug or _slugify(args.title) - if not slug: - print(colored("Error: could not generate slug from title", Colors.RED), file=sys.stderr) - return 1 - - # Create task directory with MM-DD-slug format - tasks_dir = get_tasks_dir(repo_root) - date_prefix = generate_task_date_prefix() - dir_name = f"{date_prefix}-{slug}" - task_dir = tasks_dir / dir_name - task_json_path = task_dir / FILE_TASK_JSON - - if task_dir.exists(): - print(colored(f"Warning: Task directory already exists: {dir_name}", Colors.YELLOW), file=sys.stderr) - else: - task_dir.mkdir(parents=True) - - today = datetime.now().strftime("%Y-%m-%d") - - # Record current branch as base_branch (PR target) - _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root) - current_branch = branch_out.strip() or "main" - - task_data = { - "id": slug, - "name": slug, - "title": args.title, - "description": args.description or "", - "status": "planning", - "dev_type": None, - "scope": None, - "priority": args.priority, - "creator": creator, - "assignee": assignee, - "createdAt": today, - "completedAt": None, - "branch": None, - "base_branch": current_branch, - "worktree_path": None, - "current_phase": 0, - "next_action": [ - {"phase": 1, "action": "implement"}, - {"phase": 2, "action": "check"}, - {"phase": 3, "action": "finish"}, - {"phase": 4, "action": "create-pr"}, - ], - "commit": None, - "pr_url": None, - "subtasks": [], - "children": [], - "parent": None, - "relatedFiles": [], - "notes": "", - "meta": {}, - } - - _write_json_file(task_json_path, task_data) - - # Handle --parent: establish bidirectional link - if args.parent: - parent_dir = _resolve_task_dir(args.parent, repo_root) - parent_json_path = parent_dir / FILE_TASK_JSON - if not parent_json_path.is_file(): - print(colored(f"Warning: Parent task.json not found: {args.parent}", Colors.YELLOW), file=sys.stderr) - else: - parent_data = _read_json_file(parent_json_path) - if parent_data: - # Add child to parent's children list - parent_children = parent_data.get("children", []) - if dir_name not in parent_children: - parent_children.append(dir_name) - parent_data["children"] = parent_children - _write_json_file(parent_json_path, parent_data) - - # Set parent in child's task.json - task_data["parent"] = parent_dir.name - _write_json_file(task_json_path, task_data) - - print(colored(f"Linked as child of: {parent_dir.name}", Colors.GREEN), file=sys.stderr) - - print(colored(f"Created task: {dir_name}", Colors.GREEN), file=sys.stderr) - print("", file=sys.stderr) - print(colored("Next steps:", Colors.BLUE), file=sys.stderr) - print(" 1. Create prd.md with requirements", file=sys.stderr) - print(" 2. Run: python3 task.py init-context <dir> <dev_type>", file=sys.stderr) - print(" 3. Run: python3 task.py start <dir>", file=sys.stderr) - print("", file=sys.stderr) - - # Output relative path for script chaining - print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}") - - _run_hooks("after_create", task_json_path, repo_root) - return 0 - - -# ============================================================================= -# Command: init-context -# ============================================================================= - -def cmd_init_context(args: argparse.Namespace) -> int: - """Initialize JSONL context files for a task.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - dev_type = args.type - - if not dev_type: - print(colored("Error: Missing arguments", Colors.RED)) - print("Usage: python3 task.py init-context <task-dir> <dev_type>") - print(" dev_type: backend | frontend | fullstack | test | docs") - return 1 - - if not target_dir.is_dir(): - print(colored(f"Error: Directory not found: {target_dir}", Colors.RED)) - return 1 - - print(colored("=== Initializing Agent Context Files ===", Colors.BLUE)) - print(f"Target dir: {target_dir}") - print(f"Dev type: {dev_type}") - print() - - # implement.jsonl - print(colored("Creating implement.jsonl...", Colors.CYAN)) - implement_entries = get_implement_base() - if dev_type in ("backend", "test"): - implement_entries.extend(get_implement_backend()) - elif dev_type == "frontend": - implement_entries.extend(get_implement_frontend()) - elif dev_type == "fullstack": - implement_entries.extend(get_implement_backend()) - implement_entries.extend(get_implement_frontend()) - - implement_file = target_dir / "implement.jsonl" - _write_jsonl(implement_file, implement_entries) - print(f" {colored('✓', Colors.GREEN)} {len(implement_entries)} entries") - - # check.jsonl - print(colored("Creating check.jsonl...", Colors.CYAN)) - check_entries = get_check_context(dev_type, repo_root) - check_file = target_dir / "check.jsonl" - _write_jsonl(check_file, check_entries) - print(f" {colored('✓', Colors.GREEN)} {len(check_entries)} entries") - - # debug.jsonl - print(colored("Creating debug.jsonl...", Colors.CYAN)) - debug_entries = get_debug_context(dev_type, repo_root) - debug_file = target_dir / "debug.jsonl" - _write_jsonl(debug_file, debug_entries) - print(f" {colored('✓', Colors.GREEN)} {len(debug_entries)} entries") - - print() - print(colored("✓ All context files created", Colors.GREEN)) - print() - print(colored("Next steps:", Colors.BLUE)) - print(" 1. Add task-specific specs: python3 task.py add-context <dir> <jsonl> <path>") - print(" 2. Set as current: python3 task.py start <dir>") - - return 0 - - -# ============================================================================= -# Command: add-context -# ============================================================================= - -def cmd_add_context(args: argparse.Namespace) -> int: - """Add entry to JSONL context file.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - - jsonl_name = args.file - path = args.path - reason = args.reason or "Added manually" - - if not target_dir.is_dir(): - print(colored(f"Error: Directory not found: {target_dir}", Colors.RED)) - return 1 - - # Support shorthand - if not jsonl_name.endswith(".jsonl"): - jsonl_name = f"{jsonl_name}.jsonl" - - jsonl_file = target_dir / jsonl_name - full_path = repo_root / path - - entry_type = "file" - if full_path.is_dir(): - entry_type = "directory" - if not path.endswith("/"): - path = f"{path}/" - elif not full_path.is_file(): - print(colored(f"Error: Path not found: {path}", Colors.RED)) - return 1 - - # Check if already exists - if jsonl_file.is_file(): - content = jsonl_file.read_text(encoding="utf-8") - if f'"{path}"' in content: - print(colored(f"Warning: Entry already exists for {path}", Colors.YELLOW)) - return 0 - - # Add entry - entry: dict - if entry_type == "directory": - entry = {"file": path, "type": "directory", "reason": reason} - else: - entry = {"file": path, "reason": reason} - - with jsonl_file.open("a", encoding="utf-8") as f: - f.write(json.dumps(entry, ensure_ascii=False) + "\n") - - print(colored(f"Added {entry_type}: {path}", Colors.GREEN)) - return 0 - - -# ============================================================================= -# Command: validate -# ============================================================================= - -def cmd_validate(args: argparse.Namespace) -> int: - """Validate JSONL context files.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - - if not target_dir.is_dir(): - print(colored("Error: task directory required", Colors.RED)) - return 1 - - print(colored("=== Validating Context Files ===", Colors.BLUE)) - print(f"Target dir: {target_dir}") - print() - - total_errors = 0 - for jsonl_name in ["implement.jsonl", "check.jsonl", "debug.jsonl"]: - jsonl_file = target_dir / jsonl_name - errors = _validate_jsonl(jsonl_file, repo_root) - total_errors += errors - - print() - if total_errors == 0: - print(colored("✓ All validations passed", Colors.GREEN)) - return 0 - else: - print(colored(f"✗ Validation failed ({total_errors} errors)", Colors.RED)) - return 1 - - -def _validate_jsonl(jsonl_file: Path, repo_root: Path) -> int: - """Validate a single JSONL file.""" - file_name = jsonl_file.name - errors = 0 - - if not jsonl_file.is_file(): - print(f" {colored(f'{file_name}: not found (skipped)', Colors.YELLOW)}") - return 0 - - line_num = 0 - for line in jsonl_file.read_text(encoding="utf-8").splitlines(): - line_num += 1 - if not line.strip(): - continue - - try: - data = json.loads(line) - except json.JSONDecodeError: - print(f" {colored(f'{file_name}:{line_num}: Invalid JSON', Colors.RED)}") - errors += 1 - continue - - file_path = data.get("file") - entry_type = data.get("type", "file") - - if not file_path: - print(f" {colored(f'{file_name}:{line_num}: Missing file field', Colors.RED)}") - errors += 1 - continue - - full_path = repo_root / file_path - if entry_type == "directory": - if not full_path.is_dir(): - print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}") - errors += 1 - else: - if not full_path.is_file(): - print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}") - errors += 1 - - if errors == 0: - print(f" {colored(f'{file_name}: ✓ ({line_num} entries)', Colors.GREEN)}") - else: - print(f" {colored(f'{file_name}: ✗ ({errors} errors)', Colors.RED)}") - - return errors - - -# ============================================================================= -# Command: list-context -# ============================================================================= - -def cmd_list_context(args: argparse.Namespace) -> int: - """List JSONL context entries.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - - if not target_dir.is_dir(): - print(colored("Error: task directory required", Colors.RED)) - return 1 - - print(colored("=== Context Files ===", Colors.BLUE)) - print() - - for jsonl_name in ["implement.jsonl", "check.jsonl", "debug.jsonl"]: - jsonl_file = target_dir / jsonl_name - if not jsonl_file.is_file(): - continue - - print(colored(f"[{jsonl_name}]", Colors.CYAN)) - - count = 0 - for line in jsonl_file.read_text(encoding="utf-8").splitlines(): - if not line.strip(): - continue - - try: - data = json.loads(line) - except json.JSONDecodeError: - continue - - count += 1 - file_path = data.get("file", "?") - entry_type = data.get("type", "file") - reason = data.get("reason", "-") - - if entry_type == "directory": - print(f" {colored(f'{count}.', Colors.GREEN)} [DIR] {file_path}") - else: - print(f" {colored(f'{count}.', Colors.GREEN)} {file_path}") - print(f" {colored('→', Colors.YELLOW)} {reason}") - - print() - - return 0 # ============================================================================= @@ -651,7 +75,7 @@ def cmd_start(args: argparse.Namespace) -> int: return 1 # Resolve task directory (supports task name, relative path, or absolute path) - full_path = _resolve_task_dir(task_input, repo_root) + full_path = resolve_task_dir(task_input, repo_root) if not full_path.is_dir(): print(colored(f"Error: Task not found: {task_input}", Colors.RED)) @@ -670,7 +94,7 @@ def cmd_start(args: argparse.Namespace) -> int: print(colored("The hook will now inject context from this task's jsonl files.", Colors.BLUE)) task_json_path = full_path / FILE_TASK_JSON - _run_hooks("after_start", task_json_path, repo_root) + run_task_hooks("after_start", task_json_path, repo_root) return 0 else: print(colored("Error: Failed to set current task", Colors.RED)) @@ -693,221 +117,7 @@ def cmd_finish(args: argparse.Namespace) -> int: print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN)) if task_json_path.is_file(): - _run_hooks("after_finish", task_json_path, repo_root) - return 0 - - -# ============================================================================= -# Command: archive -# ============================================================================= - -def cmd_archive(args: argparse.Namespace) -> int: - """Archive completed task.""" - repo_root = get_repo_root() - task_name = args.name - - if not task_name: - print(colored("Error: Task name is required", Colors.RED), file=sys.stderr) - return 1 - - tasks_dir = get_tasks_dir(repo_root) - - # Find task directory - task_dir = find_task_by_name(task_name, tasks_dir) - - if not task_dir or not task_dir.is_dir(): - print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr) - print("Active tasks:", file=sys.stderr) - cmd_list(argparse.Namespace(mine=False, status=None)) - return 1 - - dir_name = task_dir.name - task_json_path = task_dir / FILE_TASK_JSON - - # Update status before archiving - today = datetime.now().strftime("%Y-%m-%d") - if task_json_path.is_file(): - data = _read_json_file(task_json_path) - if data: - data["status"] = "completed" - data["completedAt"] = today - _write_json_file(task_json_path, data) - - # Handle subtask relationships on archive - task_parent = data.get("parent") - task_children = data.get("children", []) - - # If this is a child, remove from parent's children list - if task_parent: - parent_dir = find_task_by_name(task_parent, tasks_dir) - if parent_dir: - parent_json = parent_dir / FILE_TASK_JSON - if parent_json.is_file(): - parent_data = _read_json_file(parent_json) - if parent_data: - parent_children = parent_data.get("children", []) - if dir_name in parent_children: - parent_children.remove(dir_name) - parent_data["children"] = parent_children - _write_json_file(parent_json, parent_data) - - # If this is a parent, clear parent field in all children - if task_children: - for child_name in task_children: - child_dir_path = find_task_by_name(child_name, tasks_dir) - if child_dir_path: - child_json = child_dir_path / FILE_TASK_JSON - if child_json.is_file(): - child_data = _read_json_file(child_json) - if child_data: - child_data["parent"] = None - _write_json_file(child_json, child_data) - - # Clear if current task - current = get_current_task(repo_root) - if current and dir_name in current: - clear_current_task(repo_root) - - # Archive - result = archive_task_complete(task_dir, repo_root) - if "archived_to" in result: - archive_dest = Path(result["archived_to"]) - year_month = archive_dest.parent.name - print(colored(f"Archived: {dir_name} -> archive/{year_month}/", Colors.GREEN), file=sys.stderr) - - # Auto-commit unless --no-commit - if not getattr(args, "no_commit", False): - _auto_commit_archive(dir_name, repo_root) - - # Return the archive path - print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}") - - # Run hooks with the archived path - archived_json = archive_dest / FILE_TASK_JSON - _run_hooks("after_archive", archived_json, repo_root) - return 0 - - return 1 - - -def _auto_commit_archive(task_name: str, repo_root: Path) -> None: - """Stage .trellis/tasks/ changes and commit after archive.""" - tasks_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}" - _run_git_command(["add", "-A", tasks_rel], cwd=repo_root) - - # Check if there are staged changes - rc, _, _ = _run_git_command( - ["diff", "--cached", "--quiet", "--", tasks_rel], cwd=repo_root - ) - if rc == 0: - print("[OK] No task changes to commit.", file=sys.stderr) - return - - commit_msg = f"chore(task): archive {task_name}" - rc, _, err = _run_git_command(["commit", "-m", commit_msg], cwd=repo_root) - if rc == 0: - print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr) - else: - print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr) - - -# ============================================================================= -# Command: add-subtask -# ============================================================================= - -def cmd_add_subtask(args: argparse.Namespace) -> int: - """Link a child task to a parent task.""" - repo_root = get_repo_root() - - parent_dir = _resolve_task_dir(args.parent_dir, repo_root) - child_dir = _resolve_task_dir(args.child_dir, repo_root) - - parent_json_path = parent_dir / FILE_TASK_JSON - child_json_path = child_dir / FILE_TASK_JSON - - if not parent_json_path.is_file(): - print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr) - return 1 - - if not child_json_path.is_file(): - print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) - return 1 - - parent_data = _read_json_file(parent_json_path) - child_data = _read_json_file(child_json_path) - - if not parent_data or not child_data: - print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr) - return 1 - - # Check if child already has a parent - existing_parent = child_data.get("parent") - if existing_parent: - print(colored(f"Error: Child task already has a parent: {existing_parent}", Colors.RED), file=sys.stderr) - return 1 - - # Add child to parent's children list - parent_children = parent_data.get("children", []) - child_dir_name = child_dir.name - if child_dir_name not in parent_children: - parent_children.append(child_dir_name) - parent_data["children"] = parent_children - - # Set parent in child's task.json - child_data["parent"] = parent_dir.name - - # Write both - _write_json_file(parent_json_path, parent_data) - _write_json_file(child_json_path, child_data) - - print(colored(f"Linked: {child_dir.name} -> {parent_dir.name}", Colors.GREEN), file=sys.stderr) - return 0 - - -# ============================================================================= -# Command: remove-subtask -# ============================================================================= - -def cmd_remove_subtask(args: argparse.Namespace) -> int: - """Unlink a child task from a parent task.""" - repo_root = get_repo_root() - - parent_dir = _resolve_task_dir(args.parent_dir, repo_root) - child_dir = _resolve_task_dir(args.child_dir, repo_root) - - parent_json_path = parent_dir / FILE_TASK_JSON - child_json_path = child_dir / FILE_TASK_JSON - - if not parent_json_path.is_file(): - print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr) - return 1 - - if not child_json_path.is_file(): - print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) - return 1 - - parent_data = _read_json_file(parent_json_path) - child_data = _read_json_file(child_json_path) - - if not parent_data or not child_data: - print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr) - return 1 - - # Remove child from parent's children list - parent_children = parent_data.get("children", []) - child_dir_name = child_dir.name - if child_dir_name in parent_children: - parent_children.remove(child_dir_name) - parent_data["children"] = parent_children - - # Clear parent in child's task.json - child_data["parent"] = None - - # Write both - _write_json_file(parent_json_path, parent_data) - _write_json_file(child_json_path, child_data) - - print(colored(f"Unlinked: {child_dir.name} from {parent_dir.name}", Colors.GREEN), file=sys.stderr) + run_task_hooks("after_finish", task_json_path, repo_root) return 0 @@ -915,24 +125,6 @@ def cmd_remove_subtask(args: argparse.Namespace) -> int: # Command: list # ============================================================================= -def _get_children_progress(children: list[str], tasks_dir: Path) -> str: - """Get children progress summary like '[2/3 done]'.""" - if not children: - return "" - done_count = 0 - total = len(children) - for child_name in children: - child_dir = tasks_dir / child_name - child_json = child_dir / FILE_TASK_JSON - if child_json.is_file(): - data = _read_json_file(child_json) - if data: - status = data.get("status", "") - if status in ("completed", "done"): - done_count += 1 - return f" [{done_count}/{total} done]" - - def cmd_list(args: argparse.Namespace) -> int: """List active tasks.""" repo_root = get_repo_root() @@ -951,51 +143,23 @@ def cmd_list(args: argparse.Namespace) -> int: print(colored("All active tasks:", Colors.BLUE)) print() - # First pass: collect all task data and identify parent/child relationships - all_tasks: dict[str, dict] = {} - if tasks_dir.is_dir(): - for d in sorted(tasks_dir.iterdir()): - if not d.is_dir() or d.name == "archive": - continue - - dir_name = d.name - task_json = d / FILE_TASK_JSON - status = "unknown" - assignee = "-" - children: list[str] = [] - parent: str | None = None - - if task_json.is_file(): - data = _read_json_file(task_json) - if data: - status = data.get("status", "unknown") - assignee = data.get("assignee", "-") - children = data.get("children", []) - parent = data.get("parent") - - all_tasks[dir_name] = { - "status": status, - "assignee": assignee, - "children": children, - "parent": parent, - } - - # Second pass: display tasks hierarchically + # Single pass: collect all tasks via shared iterator + all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)} + all_statuses = {name: t.status for name, t in all_tasks.items()} + + # Display tasks hierarchically count = 0 def _print_task(dir_name: str, indent: int = 0) -> None: nonlocal count - info = all_tasks[dir_name] - status = info["status"] - assignee = info["assignee"] - children = info["children"] + t = all_tasks[dir_name] # Apply --mine filter - if filter_mine and assignee != developer: + if filter_mine and (t.assignee or "-") != developer: return # Apply --status filter - if filter_status and status != filter_status: + if filter_status and t.status != filter_status: return relative_path = f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}" @@ -1004,25 +168,27 @@ def cmd_list(args: argparse.Namespace) -> int: marker = f" {colored('<- current', Colors.GREEN)}" # Children progress - progress = _get_children_progress(children, tasks_dir) if children else "" + progress = children_progress(t.children, all_statuses) + + # Package tag + pkg_tag = f" @{t.package}" if t.package else "" prefix = " " * indent + " - " if filter_mine: - print(f"{prefix}{dir_name}/ ({status}){progress}{marker}") + print(f"{prefix}{dir_name}/ ({t.status}){pkg_tag}{progress}{marker}") else: - print(f"{prefix}{dir_name}/ ({status}){progress} [{colored(assignee, Colors.CYAN)}]{marker}") + print(f"{prefix}{dir_name}/ ({t.status}){pkg_tag}{progress} [{colored(t.assignee or '-', Colors.CYAN)}]{marker}") count += 1 # Print children indented - for child_name in children: + for child_name in t.children: if child_name in all_tasks: _print_task(child_name, indent + 1) # Display only top-level tasks (those without a parent) for dir_name in sorted(all_tasks.keys()): - info = all_tasks[dir_name] - if not info["parent"]: + if not all_tasks[dir_name].parent: _print_task(dir_name) if count == 0: @@ -1070,106 +236,6 @@ def cmd_list_archive(args: argparse.Namespace) -> int: return 0 -# ============================================================================= -# Command: set-branch -# ============================================================================= - -def cmd_set_branch(args: argparse.Namespace) -> int: - """Set git branch for task.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - branch = args.branch - - if not branch: - print(colored("Error: Missing arguments", Colors.RED)) - print("Usage: python3 task.py set-branch <task-dir> <branch-name>") - return 1 - - task_json = target_dir / FILE_TASK_JSON - if not task_json.is_file(): - print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) - return 1 - - data = _read_json_file(task_json) - if not data: - return 1 - - data["branch"] = branch - _write_json_file(task_json, data) - - print(colored(f"✓ Branch set to: {branch}", Colors.GREEN)) - print() - print(colored("Now you can start the multi-agent pipeline:", Colors.BLUE)) - print(f" python3 ./.trellis/scripts/multi_agent/start.py {args.dir}") - return 0 - - -# ============================================================================= -# Command: set-base-branch -# ============================================================================= - -def cmd_set_base_branch(args: argparse.Namespace) -> int: - """Set the base branch (PR target) for task.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - base_branch = args.base_branch - - if not base_branch: - print(colored("Error: Missing arguments", Colors.RED)) - print("Usage: python3 task.py set-base-branch <task-dir> <base-branch>") - print("Example: python3 task.py set-base-branch <dir> develop") - print() - print("This sets the target branch for PR (the branch your feature will merge into).") - return 1 - - task_json = target_dir / FILE_TASK_JSON - if not task_json.is_file(): - print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) - return 1 - - data = _read_json_file(task_json) - if not data: - return 1 - - data["base_branch"] = base_branch - _write_json_file(task_json, data) - - print(colored(f"✓ Base branch set to: {base_branch}", Colors.GREEN)) - print(f" PR will target: {base_branch}") - return 0 - - -# ============================================================================= -# Command: set-scope -# ============================================================================= - -def cmd_set_scope(args: argparse.Namespace) -> int: - """Set scope for PR title.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - scope = args.scope - - if not scope: - print(colored("Error: Missing arguments", Colors.RED)) - print("Usage: python3 task.py set-scope <task-dir> <scope>") - return 1 - - task_json = target_dir / FILE_TASK_JSON - if not task_json.is_file(): - print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) - return 1 - - data = _read_json_file(task_json) - if not data: - return 1 - - data["scope"] = scope - _write_json_file(task_json, data) - - print(colored(f"✓ Scope set to: {scope}", Colors.GREEN)) - return 0 - - # ============================================================================= # Command: create-pr (delegates to multi-agent script) # ============================================================================= @@ -1200,8 +266,10 @@ def show_usage() -> None: Usage: python3 task.py create <title> Create new task directory + python3 task.py create <title> --package <pkg> Create task for a specific package python3 task.py create <title> --parent <dir> Create task as child of parent python3 task.py init-context <dir> <dev_type> Initialize jsonl files + python3 task.py init-context <dir> <type> --package <pkg> With explicit package python3 task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl python3 task.py validate <dir> Validate jsonl files python3 task.py list-context <dir> List jsonl entries @@ -1219,15 +287,20 @@ Usage: Arguments: dev_type: backend | frontend | fullstack | test | docs +Monorepo options: + --package <pkg> Package name (validated against config.yaml packages) + List options: --mine, -m Show only tasks assigned to current developer --status, -s <s> Filter by status (planning, in_progress, review, completed) Examples: python3 task.py create "Add login feature" --slug add-login + python3 task.py create "Add login feature" --slug add-login --package cli python3 task.py create "Child task" --slug child --parent .trellis/tasks/01-21-parent python3 task.py init-context .trellis/tasks/01-21-add-login backend - python3 task.py add-context <dir> implement .trellis/spec/backend/auth.md "Auth guidelines" + python3 task.py init-context .trellis/tasks/01-21-add-login backend --package cli + python3 task.py add-context <dir> implement .trellis/spec/cli/backend/auth.md "Auth guidelines" python3 task.py set-branch <dir> task/add-login python3 task.py start .trellis/tasks/01-21-add-login python3 task.py create-pr # Uses current task @@ -1262,11 +335,13 @@ def main() -> int: p_create.add_argument("--priority", "-p", default="P2", help="Priority (P0-P3)") p_create.add_argument("--description", "-d", help="Task description") p_create.add_argument("--parent", help="Parent task directory (establishes subtask link)") + p_create.add_argument("--package", help="Package name for monorepo projects") # init-context p_init = subparsers.add_parser("init-context", help="Initialize context files") p_init.add_argument("dir", help="Task directory") p_init.add_argument("type", help="Dev type: backend|frontend|fullstack|test|docs") + p_init.add_argument("--package", help="Package name for monorepo projects") # add-context p_add = subparsers.add_parser("add-context", help="Add context entry") diff --git a/.trellis/tasks/04-07-migrate-to-0.4.0-beta.8/prd.md b/.trellis/tasks/04-07-migrate-to-0.4.0-beta.8/prd.md new file mode 100644 index 0000000..e91cb4c --- /dev/null +++ b/.trellis/tasks/04-07-migrate-to-0.4.0-beta.8/prd.md @@ -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`). + diff --git a/.trellis/tasks/04-07-migrate-to-0.4.0-beta.8/task.json b/.trellis/tasks/04-07-migrate-to-0.4.0-beta.8/task.json new file mode 100644 index 0000000..101f1e9 --- /dev/null +++ b/.trellis/tasks/04-07-migrate-to-0.4.0-beta.8/task.json @@ -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": {} +} \ No newline at end of file