forked from mengyxu/noob-components
72 changed files with 5248 additions and 3072 deletions
@ -1,18 +0,0 @@
@@ -1,18 +0,0 @@
|
||||
--- |
||||
name: before-backend-dev |
||||
description: "Read the backend development guidelines before starting your development task." |
||||
--- |
||||
|
||||
Read the backend development guidelines before starting your development task. |
||||
|
||||
Execute these steps: |
||||
1. Read `.trellis/spec/backend/index.md` to understand available guidelines |
||||
2. Based on your task, read the relevant guideline files: |
||||
- Database work → `.trellis/spec/backend/database-guidelines.md` |
||||
- Error handling → `.trellis/spec/backend/error-handling.md` |
||||
- Logging → `.trellis/spec/backend/logging-guidelines.md` |
||||
- Type questions → `.trellis/spec/backend/type-safety.md` |
||||
3. Understand the coding standards and patterns you need to follow |
||||
4. Then proceed with your development plan |
||||
|
||||
This step is **mandatory** before writing any backend code. |
||||
@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
|
||||
--- |
||||
name: before-dev |
||||
description: "Discovers and injects project-specific coding guidelines from .trellis/spec/ before implementation begins. Reads spec indexes, pre-development checklists, and shared thinking guides for the target package. Use when starting a new coding task, before writing any code, switching to a different package, or needing to refresh project conventions and standards." |
||||
--- |
||||
|
||||
Read the relevant development guidelines before starting your task. |
||||
|
||||
Execute these steps: |
||||
|
||||
1. **Discover packages and their spec layers**: |
||||
```bash |
||||
python3 ./.trellis/scripts/get_context.py --mode packages |
||||
``` |
||||
|
||||
2. **Identify which specs apply** to your task based on: |
||||
- Which package you're modifying (e.g., `cli/`, `docs-site/`) |
||||
- What type of work (backend, frontend, unit-test, docs, etc.) |
||||
|
||||
3. **Read the spec index** for each relevant module: |
||||
```bash |
||||
cat .trellis/spec/<package>/<layer>/index.md |
||||
``` |
||||
Follow the **"Pre-Development Checklist"** section in the index. |
||||
|
||||
4. **Read the specific guideline files** listed in the Pre-Development Checklist that are relevant to your task. The index is NOT the goal — it points you to the actual guideline files (e.g., `error-handling.md`, `conventions.md`, `mock-strategies.md`). Read those files to understand the coding standards and patterns. |
||||
|
||||
5. **Always read shared guides**: |
||||
```bash |
||||
cat .trellis/spec/guides/index.md |
||||
``` |
||||
|
||||
6. Understand the coding standards and patterns you need to follow, then proceed with your development plan. |
||||
|
||||
This step is **mandatory** before writing any code. |
||||
@ -1,18 +0,0 @@
@@ -1,18 +0,0 @@
|
||||
--- |
||||
name: before-frontend-dev |
||||
description: "Read the frontend development guidelines before starting your development task." |
||||
--- |
||||
|
||||
Read the frontend development guidelines before starting your development task. |
||||
|
||||
Execute these steps: |
||||
1. Read `.trellis/spec/frontend/index.md` to understand available guidelines |
||||
2. Based on your task, read the relevant guideline files: |
||||
- Component work → `.trellis/spec/frontend/component-guidelines.md` |
||||
- Hook work → `.trellis/spec/frontend/hook-guidelines.md` |
||||
- State management → `.trellis/spec/frontend/state-management.md` |
||||
- Type questions → `.trellis/spec/frontend/type-safety.md` |
||||
3. Understand the coding standards and patterns you need to follow |
||||
4. Then proceed with your development plan |
||||
|
||||
This step is **mandatory** before writing any frontend code. |
||||
@ -1,18 +0,0 @@
@@ -1,18 +0,0 @@
|
||||
--- |
||||
name: check-backend |
||||
description: "Check if the code you just wrote follows the backend development guidelines." |
||||
--- |
||||
|
||||
Check if the code you just wrote follows the backend development guidelines. |
||||
|
||||
Execute these steps: |
||||
1. Run `git status` to see modified files |
||||
2. Read `.trellis/spec/backend/index.md` to understand which guidelines apply |
||||
3. Based on what you changed, read the relevant guideline files: |
||||
- Database changes → `.trellis/spec/backend/database-guidelines.md` |
||||
- Error handling → `.trellis/spec/backend/error-handling.md` |
||||
- Logging changes → `.trellis/spec/backend/logging-guidelines.md` |
||||
- Type changes → `.trellis/spec/backend/type-safety.md` |
||||
- Any changes → `.trellis/spec/backend/quality-guidelines.md` |
||||
4. Review your code against the guidelines |
||||
5. Report any violations and fix them if found |
||||
@ -1,18 +0,0 @@
@@ -1,18 +0,0 @@
|
||||
--- |
||||
name: check-frontend |
||||
description: "Check if the code you just wrote follows the frontend development guidelines." |
||||
--- |
||||
|
||||
Check if the code you just wrote follows the frontend development guidelines. |
||||
|
||||
Execute these steps: |
||||
1. Run `git status` to see modified files |
||||
2. Read `.trellis/spec/frontend/index.md` to understand which guidelines apply |
||||
3. Based on what you changed, read the relevant guideline files: |
||||
- Component changes → `.trellis/spec/frontend/component-guidelines.md` |
||||
- Hook changes → `.trellis/spec/frontend/hook-guidelines.md` |
||||
- State changes → `.trellis/spec/frontend/state-management.md` |
||||
- Type changes → `.trellis/spec/frontend/type-safety.md` |
||||
- Any changes → `.trellis/spec/frontend/quality-guidelines.md` |
||||
4. Review your code against the guidelines |
||||
5. Report any violations and fix them if found |
||||
@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
|
||||
--- |
||||
name: check |
||||
description: "Validates recently written code against project-specific development guidelines from .trellis/spec/. Identifies changed files via git diff, discovers applicable spec modules, runs lint and typecheck, and reports guideline violations. Use when code is written and needs quality verification, to catch context drift during long sessions, or before committing changes." |
||||
--- |
||||
|
||||
Check if the code you just wrote follows the development guidelines. |
||||
|
||||
Execute these steps: |
||||
|
||||
1. **Identify changed files**: |
||||
```bash |
||||
git diff --name-only HEAD |
||||
``` |
||||
|
||||
2. **Determine which spec modules apply** based on the changed file paths: |
||||
```bash |
||||
python3 ./.trellis/scripts/get_context.py --mode packages |
||||
``` |
||||
|
||||
3. **Read the spec index** for each relevant module: |
||||
```bash |
||||
cat .trellis/spec/<package>/<layer>/index.md |
||||
``` |
||||
Follow the **"Quality Check"** section in the index. |
||||
|
||||
4. **Read the specific guideline files** referenced in the Quality Check section (e.g., `quality-guidelines.md`, `conventions.md`). The index is NOT the goal — it points you to the actual guideline files. Read those files and review your code against them. |
||||
|
||||
5. **Run lint and typecheck** for the affected package. |
||||
|
||||
6. **Report any violations** and fix them if found. |
||||
@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
|
||||
--- |
||||
name: improve-ut |
||||
description: "Analyzes changed files and improves unit test coverage using project-specific testing conventions from .trellis/spec/ unit-test specs. Determines test scope (unit vs integration vs regression), adds or updates tests following existing patterns, and runs validation. Use when code changes need test coverage, after implementing a feature, after fixing a bug, or when test gaps are identified." |
||||
--- |
||||
|
||||
# Improve Unit Tests (UT) |
||||
|
||||
Use this skill to improve test coverage after code changes. |
||||
|
||||
## Usage |
||||
|
||||
```text |
||||
$improve-ut |
||||
``` |
||||
|
||||
## Source of Truth |
||||
|
||||
Discover and read unit-test specs dynamically: |
||||
|
||||
```bash |
||||
# Discover available packages and their spec layers |
||||
python3 ./.trellis/scripts/get_context.py --mode packages |
||||
``` |
||||
|
||||
Look for packages with `unit-test` spec layer in the output. For each discovered `unit-test/` directory, read all relevant spec files inside it (for example `index.md`, `conventions.md`, `integration-patterns.md`, `mock-strategies.md`). |
||||
|
||||
> If this skill conflicts with the unit-test specs, the specs win. |
||||
|
||||
--- |
||||
|
||||
## Execution Flow |
||||
|
||||
1. Inspect changed files: |
||||
- `git diff --name-only` |
||||
2. Decide test scope using unit-test specs: |
||||
- unit vs integration vs regression |
||||
- mock vs real filesystem flow |
||||
3. Add/update tests using existing project test patterns |
||||
4. Run validation: |
||||
|
||||
```bash |
||||
pnpm lint |
||||
pnpm typecheck |
||||
pnpm test |
||||
``` |
||||
|
||||
5. Summarize decisions, updates, and remaining test gaps. |
||||
|
||||
--- |
||||
|
||||
## Output Format |
||||
|
||||
```markdown |
||||
## UT Coverage Plan |
||||
- Changed areas: ... |
||||
- Test scope (unit/integration/regression): ... |
||||
|
||||
## Test Updates |
||||
- Added: ... |
||||
- Updated: ... |
||||
|
||||
## Validation |
||||
- pnpm lint: pass/fail |
||||
- pnpm typecheck: pass/fail |
||||
- pnpm test: pass/fail |
||||
|
||||
## Gaps / Follow-ups |
||||
- <none or explicit rationale> |
||||
``` |
||||
@ -1,13 +0,0 @@
@@ -1,13 +0,0 @@
|
||||
Read the backend development guidelines before starting your development task. |
||||
|
||||
Execute these steps: |
||||
1. Read `.trellis/spec/backend/index.md` to understand available guidelines |
||||
2. Based on your task, read the relevant guideline files: |
||||
- Database work → `.trellis/spec/backend/database-guidelines.md` |
||||
- Error handling → `.trellis/spec/backend/error-handling.md` |
||||
- Logging → `.trellis/spec/backend/logging-guidelines.md` |
||||
- Type questions → `.trellis/spec/backend/type-safety.md` |
||||
3. Understand the coding standards and patterns you need to follow |
||||
4. Then proceed with your development plan |
||||
|
||||
This step is **mandatory** before writing any backend code. |
||||
@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
|
||||
Read the relevant development guidelines before starting your task. |
||||
|
||||
Execute these steps: |
||||
|
||||
1. **Discover packages and their spec layers**: |
||||
```bash |
||||
python3 ./.trellis/scripts/get_context.py --mode packages |
||||
``` |
||||
|
||||
2. **Identify which specs apply** to your task based on: |
||||
- Which package you're modifying (e.g., `cli/`, `docs-site/`) |
||||
- What type of work (backend, frontend, unit-test, docs, etc.) |
||||
|
||||
3. **Read the spec index** for each relevant module: |
||||
```bash |
||||
cat .trellis/spec/<package>/<layer>/index.md |
||||
``` |
||||
Follow the **"Pre-Development Checklist"** section in the index. |
||||
|
||||
4. **Read the specific guideline files** listed in the Pre-Development Checklist that are relevant to your task. The index is NOT the goal — it points you to the actual guideline files (e.g., `error-handling.md`, `conventions.md`, `mock-strategies.md`). Read those files to understand the coding standards and patterns. |
||||
|
||||
5. **Always read shared guides**: |
||||
```bash |
||||
cat .trellis/spec/guides/index.md |
||||
``` |
||||
|
||||
6. Understand the coding standards and patterns you need to follow, then proceed with your development plan. |
||||
|
||||
This step is **mandatory** before writing any code. |
||||
@ -1,13 +0,0 @@
@@ -1,13 +0,0 @@
|
||||
Read the frontend development guidelines before starting your development task. |
||||
|
||||
Execute these steps: |
||||
1. Read `.trellis/spec/frontend/index.md` to understand available guidelines |
||||
2. Based on your task, read the relevant guideline files: |
||||
- Component work → `.trellis/spec/frontend/component-guidelines.md` |
||||
- Hook work → `.trellis/spec/frontend/hook-guidelines.md` |
||||
- State management → `.trellis/spec/frontend/state-management.md` |
||||
- Type questions → `.trellis/spec/frontend/type-safety.md` |
||||
3. Understand the coding standards and patterns you need to follow |
||||
4. Then proceed with your development plan |
||||
|
||||
This step is **mandatory** before writing any frontend code. |
||||
@ -1,13 +0,0 @@
@@ -1,13 +0,0 @@
|
||||
Check if the code you just wrote follows the backend development guidelines. |
||||
|
||||
Execute these steps: |
||||
1. Run `git status` to see modified files |
||||
2. Read `.trellis/spec/backend/index.md` to understand which guidelines apply |
||||
3. Based on what you changed, read the relevant guideline files: |
||||
- Database changes → `.trellis/spec/backend/database-guidelines.md` |
||||
- Error handling → `.trellis/spec/backend/error-handling.md` |
||||
- Logging changes → `.trellis/spec/backend/logging-guidelines.md` |
||||
- Type changes → `.trellis/spec/backend/type-safety.md` |
||||
- Any changes → `.trellis/spec/backend/quality-guidelines.md` |
||||
4. Review your code against the guidelines |
||||
5. Report any violations and fix them if found |
||||
@ -1,13 +0,0 @@
@@ -1,13 +0,0 @@
|
||||
Check if the code you just wrote follows the frontend development guidelines. |
||||
|
||||
Execute these steps: |
||||
1. Run `git status` to see modified files |
||||
2. Read `.trellis/spec/frontend/index.md` to understand which guidelines apply |
||||
3. Based on what you changed, read the relevant guideline files: |
||||
- Component changes → `.trellis/spec/frontend/component-guidelines.md` |
||||
- Hook changes → `.trellis/spec/frontend/hook-guidelines.md` |
||||
- State changes → `.trellis/spec/frontend/state-management.md` |
||||
- Type changes → `.trellis/spec/frontend/type-safety.md` |
||||
- Any changes → `.trellis/spec/frontend/quality-guidelines.md` |
||||
4. Review your code against the guidelines |
||||
5. Report any violations and fix them if found |
||||
@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
|
||||
Check if the code you just wrote follows the development guidelines. |
||||
|
||||
Execute these steps: |
||||
|
||||
1. **Identify changed files**: |
||||
```bash |
||||
git diff --name-only HEAD |
||||
``` |
||||
|
||||
2. **Determine which spec modules apply** based on the changed file paths: |
||||
```bash |
||||
python3 ./.trellis/scripts/get_context.py --mode packages |
||||
``` |
||||
|
||||
3. **Read the spec index** for each relevant module: |
||||
```bash |
||||
cat .trellis/spec/<package>/<layer>/index.md |
||||
``` |
||||
Follow the **"Quality Check"** section in the index. |
||||
|
||||
4. **Read the specific guideline files** referenced in the Quality Check section (e.g., `quality-guidelines.md`, `conventions.md`). The index is NOT the goal — it points you to the actual guideline files. Read those files and review your code against them. |
||||
|
||||
5. **Run lint and typecheck** for the affected package. |
||||
|
||||
6. **Report any violations** and fix them if found. |
||||
@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
|
||||
name = "check" |
||||
description = "Read-only Trellis reviewer focused on correctness, missing tests, and spec drift." |
||||
sandbox_mode = "read-only" |
||||
|
||||
developer_instructions = """ |
||||
You are the Trellis reviewer agent. |
||||
|
||||
Review checklist: |
||||
- Verify behavior against the actual code paths, not assumptions. |
||||
- Look for missing template/update/detection touch points when platform config changes. |
||||
- Check whether tests should be added or updated. |
||||
- Check whether `.trellis/spec/` docs need sync after implementation. |
||||
- Prefer concrete findings over speculative warnings. |
||||
|
||||
Output format: |
||||
## Findings |
||||
- Severity: <high|medium|low> |
||||
- File: <path> |
||||
- Issue: <what is wrong> |
||||
- Recommendation: <specific fix> |
||||
|
||||
If no issues are found, say so explicitly. |
||||
""" |
||||
@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
|
||||
name = "implement" |
||||
description = "Workspace-write Trellis implementer that follows specs and keeps generated templates in sync." |
||||
sandbox_mode = "workspace-write" |
||||
|
||||
developer_instructions = """ |
||||
You are the Trellis implementer agent. |
||||
|
||||
Rules: |
||||
- Read before write. Follow `.trellis/spec/` guidance relevant to the task. |
||||
- Keep changes focused on the requested scope. |
||||
- When touching platform registries or template lists, search first so you do not miss mirrored update paths. |
||||
- If you modify `.trellis/scripts/`, keep `packages/cli/src/templates/trellis/scripts/` in sync. |
||||
- Do not make destructive git changes unless explicitly asked. |
||||
|
||||
Before finishing, summarize: |
||||
- Files changed |
||||
- Tests/checks run |
||||
- Remaining risks or follow-ups |
||||
""" |
||||
@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
|
||||
name = "research" |
||||
description = "Read-only Trellis researcher for specs, code patterns, and affected files." |
||||
sandbox_mode = "read-only" |
||||
|
||||
developer_instructions = """ |
||||
You are the Trellis researcher agent. |
||||
|
||||
Responsibilities: |
||||
- Read `.trellis/workflow.md`, relevant `.trellis/spec/` files, and target code before proposing changes. |
||||
- Identify the smallest set of relevant specs, code patterns, and files to modify. |
||||
- Call out cross-layer or cross-platform risks when they are real. |
||||
- Do not edit files. |
||||
|
||||
Output format: |
||||
## Relevant Specs |
||||
- <path>: <why> |
||||
|
||||
## Code Patterns Found |
||||
- <pattern>: <file> |
||||
|
||||
## Files to Modify |
||||
- <path>: <change> |
||||
|
||||
## Risks / Follow-ups |
||||
- <none or concrete note> |
||||
""" |
||||
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
# Project-scoped Codex defaults for Trellis workflows. |
||||
# Codex loads this after ~/.codex/config.toml when you work in this project. |
||||
|
||||
# Keep AGENTS.md as the primary project instruction file. |
||||
project_doc_fallback_filenames = ["AGENTS.md"] |
||||
@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
|
||||
{ |
||||
"hooks": { |
||||
"SessionStart": [ |
||||
{ |
||||
"hooks": [ |
||||
{ |
||||
"type": "command", |
||||
"command": "python3 .codex/hooks/session-start.py", |
||||
"timeout": 15, |
||||
"statusMessage": "Loading Trellis context..." |
||||
} |
||||
] |
||||
} |
||||
] |
||||
} |
||||
} |
||||
@ -0,0 +1,204 @@
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python3 |
||||
# -*- coding: utf-8 -*- |
||||
""" |
||||
Codex Session Start Hook - Inject Trellis context into Codex sessions. |
||||
|
||||
Output format follows Codex hook protocol: |
||||
stdout JSON → { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: "..." } } |
||||
""" |
||||
|
||||
from __future__ import annotations |
||||
|
||||
import json |
||||
import os |
||||
import subprocess |
||||
import sys |
||||
import warnings |
||||
from io import StringIO |
||||
from pathlib import Path |
||||
|
||||
warnings.filterwarnings("ignore") |
||||
|
||||
|
||||
def should_skip_injection() -> bool: |
||||
return os.environ.get("CODEX_NON_INTERACTIVE") == "1" |
||||
|
||||
|
||||
def read_file(path: Path, fallback: str = "") -> str: |
||||
try: |
||||
return path.read_text(encoding="utf-8") |
||||
except (FileNotFoundError, PermissionError): |
||||
return fallback |
||||
|
||||
|
||||
def run_script(script_path: Path) -> str: |
||||
try: |
||||
env = os.environ.copy() |
||||
env["PYTHONIOENCODING"] = "utf-8" |
||||
cmd = [sys.executable, "-W", "ignore", str(script_path)] |
||||
result = subprocess.run( |
||||
cmd, |
||||
capture_output=True, |
||||
text=True, |
||||
encoding="utf-8", |
||||
errors="replace", |
||||
timeout=5, |
||||
cwd=str(script_path.parent.parent.parent), |
||||
env=env, |
||||
) |
||||
return result.stdout if result.returncode == 0 else "No context available" |
||||
except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError): |
||||
return "No context available" |
||||
|
||||
|
||||
def _get_task_status(trellis_dir: Path) -> str: |
||||
current_task_file = trellis_dir / ".current-task" |
||||
if not current_task_file.is_file(): |
||||
return "Status: NO ACTIVE TASK\nNext: Describe what you want to work on" |
||||
|
||||
task_ref = current_task_file.read_text(encoding="utf-8").strip() |
||||
if not task_ref: |
||||
return "Status: NO ACTIVE TASK\nNext: Describe what you want to work on" |
||||
|
||||
if Path(task_ref).is_absolute(): |
||||
task_dir = Path(task_ref) |
||||
elif task_ref.startswith(".trellis/"): |
||||
task_dir = trellis_dir.parent / task_ref |
||||
else: |
||||
task_dir = trellis_dir / "tasks" / task_ref |
||||
if not task_dir.is_dir(): |
||||
return f"Status: STALE POINTER\nTask: {task_ref}\nNext: Task directory not found. Run: python3 ./.trellis/scripts/task.py finish" |
||||
|
||||
task_json_path = task_dir / "task.json" |
||||
task_data: dict = {} |
||||
if task_json_path.is_file(): |
||||
try: |
||||
task_data = json.loads(task_json_path.read_text(encoding="utf-8")) |
||||
except (json.JSONDecodeError, PermissionError): |
||||
pass |
||||
|
||||
task_title = task_data.get("title", task_ref) |
||||
task_status = task_data.get("status", "unknown") |
||||
|
||||
if task_status == "completed": |
||||
return f"Status: COMPLETED\nTask: {task_title}\nNext: Archive with `python3 ./.trellis/scripts/task.py archive {task_dir.name}` or start a new task" |
||||
|
||||
has_context = False |
||||
for jsonl_name in ("implement.jsonl", "check.jsonl", "spec.jsonl"): |
||||
jsonl_path = task_dir / jsonl_name |
||||
if jsonl_path.is_file() and jsonl_path.stat().st_size > 0: |
||||
has_context = True |
||||
break |
||||
|
||||
has_prd = (task_dir / "prd.md").is_file() |
||||
|
||||
if not has_prd: |
||||
return f"Status: NOT READY\nTask: {task_title}\nMissing: prd.md not created\nNext: Write PRD, then research → init-context → start" |
||||
|
||||
if not has_context: |
||||
return f"Status: NOT READY\nTask: {task_title}\nMissing: Context not configured (no jsonl files)\nNext: Complete Phase 2 (research → init-context → start) before implementing" |
||||
|
||||
return f"Status: READY\nTask: {task_title}\nNext: Continue with implement or check" |
||||
|
||||
|
||||
def main() -> None: |
||||
if should_skip_injection(): |
||||
sys.exit(0) |
||||
|
||||
# Read hook input from stdin |
||||
try: |
||||
hook_input = json.loads(sys.stdin.read()) |
||||
project_dir = Path(hook_input.get("cwd", ".")).resolve() |
||||
except (json.JSONDecodeError, KeyError): |
||||
project_dir = Path(".").resolve() |
||||
|
||||
trellis_dir = project_dir / ".trellis" |
||||
codex_dir = project_dir / ".codex" |
||||
|
||||
output = StringIO() |
||||
|
||||
output.write("""<session-context> |
||||
You are starting a new session in a Trellis-managed project. |
||||
Read and follow all instructions below carefully. |
||||
</session-context> |
||||
|
||||
""") |
||||
|
||||
output.write("<current-state>\n") |
||||
context_script = trellis_dir / "scripts" / "get_context.py" |
||||
output.write(run_script(context_script)) |
||||
output.write("\n</current-state>\n\n") |
||||
|
||||
output.write("<workflow>\n") |
||||
workflow_content = read_file(trellis_dir / "workflow.md", "No workflow.md found") |
||||
output.write(workflow_content) |
||||
output.write("\n</workflow>\n\n") |
||||
|
||||
output.write("<guidelines>\n") |
||||
output.write("**Note**: The guidelines below are index files — they list available guideline documents and their locations.\n") |
||||
output.write("During actual development, you MUST read the specific guideline files listed in each index's Pre-Development Checklist.\n\n") |
||||
|
||||
spec_dir = trellis_dir / "spec" |
||||
if spec_dir.is_dir(): |
||||
for sub in sorted(spec_dir.iterdir()): |
||||
if not sub.is_dir() or sub.name.startswith("."): |
||||
continue |
||||
|
||||
if sub.name == "guides": |
||||
index_file = sub / "index.md" |
||||
if index_file.is_file(): |
||||
output.write(f"## {sub.name}\n") |
||||
output.write(read_file(index_file)) |
||||
output.write("\n\n") |
||||
continue |
||||
|
||||
index_file = sub / "index.md" |
||||
if index_file.is_file(): |
||||
output.write(f"## {sub.name}\n") |
||||
output.write(read_file(index_file)) |
||||
output.write("\n\n") |
||||
else: |
||||
for nested in sorted(sub.iterdir()): |
||||
if not nested.is_dir(): |
||||
continue |
||||
nested_index = nested / "index.md" |
||||
if nested_index.is_file(): |
||||
output.write(f"## {sub.name}/{nested.name}\n") |
||||
output.write(read_file(nested_index)) |
||||
output.write("\n\n") |
||||
|
||||
output.write("</guidelines>\n\n") |
||||
|
||||
# Inject start skill as instructions (Codex uses skills, not slash commands) |
||||
start_skill = codex_dir / "skills" / "start" / "SKILL.md" |
||||
if not start_skill.is_file(): |
||||
start_skill = project_dir / ".agents" / "skills" / "start" / "SKILL.md" |
||||
if start_skill.is_file(): |
||||
output.write("<instructions>\n") |
||||
output.write(read_file(start_skill)) |
||||
output.write("\n</instructions>\n\n") |
||||
|
||||
task_status = _get_task_status(trellis_dir) |
||||
output.write(f"<task-status>\n{task_status}\n</task-status>\n\n") |
||||
|
||||
output.write("""<ready> |
||||
Context loaded. Steps 1-3 (workflow, context, guidelines) are already injected above — do NOT re-read them. |
||||
Start from Step 4. Wait for user's first message, then follow <instructions> to handle their request. |
||||
If there is an active task, ask whether to continue it. |
||||
</ready>""") |
||||
|
||||
context = output.getvalue() |
||||
result = { |
||||
"suppressOutput": True, |
||||
"systemMessage": f"Trellis context injected ({len(context)} chars)", |
||||
"hookSpecificOutput": { |
||||
"hookEventName": "SessionStart", |
||||
"additionalContext": context, |
||||
}, |
||||
} |
||||
|
||||
print(json.dumps(result, ensure_ascii=False), flush=True) |
||||
|
||||
|
||||
if __name__ == "__main__": |
||||
main() |
||||
@ -0,0 +1,194 @@
@@ -0,0 +1,194 @@
|
||||
--- |
||||
name: parallel |
||||
description: "Multi-agent pipeline orchestrator that plans and dispatches parallel development tasks to worktree agents. Reads project context, configures task directories with PRDs and jsonl context files, and launches isolated coding agents. Use when multiple independent features need parallel development, orchestrating worktree agents, or managing multi-agent coding pipelines." |
||||
--- |
||||
|
||||
# Multi-Agent Pipeline Orchestrator |
||||
|
||||
You are the Multi-Agent Pipeline Orchestrator Agent, running in the main repository, responsible for collaborating with users to manage parallel development tasks. |
||||
|
||||
## Role Definition |
||||
|
||||
- **You are in the main repository**, not in a worktree |
||||
- **You don't write code directly** - code work is done by agents in worktrees |
||||
- **You are responsible for planning and dispatching**: discuss requirements, create plans, configure context, start worktree agents |
||||
- **Delegate complex analysis to research**: find specs, inspect code structure, and reduce ambiguity before dispatch |
||||
|
||||
--- |
||||
|
||||
## Operation Types |
||||
|
||||
Operations in this document are categorized as: |
||||
|
||||
| Marker | Meaning | Executor | |
||||
|--------|---------|----------| |
||||
| `[AI]` | Bash scripts or tool calls executed by AI | You (AI) | |
||||
| `[USER]` | Skills executed by user | User | |
||||
|
||||
--- |
||||
|
||||
## Startup Flow |
||||
|
||||
### Step 1: Understand Trellis Workflow `[AI]` |
||||
|
||||
First, read the workflow guide to understand the development process: |
||||
|
||||
```bash |
||||
cat .trellis/workflow.md # Development process, conventions, and quick start guide |
||||
``` |
||||
|
||||
### Step 2: Get Current Status `[AI]` |
||||
|
||||
```bash |
||||
python3 ./.trellis/scripts/get_context.py |
||||
``` |
||||
|
||||
### Step 3: Read Project Guidelines `[AI]` |
||||
|
||||
```bash |
||||
python3 ./.trellis/scripts/get_context.py --mode packages # Discover available spec layers |
||||
cat .trellis/spec/guides/index.md # Thinking guides |
||||
``` |
||||
|
||||
### Step 4: Ask User for Requirements |
||||
|
||||
Ask the user: |
||||
|
||||
1. What feature to develop? |
||||
2. Which modules are involved? |
||||
3. Development type? (backend / frontend / fullstack) |
||||
|
||||
--- |
||||
|
||||
## Planning: Choose Your Approach |
||||
|
||||
Based on requirement complexity, choose one of these approaches: |
||||
|
||||
### Option A: Plan Agent (Recommended for complex features) `[AI]` |
||||
|
||||
Use when: |
||||
- Requirements need analysis and validation |
||||
- Multiple modules or cross-layer changes |
||||
- Unclear scope that needs research |
||||
|
||||
```bash |
||||
python3 ./.trellis/scripts/multi_agent/plan.py \ |
||||
--name "<feature-name>" \ |
||||
--type "<backend|frontend|fullstack>" \ |
||||
--requirement "<user requirement description>" \ |
||||
--platform codex |
||||
``` |
||||
|
||||
Plan Agent will: |
||||
1. Evaluate requirement validity (may reject if unclear/too large) |
||||
2. Analyze the codebase and specs |
||||
3. Create and configure task directory |
||||
4. Write `prd.md` with acceptance criteria |
||||
5. Output a ready-to-use task directory |
||||
|
||||
After `plan.py` completes, start the worktree agent: |
||||
|
||||
```bash |
||||
python3 ./.trellis/scripts/multi_agent/start.py "$TASK_DIR" --platform codex |
||||
``` |
||||
|
||||
### Option B: Manual Configuration (For simple or already-clear features) `[AI]` |
||||
|
||||
Use when: |
||||
- Requirements are already clear and specific |
||||
- You know exactly which files are involved |
||||
- Simple, well-scoped changes |
||||
|
||||
#### Step 1: Create Task Directory |
||||
|
||||
```bash |
||||
TASK_DIR=$(python3 ./.trellis/scripts/task.py create "<title>" --slug <task-name>) |
||||
``` |
||||
|
||||
#### Step 2: Configure Task |
||||
|
||||
```bash |
||||
python3 ./.trellis/scripts/task.py init-context "$TASK_DIR" <dev_type> |
||||
python3 ./.trellis/scripts/task.py set-branch "$TASK_DIR" feature/<name> |
||||
python3 ./.trellis/scripts/task.py set-scope "$TASK_DIR" <scope> |
||||
``` |
||||
|
||||
#### Step 3: Add Context |
||||
|
||||
```bash |
||||
python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" implement "<path>" "<reason>" |
||||
python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" check "<path>" "<reason>" |
||||
``` |
||||
|
||||
#### Step 4: Create `prd.md` |
||||
|
||||
```bash |
||||
cat > "$TASK_DIR/prd.md" << 'END_PRD' |
||||
# Feature: <name> |
||||
|
||||
## Requirements |
||||
- ... |
||||
|
||||
## Acceptance Criteria |
||||
- ... |
||||
END_PRD |
||||
``` |
||||
|
||||
#### Step 5: Validate and Start |
||||
|
||||
```bash |
||||
python3 ./.trellis/scripts/task.py validate "$TASK_DIR" |
||||
python3 ./.trellis/scripts/multi_agent/start.py "$TASK_DIR" --platform codex |
||||
``` |
||||
|
||||
--- |
||||
|
||||
## After Starting: Report Status |
||||
|
||||
Tell the user the agent has started and provide monitoring commands. |
||||
|
||||
--- |
||||
|
||||
## User Available Skills `[USER]` |
||||
|
||||
The following skills are for users (not AI): |
||||
|
||||
| Skill | Description | |
||||
|-------|-------------| |
||||
| `$parallel` | Start Multi-Agent Pipeline (this skill) | |
||||
| `$start` | Start normal development mode (single process) | |
||||
| `$record-session` | Record session progress | |
||||
| `$finish-work` | Pre-completion checklist | |
||||
|
||||
--- |
||||
|
||||
## Monitoring Commands (for user reference) |
||||
|
||||
Tell the user they can use these commands to monitor: |
||||
|
||||
```bash |
||||
python3 ./.trellis/scripts/multi_agent/status.py # Overview |
||||
python3 ./.trellis/scripts/multi_agent/status.py --log <name> # View log |
||||
python3 ./.trellis/scripts/multi_agent/status.py --watch <name> # Real-time monitoring |
||||
python3 ./.trellis/scripts/multi_agent/cleanup.py <branch> # Cleanup worktree |
||||
``` |
||||
|
||||
--- |
||||
|
||||
## Pipeline Phases |
||||
|
||||
The dispatch agent in the worktree will automatically execute: |
||||
|
||||
1. implement → Implement feature |
||||
2. check → Check code quality |
||||
3. finish → Final verification |
||||
4. create-pr → Create PR |
||||
|
||||
--- |
||||
|
||||
## Core Rules |
||||
|
||||
- **Don't write code directly** - delegate to agents in worktrees |
||||
- **Don't execute git commit** - the flow handles it in the worktree pipeline |
||||
- **Delegate complex analysis before dispatch** - find specs, inspect code structure, and reduce ambiguity |
||||
- **Prefer focused tasks** - parallelism works best when each worktree has a narrow scope |
||||
@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
|
||||
""" |
||||
Git command execution utility. |
||||
|
||||
Single source of truth for running git commands across all Trellis scripts. |
||||
""" |
||||
|
||||
from __future__ import annotations |
||||
|
||||
import subprocess |
||||
from pathlib import Path |
||||
|
||||
|
||||
def run_git(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]: |
||||
"""Run a git command and return (returncode, stdout, stderr). |
||||
|
||||
Uses UTF-8 encoding with -c i18n.logOutputEncoding=UTF-8 to ensure |
||||
consistent output across all platforms (Windows, macOS, Linux). |
||||
""" |
||||
try: |
||||
git_args = ["git", "-c", "i18n.logOutputEncoding=UTF-8"] + args |
||||
result = subprocess.run( |
||||
git_args, |
||||
cwd=cwd, |
||||
capture_output=True, |
||||
text=True, |
||||
encoding="utf-8", |
||||
errors="replace", |
||||
) |
||||
return result.returncode, result.stdout, result.stderr |
||||
except Exception as e: |
||||
return 1, "", str(e) |
||||
@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
|
||||
""" |
||||
JSON file I/O utilities. |
||||
|
||||
Provides read_json and write_json as the single source of truth |
||||
for JSON file operations across all Trellis scripts. |
||||
""" |
||||
|
||||
from __future__ import annotations |
||||
|
||||
import json |
||||
from pathlib import Path |
||||
|
||||
|
||||
def read_json(path: Path) -> dict | None: |
||||
"""Read and parse a JSON file. |
||||
|
||||
Returns None if the file doesn't exist, is invalid JSON, or can't be read. |
||||
""" |
||||
try: |
||||
return json.loads(path.read_text(encoding="utf-8")) |
||||
except (FileNotFoundError, json.JSONDecodeError, OSError): |
||||
return None |
||||
|
||||
|
||||
def write_json(path: Path, data: dict) -> bool: |
||||
"""Write dict to JSON file with pretty formatting. |
||||
|
||||
Returns True on success, False on error. |
||||
""" |
||||
try: |
||||
path.write_text( |
||||
json.dumps(data, indent=2, ensure_ascii=False), |
||||
encoding="utf-8", |
||||
) |
||||
return True |
||||
except (OSError, IOError): |
||||
return False |
||||
@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
|
||||
""" |
||||
Terminal output utilities: colors and structured logging. |
||||
|
||||
Single source of truth for Colors and log_* functions |
||||
used across all Trellis scripts. |
||||
""" |
||||
|
||||
from __future__ import annotations |
||||
|
||||
|
||||
class Colors: |
||||
"""ANSI color codes for terminal output.""" |
||||
|
||||
RED = "\033[0;31m" |
||||
GREEN = "\033[0;32m" |
||||
YELLOW = "\033[1;33m" |
||||
BLUE = "\033[0;34m" |
||||
CYAN = "\033[0;36m" |
||||
DIM = "\033[2m" |
||||
NC = "\033[0m" # No Color / Reset |
||||
|
||||
|
||||
def colored(text: str, color: str) -> str: |
||||
"""Apply ANSI color to text.""" |
||||
return f"{color}{text}{Colors.NC}" |
||||
|
||||
|
||||
def log_info(msg: str) -> None: |
||||
"""Print info-level message with [INFO] prefix.""" |
||||
print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}") |
||||
|
||||
|
||||
def log_success(msg: str) -> None: |
||||
"""Print success message with [SUCCESS] prefix.""" |
||||
print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}") |
||||
|
||||
|
||||
def log_warn(msg: str) -> None: |
||||
"""Print warning message with [WARN] prefix.""" |
||||
print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}") |
||||
|
||||
|
||||
def log_error(msg: str) -> None: |
||||
"""Print error message with [ERROR] prefix.""" |
||||
print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}") |
||||
@ -0,0 +1,238 @@
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env python3 |
||||
""" |
||||
Package discovery and context output. |
||||
|
||||
Provides: |
||||
get_packages_info - Get structured package info |
||||
get_packages_section - Build PACKAGES text section |
||||
get_context_packages_text - Full packages text output (--mode packages) |
||||
get_context_packages_json - Full packages JSON output (--mode packages --json) |
||||
""" |
||||
|
||||
from __future__ import annotations |
||||
|
||||
from pathlib import Path |
||||
|
||||
from .config import _is_true_config_value, get_default_package, get_packages, get_spec_scope |
||||
from .paths import ( |
||||
DIR_SPEC, |
||||
DIR_WORKFLOW, |
||||
get_current_task, |
||||
get_repo_root, |
||||
) |
||||
from .tasks import load_task |
||||
|
||||
|
||||
# ============================================================================= |
||||
# Internal Helpers |
||||
# ============================================================================= |
||||
|
||||
def _scan_spec_layers(spec_dir: Path, package: str | None = None) -> list[str]: |
||||
"""Scan spec directory for available layers (subdirectories). |
||||
|
||||
For monorepo: scans spec/<package>/ |
||||
For single-repo: scans spec/ |
||||
""" |
||||
target = spec_dir / package if package else spec_dir |
||||
if not target.is_dir(): |
||||
return [] |
||||
return sorted( |
||||
d.name for d in target.iterdir() if d.is_dir() and d.name != "guides" |
||||
) |
||||
|
||||
|
||||
def _get_active_task_package(repo_root: Path) -> str | None: |
||||
"""Get the package field from the active task's task.json.""" |
||||
current = get_current_task(repo_root) |
||||
if not current: |
||||
return None |
||||
ct = load_task(repo_root / current) |
||||
return ct.package if ct and ct.package else None |
||||
|
||||
|
||||
def _resolve_scope_set( |
||||
packages: dict, |
||||
spec_scope, |
||||
task_pkg: str | None, |
||||
default_pkg: str | None, |
||||
) -> set | None: |
||||
"""Resolve spec_scope to a set of allowed package names, or None for full scan.""" |
||||
if not packages: |
||||
return None |
||||
|
||||
if spec_scope is None: |
||||
return None |
||||
|
||||
if isinstance(spec_scope, str) and spec_scope == "active_task": |
||||
if task_pkg and task_pkg in packages: |
||||
return {task_pkg} |
||||
if default_pkg and default_pkg in packages: |
||||
return {default_pkg} |
||||
return None |
||||
|
||||
if isinstance(spec_scope, list): |
||||
valid = {e for e in spec_scope if e in packages} |
||||
if valid: |
||||
return valid |
||||
# All invalid: fallback |
||||
if task_pkg and task_pkg in packages: |
||||
return {task_pkg} |
||||
if default_pkg and default_pkg in packages: |
||||
return {default_pkg} |
||||
return None |
||||
|
||||
return None |
||||
|
||||
|
||||
# ============================================================================= |
||||
# Public Functions |
||||
# ============================================================================= |
||||
|
||||
def get_packages_info(repo_root: Path) -> list[dict]: |
||||
"""Get structured package info for monorepo projects. |
||||
|
||||
Returns list of dicts with keys: name, path, type, default, specLayers, |
||||
isSubmodule, isGitRepo. |
||||
Returns empty list for single-repo projects. |
||||
""" |
||||
packages = get_packages(repo_root) |
||||
if not packages: |
||||
return [] |
||||
|
||||
default_pkg = get_default_package(repo_root) |
||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC |
||||
result = [] |
||||
|
||||
for pkg_name, pkg_config in packages.items(): |
||||
pkg_path = pkg_config.get("path", pkg_name) if isinstance(pkg_config, dict) else str(pkg_config) |
||||
pkg_type = pkg_config.get("type", "local") if isinstance(pkg_config, dict) else "local" |
||||
pkg_git = pkg_config.get("git", False) if isinstance(pkg_config, dict) else False |
||||
layers = _scan_spec_layers(spec_dir, pkg_name) |
||||
|
||||
result.append({ |
||||
"name": pkg_name, |
||||
"path": pkg_path, |
||||
"type": pkg_type, |
||||
"default": pkg_name == default_pkg, |
||||
"specLayers": layers, |
||||
"isSubmodule": pkg_type == "submodule", |
||||
"isGitRepo": _is_true_config_value(pkg_git), |
||||
}) |
||||
|
||||
return result |
||||
|
||||
|
||||
def get_packages_section(repo_root: Path) -> str: |
||||
"""Build the PACKAGES section for text output.""" |
||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC |
||||
pkg_info = get_packages_info(repo_root) |
||||
|
||||
lines: list[str] = [] |
||||
lines.append("## PACKAGES") |
||||
|
||||
if not pkg_info: |
||||
lines.append("(single-repo mode)") |
||||
layers = _scan_spec_layers(spec_dir) |
||||
if layers: |
||||
lines.append(f"Spec layers: {', '.join(layers)}") |
||||
return "\n".join(lines) |
||||
|
||||
default_pkg = get_default_package(repo_root) |
||||
|
||||
for pkg in pkg_info: |
||||
layers_str = f" [{', '.join(pkg['specLayers'])}]" if pkg["specLayers"] else "" |
||||
submodule_tag = " (submodule)" if pkg["isSubmodule"] else "" |
||||
git_repo_tag = " (git repo)" if pkg["isGitRepo"] else "" |
||||
default_tag = " *" if pkg["default"] else "" |
||||
lines.append( |
||||
f"- {pkg['name']:<16} {pkg['path']:<20}{layers_str}{submodule_tag}{git_repo_tag}{default_tag}" |
||||
) |
||||
|
||||
if default_pkg: |
||||
lines.append(f"Default package: {default_pkg}") |
||||
|
||||
return "\n".join(lines) |
||||
|
||||
|
||||
def get_context_packages_text(repo_root: Path | None = None) -> str: |
||||
"""Get packages context as formatted text (for --mode packages).""" |
||||
if repo_root is None: |
||||
repo_root = get_repo_root() |
||||
|
||||
pkg_info = get_packages_info(repo_root) |
||||
lines: list[str] = [] |
||||
|
||||
if not pkg_info: |
||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC |
||||
lines.append("Single-repo project (no packages configured)") |
||||
lines.append("") |
||||
layers = _scan_spec_layers(spec_dir) |
||||
if layers: |
||||
lines.append(f"Spec layers: {', '.join(layers)}") |
||||
return "\n".join(lines) |
||||
|
||||
# Resolve scope for annotations |
||||
packages_dict = get_packages(repo_root) or {} |
||||
default_pkg = get_default_package(repo_root) |
||||
spec_scope = get_spec_scope(repo_root) |
||||
task_pkg = _get_active_task_package(repo_root) |
||||
scope_set = _resolve_scope_set(packages_dict, spec_scope, task_pkg, default_pkg) |
||||
|
||||
lines.append("## PACKAGES") |
||||
lines.append("") |
||||
for pkg in pkg_info: |
||||
default_tag = " (default)" if pkg["default"] else "" |
||||
type_tag = f" [{pkg['type']}]" if pkg["type"] != "local" else "" |
||||
git_tag = " [git repo]" if pkg["isGitRepo"] else "" |
||||
|
||||
# Scope annotation |
||||
scope_tag = "" |
||||
if scope_set is not None and pkg["name"] not in scope_set: |
||||
scope_tag = " (out of scope)" |
||||
|
||||
lines.append(f"### {pkg['name']}{default_tag}{type_tag}{git_tag}{scope_tag}") |
||||
lines.append(f"Path: {pkg['path']}") |
||||
if pkg["specLayers"]: |
||||
lines.append(f"Spec layers: {', '.join(pkg['specLayers'])}") |
||||
for layer in pkg["specLayers"]: |
||||
lines.append(f" - .trellis/spec/{pkg['name']}/{layer}/index.md") |
||||
else: |
||||
lines.append("Spec: not configured") |
||||
lines.append("") |
||||
|
||||
# Also show shared guides |
||||
guides_dir = repo_root / DIR_WORKFLOW / DIR_SPEC / "guides" |
||||
if guides_dir.is_dir(): |
||||
lines.append("### Shared Guides (always included)") |
||||
lines.append("Path: .trellis/spec/guides/index.md") |
||||
lines.append("") |
||||
|
||||
return "\n".join(lines) |
||||
|
||||
|
||||
def get_context_packages_json(repo_root: Path | None = None) -> dict: |
||||
"""Get packages context as a dictionary (for --mode packages --json).""" |
||||
if repo_root is None: |
||||
repo_root = get_repo_root() |
||||
|
||||
pkg_info = get_packages_info(repo_root) |
||||
|
||||
if not pkg_info: |
||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC |
||||
layers = _scan_spec_layers(spec_dir) |
||||
return { |
||||
"mode": "single-repo", |
||||
"specLayers": layers, |
||||
} |
||||
|
||||
default_pkg = get_default_package(repo_root) |
||||
spec_scope = get_spec_scope(repo_root) |
||||
task_pkg = _get_active_task_package(repo_root) |
||||
|
||||
return { |
||||
"mode": "monorepo", |
||||
"packages": pkg_info, |
||||
"defaultPackage": default_pkg, |
||||
"specScope": spec_scope, |
||||
"activeTaskPackage": task_pkg, |
||||
} |
||||
@ -0,0 +1,562 @@
@@ -0,0 +1,562 @@
|
||||
#!/usr/bin/env python3 |
||||
""" |
||||
Session context generation (default + record modes). |
||||
|
||||
Provides: |
||||
get_context_json - JSON output for default mode |
||||
get_context_text - Text output for default mode |
||||
get_context_record_json - JSON for record mode |
||||
get_context_text_record - Text for record mode |
||||
output_json - Print JSON |
||||
output_text - Print text |
||||
""" |
||||
|
||||
from __future__ import annotations |
||||
|
||||
import json |
||||
from pathlib import Path |
||||
|
||||
from .config import get_git_packages |
||||
from .git import run_git |
||||
from .packages_context import get_packages_section |
||||
from .tasks import iter_active_tasks, load_task, get_all_statuses, children_progress |
||||
from .paths import ( |
||||
DIR_SCRIPTS, |
||||
DIR_SPEC, |
||||
DIR_TASKS, |
||||
DIR_WORKFLOW, |
||||
DIR_WORKSPACE, |
||||
count_lines, |
||||
get_active_journal_file, |
||||
get_current_task, |
||||
get_developer, |
||||
get_repo_root, |
||||
get_tasks_dir, |
||||
) |
||||
|
||||
|
||||
# ============================================================================= |
||||
# Helpers |
||||
# ============================================================================= |
||||
|
||||
def _collect_package_git_info(repo_root: Path) -> list[dict]: |
||||
"""Collect git status and recent commits for packages with independent git repos. |
||||
|
||||
Only packages marked with ``git: true`` in config.yaml are included. |
||||
|
||||
Returns: |
||||
List of dicts with keys: name, path, branch, isClean, |
||||
uncommittedChanges, recentCommits. |
||||
Empty list if no git-repo packages are configured. |
||||
""" |
||||
git_pkgs = get_git_packages(repo_root) |
||||
if not git_pkgs: |
||||
return [] |
||||
|
||||
result = [] |
||||
for pkg_name, pkg_path in git_pkgs.items(): |
||||
pkg_dir = repo_root / pkg_path |
||||
if not (pkg_dir / ".git").exists(): |
||||
continue |
||||
|
||||
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=pkg_dir) |
||||
branch = branch_out.strip() or "unknown" |
||||
|
||||
_, status_out, _ = run_git(["status", "--porcelain"], cwd=pkg_dir) |
||||
changes = len([l for l in status_out.splitlines() if l.strip()]) |
||||
|
||||
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=pkg_dir) |
||||
commits = [] |
||||
for line in log_out.splitlines(): |
||||
if line.strip(): |
||||
parts = line.split(" ", 1) |
||||
if len(parts) >= 2: |
||||
commits.append({"hash": parts[0], "message": parts[1]}) |
||||
elif len(parts) == 1: |
||||
commits.append({"hash": parts[0], "message": ""}) |
||||
|
||||
result.append({ |
||||
"name": pkg_name, |
||||
"path": pkg_path, |
||||
"branch": branch, |
||||
"isClean": changes == 0, |
||||
"uncommittedChanges": changes, |
||||
"recentCommits": commits, |
||||
}) |
||||
|
||||
return result |
||||
|
||||
|
||||
def _append_package_git_context(lines: list[str], package_git_info: list[dict]) -> None: |
||||
"""Append Git status and recent commits for package repositories.""" |
||||
for pkg in package_git_info: |
||||
lines.append(f"## GIT STATUS ({pkg['name']}: {pkg['path']})") |
||||
lines.append(f"Branch: {pkg['branch']}") |
||||
if pkg["isClean"]: |
||||
lines.append("Working directory: Clean") |
||||
else: |
||||
lines.append( |
||||
f"Working directory: {pkg['uncommittedChanges']} uncommitted change(s)" |
||||
) |
||||
lines.append("") |
||||
lines.append(f"## RECENT COMMITS ({pkg['name']}: {pkg['path']})") |
||||
if pkg["recentCommits"]: |
||||
for commit in pkg["recentCommits"]: |
||||
lines.append(f"{commit['hash']} {commit['message']}") |
||||
else: |
||||
lines.append("(no commits)") |
||||
lines.append("") |
||||
|
||||
|
||||
# ============================================================================= |
||||
# JSON Output |
||||
# ============================================================================= |
||||
|
||||
def get_context_json(repo_root: Path | None = None) -> dict: |
||||
"""Get context as a dictionary. |
||||
|
||||
Args: |
||||
repo_root: Repository root path. Defaults to auto-detected. |
||||
|
||||
Returns: |
||||
Context dictionary. |
||||
""" |
||||
if repo_root is None: |
||||
repo_root = get_repo_root() |
||||
|
||||
developer = get_developer(repo_root) |
||||
tasks_dir = get_tasks_dir(repo_root) |
||||
journal_file = get_active_journal_file(repo_root) |
||||
|
||||
journal_lines = 0 |
||||
journal_relative = "" |
||||
if journal_file and developer: |
||||
journal_lines = count_lines(journal_file) |
||||
journal_relative = ( |
||||
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}" |
||||
) |
||||
|
||||
# Git info |
||||
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) |
||||
branch = branch_out.strip() or "unknown" |
||||
|
||||
_, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root) |
||||
git_status_count = len([line for line in status_out.splitlines() if line.strip()]) |
||||
is_clean = git_status_count == 0 |
||||
|
||||
# Recent commits |
||||
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root) |
||||
commits = [] |
||||
for line in log_out.splitlines(): |
||||
if line.strip(): |
||||
parts = line.split(" ", 1) |
||||
if len(parts) >= 2: |
||||
commits.append({"hash": parts[0], "message": parts[1]}) |
||||
elif len(parts) == 1: |
||||
commits.append({"hash": parts[0], "message": ""}) |
||||
|
||||
# Tasks |
||||
tasks = [ |
||||
{ |
||||
"dir": t.dir_name, |
||||
"name": t.name, |
||||
"status": t.status, |
||||
"children": list(t.children), |
||||
"parent": t.parent, |
||||
} |
||||
for t in iter_active_tasks(tasks_dir) |
||||
] |
||||
|
||||
# Package git repos (independent sub-repositories) |
||||
pkg_git_info = _collect_package_git_info(repo_root) |
||||
|
||||
result = { |
||||
"developer": developer or "", |
||||
"git": { |
||||
"branch": branch, |
||||
"isClean": is_clean, |
||||
"uncommittedChanges": git_status_count, |
||||
"recentCommits": commits, |
||||
}, |
||||
"tasks": { |
||||
"active": tasks, |
||||
"directory": f"{DIR_WORKFLOW}/{DIR_TASKS}", |
||||
}, |
||||
"journal": { |
||||
"file": journal_relative, |
||||
"lines": journal_lines, |
||||
"nearLimit": journal_lines > 1800, |
||||
}, |
||||
} |
||||
|
||||
if pkg_git_info: |
||||
result["packageGit"] = pkg_git_info |
||||
|
||||
return result |
||||
|
||||
|
||||
def output_json(repo_root: Path | None = None) -> None: |
||||
"""Output context in JSON format. |
||||
|
||||
Args: |
||||
repo_root: Repository root path. Defaults to auto-detected. |
||||
""" |
||||
context = get_context_json(repo_root) |
||||
print(json.dumps(context, indent=2, ensure_ascii=False)) |
||||
|
||||
|
||||
# ============================================================================= |
||||
# Text Output |
||||
# ============================================================================= |
||||
|
||||
def get_context_text(repo_root: Path | None = None) -> str: |
||||
"""Get context as formatted text. |
||||
|
||||
Args: |
||||
repo_root: Repository root path. Defaults to auto-detected. |
||||
|
||||
Returns: |
||||
Formatted text output. |
||||
""" |
||||
if repo_root is None: |
||||
repo_root = get_repo_root() |
||||
|
||||
lines = [] |
||||
lines.append("========================================") |
||||
lines.append("SESSION CONTEXT") |
||||
lines.append("========================================") |
||||
lines.append("") |
||||
|
||||
developer = get_developer(repo_root) |
||||
|
||||
# Developer section |
||||
lines.append("## DEVELOPER") |
||||
if not developer: |
||||
lines.append( |
||||
f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>" |
||||
) |
||||
return "\n".join(lines) |
||||
|
||||
lines.append(f"Name: {developer}") |
||||
lines.append("") |
||||
|
||||
# Git status |
||||
lines.append("## GIT STATUS") |
||||
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) |
||||
branch = branch_out.strip() or "unknown" |
||||
lines.append(f"Branch: {branch}") |
||||
|
||||
_, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root) |
||||
status_lines = [line for line in status_out.splitlines() if line.strip()] |
||||
status_count = len(status_lines) |
||||
|
||||
if status_count == 0: |
||||
lines.append("Working directory: Clean") |
||||
else: |
||||
lines.append(f"Working directory: {status_count} uncommitted change(s)") |
||||
lines.append("") |
||||
lines.append("Changes:") |
||||
_, short_out, _ = run_git(["status", "--short"], cwd=repo_root) |
||||
for line in short_out.splitlines()[:10]: |
||||
lines.append(line) |
||||
lines.append("") |
||||
|
||||
# Recent commits |
||||
lines.append("## RECENT COMMITS") |
||||
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root) |
||||
if log_out.strip(): |
||||
for line in log_out.splitlines(): |
||||
lines.append(line) |
||||
else: |
||||
lines.append("(no commits)") |
||||
lines.append("") |
||||
|
||||
# Package git repos — independent sub-repositories |
||||
_append_package_git_context(lines, _collect_package_git_info(repo_root)) |
||||
|
||||
# Current task |
||||
lines.append("## CURRENT TASK") |
||||
current_task = get_current_task(repo_root) |
||||
if current_task: |
||||
current_task_dir = repo_root / current_task |
||||
lines.append(f"Path: {current_task}") |
||||
|
||||
ct = load_task(current_task_dir) |
||||
if ct: |
||||
lines.append(f"Name: {ct.name}") |
||||
lines.append(f"Status: {ct.status}") |
||||
lines.append(f"Created: {ct.raw.get('createdAt', 'unknown')}") |
||||
if ct.description: |
||||
lines.append(f"Description: {ct.description}") |
||||
|
||||
# Check for prd.md |
||||
prd_file = current_task_dir / "prd.md" |
||||
if prd_file.is_file(): |
||||
lines.append("") |
||||
lines.append("[!] This task has prd.md - read it for task details") |
||||
else: |
||||
lines.append("(none)") |
||||
lines.append("") |
||||
|
||||
# Active tasks |
||||
lines.append("## ACTIVE TASKS") |
||||
tasks_dir = get_tasks_dir(repo_root) |
||||
task_count = 0 |
||||
|
||||
# Collect all task data for hierarchy display |
||||
all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)} |
||||
all_statuses = {name: t.status for name, t in all_tasks.items()} |
||||
|
||||
def _print_task_tree(name: str, indent: int = 0) -> None: |
||||
nonlocal task_count |
||||
t = all_tasks[name] |
||||
progress = children_progress(t.children, all_statuses) |
||||
prefix = " " * indent |
||||
lines.append(f"{prefix}- {name}/ ({t.status}){progress} @{t.assignee or '-'}") |
||||
task_count += 1 |
||||
for child in t.children: |
||||
if child in all_tasks: |
||||
_print_task_tree(child, indent + 1) |
||||
|
||||
for dir_name in sorted(all_tasks.keys()): |
||||
if not all_tasks[dir_name].parent: |
||||
_print_task_tree(dir_name) |
||||
|
||||
if task_count == 0: |
||||
lines.append("(no active tasks)") |
||||
lines.append(f"Total: {task_count} active task(s)") |
||||
lines.append("") |
||||
|
||||
# My tasks |
||||
lines.append("## MY TASKS (Assigned to me)") |
||||
my_task_count = 0 |
||||
|
||||
for t in all_tasks.values(): |
||||
if t.assignee == developer and t.status != "done": |
||||
progress = children_progress(t.children, all_statuses) |
||||
lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress}") |
||||
my_task_count += 1 |
||||
|
||||
if my_task_count == 0: |
||||
lines.append("(no tasks assigned to you)") |
||||
lines.append("") |
||||
|
||||
# Journal file |
||||
lines.append("## JOURNAL FILE") |
||||
journal_file = get_active_journal_file(repo_root) |
||||
if journal_file: |
||||
journal_lines = count_lines(journal_file) |
||||
relative = f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}" |
||||
lines.append(f"Active file: {relative}") |
||||
lines.append(f"Line count: {journal_lines} / 2000") |
||||
if journal_lines > 1800: |
||||
lines.append("[!] WARNING: Approaching 2000 line limit!") |
||||
else: |
||||
lines.append("No journal file found") |
||||
lines.append("") |
||||
|
||||
# Packages |
||||
packages_text = get_packages_section(repo_root) |
||||
if packages_text: |
||||
lines.append(packages_text) |
||||
lines.append("") |
||||
|
||||
# Paths |
||||
lines.append("## PATHS") |
||||
lines.append(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/") |
||||
lines.append(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/") |
||||
lines.append(f"Spec: {DIR_WORKFLOW}/{DIR_SPEC}/") |
||||
lines.append("") |
||||
|
||||
lines.append("========================================") |
||||
|
||||
return "\n".join(lines) |
||||
|
||||
|
||||
# ============================================================================= |
||||
# Record Mode |
||||
# ============================================================================= |
||||
|
||||
def get_context_record_json(repo_root: Path | None = None) -> dict: |
||||
"""Get record-mode context as a dictionary. |
||||
|
||||
Focused on: my active tasks, git status, current task. |
||||
""" |
||||
if repo_root is None: |
||||
repo_root = get_repo_root() |
||||
|
||||
developer = get_developer(repo_root) |
||||
tasks_dir = get_tasks_dir(repo_root) |
||||
|
||||
# Git info |
||||
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) |
||||
branch = branch_out.strip() or "unknown" |
||||
|
||||
_, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root) |
||||
git_status_count = len([line for line in status_out.splitlines() if line.strip()]) |
||||
|
||||
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root) |
||||
commits = [] |
||||
for line in log_out.splitlines(): |
||||
if line.strip(): |
||||
parts = line.split(" ", 1) |
||||
if len(parts) >= 2: |
||||
commits.append({"hash": parts[0], "message": parts[1]}) |
||||
|
||||
# My tasks (single pass — collect statuses and filter by assignee) |
||||
all_tasks_list = list(iter_active_tasks(tasks_dir)) |
||||
all_statuses = {t.dir_name: t.status for t in all_tasks_list} |
||||
|
||||
my_tasks = [] |
||||
for t in all_tasks_list: |
||||
if t.assignee == developer: |
||||
done = sum( |
||||
1 for c in t.children |
||||
if all_statuses.get(c) in ("completed", "done") |
||||
) |
||||
my_tasks.append({ |
||||
"dir": t.dir_name, |
||||
"title": t.title, |
||||
"status": t.status, |
||||
"priority": t.priority, |
||||
"children": list(t.children), |
||||
"childrenDone": done, |
||||
"parent": t.parent, |
||||
"meta": t.meta, |
||||
}) |
||||
|
||||
# Current task |
||||
current_task_info = None |
||||
current_task = get_current_task(repo_root) |
||||
if current_task: |
||||
ct = load_task(repo_root / current_task) |
||||
if ct: |
||||
current_task_info = { |
||||
"path": current_task, |
||||
"name": ct.name, |
||||
"status": ct.status, |
||||
} |
||||
|
||||
# Package git repos |
||||
pkg_git_info = _collect_package_git_info(repo_root) |
||||
|
||||
result = { |
||||
"developer": developer or "", |
||||
"git": { |
||||
"branch": branch, |
||||
"isClean": git_status_count == 0, |
||||
"uncommittedChanges": git_status_count, |
||||
"recentCommits": commits, |
||||
}, |
||||
"myTasks": my_tasks, |
||||
"currentTask": current_task_info, |
||||
} |
||||
|
||||
if pkg_git_info: |
||||
result["packageGit"] = pkg_git_info |
||||
|
||||
return result |
||||
|
||||
|
||||
def get_context_text_record(repo_root: Path | None = None) -> str: |
||||
"""Get context as formatted text for record-session mode. |
||||
|
||||
Focused output: MY ACTIVE TASKS first (with [!!!] emphasis), |
||||
then GIT STATUS, RECENT COMMITS, CURRENT TASK. |
||||
""" |
||||
if repo_root is None: |
||||
repo_root = get_repo_root() |
||||
|
||||
lines: list[str] = [] |
||||
lines.append("========================================") |
||||
lines.append("SESSION CONTEXT (RECORD MODE)") |
||||
lines.append("========================================") |
||||
lines.append("") |
||||
|
||||
developer = get_developer(repo_root) |
||||
if not developer: |
||||
lines.append( |
||||
f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>" |
||||
) |
||||
return "\n".join(lines) |
||||
|
||||
# MY ACTIVE TASKS — first and prominent |
||||
lines.append(f"## [!!!] MY ACTIVE TASKS (Assigned to {developer})") |
||||
lines.append("[!] Review whether any should be archived before recording this session.") |
||||
lines.append("") |
||||
|
||||
tasks_dir = get_tasks_dir(repo_root) |
||||
my_task_count = 0 |
||||
|
||||
# Single pass — collect all tasks and filter by assignee |
||||
all_statuses = get_all_statuses(tasks_dir) |
||||
|
||||
for t in iter_active_tasks(tasks_dir): |
||||
if t.assignee == developer: |
||||
progress = children_progress(t.children, all_statuses) |
||||
lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress} — {t.dir_name}") |
||||
my_task_count += 1 |
||||
|
||||
if my_task_count == 0: |
||||
lines.append("(no active tasks assigned to you)") |
||||
lines.append("") |
||||
|
||||
# GIT STATUS |
||||
lines.append("## GIT STATUS") |
||||
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) |
||||
branch = branch_out.strip() or "unknown" |
||||
lines.append(f"Branch: {branch}") |
||||
|
||||
_, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root) |
||||
status_lines = [line for line in status_out.splitlines() if line.strip()] |
||||
status_count = len(status_lines) |
||||
|
||||
if status_count == 0: |
||||
lines.append("Working directory: Clean") |
||||
else: |
||||
lines.append(f"Working directory: {status_count} uncommitted change(s)") |
||||
lines.append("") |
||||
lines.append("Changes:") |
||||
_, short_out, _ = run_git(["status", "--short"], cwd=repo_root) |
||||
for line in short_out.splitlines()[:10]: |
||||
lines.append(line) |
||||
lines.append("") |
||||
|
||||
# RECENT COMMITS |
||||
lines.append("## RECENT COMMITS") |
||||
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root) |
||||
if log_out.strip(): |
||||
for line in log_out.splitlines(): |
||||
lines.append(line) |
||||
else: |
||||
lines.append("(no commits)") |
||||
lines.append("") |
||||
|
||||
# Package git repos — independent sub-repositories |
||||
_append_package_git_context(lines, _collect_package_git_info(repo_root)) |
||||
|
||||
# CURRENT TASK |
||||
lines.append("## CURRENT TASK") |
||||
current_task = get_current_task(repo_root) |
||||
if current_task: |
||||
lines.append(f"Path: {current_task}") |
||||
ct = load_task(repo_root / current_task) |
||||
if ct: |
||||
lines.append(f"Name: {ct.name}") |
||||
lines.append(f"Status: {ct.status}") |
||||
else: |
||||
lines.append("(none)") |
||||
lines.append("") |
||||
|
||||
lines.append("========================================") |
||||
|
||||
return "\n".join(lines) |
||||
|
||||
|
||||
def output_text(repo_root: Path | None = None) -> None: |
||||
"""Output context in text format. |
||||
|
||||
Args: |
||||
repo_root: Repository root path. Defaults to auto-detected. |
||||
""" |
||||
print(get_context_text(repo_root)) |
||||
@ -0,0 +1,410 @@
@@ -0,0 +1,410 @@
|
||||
#!/usr/bin/env python3 |
||||
""" |
||||
Task JSONL context management. |
||||
|
||||
Provides: |
||||
cmd_init_context - Initialize JSONL context files for a task |
||||
cmd_add_context - Add entry to JSONL context file |
||||
cmd_validate - Validate JSONL context files |
||||
cmd_list_context - List JSONL context entries |
||||
""" |
||||
|
||||
from __future__ import annotations |
||||
|
||||
import argparse |
||||
import json |
||||
import sys |
||||
from pathlib import Path |
||||
|
||||
from .cli_adapter import get_cli_adapter_auto |
||||
from .config import ( |
||||
get_packages, |
||||
is_monorepo, |
||||
resolve_package, |
||||
validate_package, |
||||
) |
||||
from .io import read_json, write_json |
||||
from .log import Colors, colored |
||||
from .paths import ( |
||||
DIR_SPEC, |
||||
DIR_WORKFLOW, |
||||
FILE_TASK_JSON, |
||||
get_repo_root, |
||||
) |
||||
from .task_utils import resolve_task_dir |
||||
|
||||
|
||||
# ============================================================================= |
||||
# JSONL Default Content Generators |
||||
# ============================================================================= |
||||
|
||||
def get_implement_base() -> list[dict]: |
||||
"""Get base implement context entries.""" |
||||
return [ |
||||
{"file": f"{DIR_WORKFLOW}/workflow.md", "reason": "Project workflow and conventions"}, |
||||
] |
||||
|
||||
|
||||
def get_implement_backend(package: str | None = None) -> list[dict]: |
||||
"""Get backend implement context entries.""" |
||||
spec_base = f"{DIR_SPEC}/{package}" if package else DIR_SPEC |
||||
return [ |
||||
{"file": f"{DIR_WORKFLOW}/{spec_base}/backend/index.md", "reason": "Backend development guide"}, |
||||
] |
||||
|
||||
|
||||
def get_implement_frontend(package: str | None = None) -> list[dict]: |
||||
"""Get frontend implement context entries.""" |
||||
spec_base = f"{DIR_SPEC}/{package}" if package else DIR_SPEC |
||||
return [ |
||||
{"file": f"{DIR_WORKFLOW}/{spec_base}/frontend/index.md", "reason": "Frontend development guide"}, |
||||
] |
||||
|
||||
|
||||
def get_check_context(repo_root: Path) -> list[dict]: |
||||
"""Get check context entries.""" |
||||
adapter = get_cli_adapter_auto(repo_root) |
||||
|
||||
entries = [ |
||||
{"file": adapter.get_trellis_command_path("finish-work"), "reason": "Finish work checklist"}, |
||||
{"file": adapter.get_trellis_command_path("check"), "reason": "Code quality check spec"}, |
||||
] |
||||
|
||||
return entries |
||||
|
||||
|
||||
def get_debug_context(repo_root: Path) -> list[dict]: |
||||
"""Get debug context entries.""" |
||||
adapter = get_cli_adapter_auto(repo_root) |
||||
|
||||
entries: list[dict] = [ |
||||
{"file": adapter.get_trellis_command_path("check"), "reason": "Code quality check spec"}, |
||||
] |
||||
|
||||
return entries |
||||
|
||||
|
||||
def _write_jsonl(path: Path, entries: list[dict]) -> None: |
||||
"""Write entries to JSONL file.""" |
||||
lines = [json.dumps(entry, ensure_ascii=False) for entry in entries] |
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8") |
||||
|
||||
|
||||
# ============================================================================= |
||||
# Command: init-context |
||||
# ============================================================================= |
||||
|
||||
def cmd_init_context(args: argparse.Namespace) -> int: |
||||
"""Initialize JSONL context files for a task.""" |
||||
repo_root = get_repo_root() |
||||
target_dir = resolve_task_dir(args.dir, repo_root) |
||||
dev_type = args.type |
||||
|
||||
if not dev_type: |
||||
print(colored("Error: Missing arguments", Colors.RED)) |
||||
print("Usage: python3 task.py init-context <task-dir> <dev_type>") |
||||
print(" dev_type: backend | frontend | fullstack | test | docs") |
||||
return 1 |
||||
|
||||
if not target_dir.is_dir(): |
||||
print(colored(f"Error: Directory not found: {target_dir}", Colors.RED)) |
||||
return 1 |
||||
|
||||
# Resolve package: --package CLI → task.json.package → default_package |
||||
cli_package: str | None = getattr(args, "package", None) |
||||
package: str | None = None |
||||
if not is_monorepo(repo_root): |
||||
# Single-repo: ignore --package, no package prefix |
||||
if cli_package: |
||||
print(colored("Warning: --package ignored in single-repo project", Colors.YELLOW), file=sys.stderr) |
||||
elif cli_package: |
||||
if not validate_package(cli_package, repo_root): |
||||
packages = get_packages(repo_root) |
||||
available = ", ".join(sorted(packages.keys())) if packages else "(none)" |
||||
print(colored(f"Error: unknown package '{cli_package}'. Available: {available}", Colors.RED), file=sys.stderr) |
||||
return 1 |
||||
package = cli_package |
||||
else: |
||||
# Read task.json.package as inferred source |
||||
task_json_path = target_dir / FILE_TASK_JSON |
||||
task_pkg_value = None |
||||
if task_json_path.is_file(): |
||||
task_data = read_json(task_json_path) |
||||
if isinstance(task_data, dict): |
||||
task_pkg_value = task_data.get("package") |
||||
# Only pass string values to resolve_package (guard against malformed JSON) |
||||
task_package = task_pkg_value if isinstance(task_pkg_value, str) else None |
||||
package = resolve_package(task_package=task_package, repo_root=repo_root) |
||||
|
||||
# Monorepo fallback prohibition |
||||
if package is None: |
||||
packages = get_packages(repo_root) |
||||
available = ", ".join(sorted(packages.keys())) if packages else "(none)" |
||||
print(colored( |
||||
f"Error: monorepo project requires --package (or set default_package in config.yaml). Available: {available}", |
||||
Colors.RED, |
||||
), file=sys.stderr) |
||||
return 1 |
||||
|
||||
print(colored("=== Initializing Agent Context Files ===", Colors.BLUE)) |
||||
print(f"Target dir: {target_dir}") |
||||
print(f"Dev type: {dev_type}") |
||||
if package: |
||||
print(f"Package: {package}") |
||||
print() |
||||
|
||||
# implement.jsonl |
||||
print(colored("Creating implement.jsonl...", Colors.CYAN)) |
||||
implement_entries = get_implement_base() |
||||
if dev_type in ("backend", "test"): |
||||
implement_entries.extend(get_implement_backend(package)) |
||||
elif dev_type == "frontend": |
||||
implement_entries.extend(get_implement_frontend(package)) |
||||
elif dev_type == "fullstack": |
||||
implement_entries.extend(get_implement_backend(package)) |
||||
implement_entries.extend(get_implement_frontend(package)) |
||||
|
||||
implement_file = target_dir / "implement.jsonl" |
||||
_write_jsonl(implement_file, implement_entries) |
||||
print(f" {colored('✓', Colors.GREEN)} {len(implement_entries)} entries") |
||||
|
||||
# check.jsonl |
||||
print(colored("Creating check.jsonl...", Colors.CYAN)) |
||||
check_entries = get_check_context(repo_root) |
||||
check_file = target_dir / "check.jsonl" |
||||
_write_jsonl(check_file, check_entries) |
||||
print(f" {colored('✓', Colors.GREEN)} {len(check_entries)} entries") |
||||
|
||||
# debug.jsonl |
||||
print(colored("Creating debug.jsonl...", Colors.CYAN)) |
||||
debug_entries = get_debug_context(repo_root) |
||||
debug_file = target_dir / "debug.jsonl" |
||||
_write_jsonl(debug_file, debug_entries) |
||||
print(f" {colored('✓', Colors.GREEN)} {len(debug_entries)} entries") |
||||
|
||||
# Update task.json dev_type and package |
||||
task_json_path = target_dir / FILE_TASK_JSON |
||||
if task_json_path.is_file(): |
||||
task_data = read_json(task_json_path) |
||||
if isinstance(task_data, dict): |
||||
task_data["dev_type"] = dev_type |
||||
task_data["package"] = package # Always sync to match resolved value |
||||
write_json(task_json_path, task_data) |
||||
|
||||
print() |
||||
print(colored("✓ All context files created", Colors.GREEN)) |
||||
print() |
||||
|
||||
# Show what was auto-injected |
||||
all_injected = [e["file"] for e in implement_entries] |
||||
print(colored("Auto-injected (defaults only):", Colors.YELLOW)) |
||||
for f in all_injected: |
||||
print(f" - {f}") |
||||
print() |
||||
|
||||
# Scan spec directory for available spec files the AI should consider |
||||
spec_base = repo_root / DIR_WORKFLOW / DIR_SPEC |
||||
if package: |
||||
spec_base = spec_base / package |
||||
available_specs: list[str] = [] |
||||
if spec_base.is_dir(): |
||||
for md_file in sorted(spec_base.rglob("*.md")): |
||||
rel = str(md_file.relative_to(repo_root)) |
||||
if rel not in all_injected: |
||||
available_specs.append(rel) |
||||
|
||||
if available_specs: |
||||
print(colored("Available spec files (not yet injected):", Colors.BLUE)) |
||||
for spec in available_specs: |
||||
print(f" - {spec}") |
||||
print() |
||||
|
||||
print(colored("Next steps:", Colors.BLUE)) |
||||
print(" 1. Review the spec files above and add relevant ones for your task:") |
||||
print(f" python3 task.py add-context <dir> implement <spec-path> \"<reason>\"") |
||||
print(" 2. Set as current: python3 task.py start <dir>") |
||||
|
||||
return 0 |
||||
|
||||
|
||||
# ============================================================================= |
||||
# Command: add-context |
||||
# ============================================================================= |
||||
|
||||
def cmd_add_context(args: argparse.Namespace) -> int: |
||||
"""Add entry to JSONL context file.""" |
||||
repo_root = get_repo_root() |
||||
target_dir = resolve_task_dir(args.dir, repo_root) |
||||
|
||||
jsonl_name = args.file |
||||
path = args.path |
||||
reason = args.reason or "Added manually" |
||||
|
||||
if not target_dir.is_dir(): |
||||
print(colored(f"Error: Directory not found: {target_dir}", Colors.RED)) |
||||
return 1 |
||||
|
||||
# Support shorthand |
||||
if not jsonl_name.endswith(".jsonl"): |
||||
jsonl_name = f"{jsonl_name}.jsonl" |
||||
|
||||
jsonl_file = target_dir / jsonl_name |
||||
full_path = repo_root / path |
||||
|
||||
entry_type = "file" |
||||
if full_path.is_dir(): |
||||
entry_type = "directory" |
||||
if not path.endswith("/"): |
||||
path = f"{path}/" |
||||
elif not full_path.is_file(): |
||||
print(colored(f"Error: Path not found: {path}", Colors.RED)) |
||||
return 1 |
||||
|
||||
# Check if already exists |
||||
if jsonl_file.is_file(): |
||||
content = jsonl_file.read_text(encoding="utf-8") |
||||
if f'"{path}"' in content: |
||||
print(colored(f"Warning: Entry already exists for {path}", Colors.YELLOW)) |
||||
return 0 |
||||
|
||||
# Add entry |
||||
entry: dict |
||||
if entry_type == "directory": |
||||
entry = {"file": path, "type": "directory", "reason": reason} |
||||
else: |
||||
entry = {"file": path, "reason": reason} |
||||
|
||||
with jsonl_file.open("a", encoding="utf-8") as f: |
||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n") |
||||
|
||||
print(colored(f"Added {entry_type}: {path}", Colors.GREEN)) |
||||
return 0 |
||||
|
||||
|
||||
# ============================================================================= |
||||
# Command: validate |
||||
# ============================================================================= |
||||
|
||||
def cmd_validate(args: argparse.Namespace) -> int: |
||||
"""Validate JSONL context files.""" |
||||
repo_root = get_repo_root() |
||||
target_dir = resolve_task_dir(args.dir, repo_root) |
||||
|
||||
if not target_dir.is_dir(): |
||||
print(colored("Error: task directory required", Colors.RED)) |
||||
return 1 |
||||
|
||||
print(colored("=== Validating Context Files ===", Colors.BLUE)) |
||||
print(f"Target dir: {target_dir}") |
||||
print() |
||||
|
||||
total_errors = 0 |
||||
for jsonl_name in ["implement.jsonl", "check.jsonl", "debug.jsonl"]: |
||||
jsonl_file = target_dir / jsonl_name |
||||
errors = _validate_jsonl(jsonl_file, repo_root) |
||||
total_errors += errors |
||||
|
||||
print() |
||||
if total_errors == 0: |
||||
print(colored("✓ All validations passed", Colors.GREEN)) |
||||
return 0 |
||||
else: |
||||
print(colored(f"✗ Validation failed ({total_errors} errors)", Colors.RED)) |
||||
return 1 |
||||
|
||||
|
||||
def _validate_jsonl(jsonl_file: Path, repo_root: Path) -> int: |
||||
"""Validate a single JSONL file.""" |
||||
file_name = jsonl_file.name |
||||
errors = 0 |
||||
|
||||
if not jsonl_file.is_file(): |
||||
print(f" {colored(f'{file_name}: not found (skipped)', Colors.YELLOW)}") |
||||
return 0 |
||||
|
||||
line_num = 0 |
||||
for line in jsonl_file.read_text(encoding="utf-8").splitlines(): |
||||
line_num += 1 |
||||
if not line.strip(): |
||||
continue |
||||
|
||||
try: |
||||
data = json.loads(line) |
||||
except json.JSONDecodeError: |
||||
print(f" {colored(f'{file_name}:{line_num}: Invalid JSON', Colors.RED)}") |
||||
errors += 1 |
||||
continue |
||||
|
||||
file_path = data.get("file") |
||||
entry_type = data.get("type", "file") |
||||
|
||||
if not file_path: |
||||
print(f" {colored(f'{file_name}:{line_num}: Missing file field', Colors.RED)}") |
||||
errors += 1 |
||||
continue |
||||
|
||||
full_path = repo_root / file_path |
||||
if entry_type == "directory": |
||||
if not full_path.is_dir(): |
||||
print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}") |
||||
errors += 1 |
||||
else: |
||||
if not full_path.is_file(): |
||||
print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}") |
||||
errors += 1 |
||||
|
||||
if errors == 0: |
||||
print(f" {colored(f'{file_name}: ✓ ({line_num} entries)', Colors.GREEN)}") |
||||
else: |
||||
print(f" {colored(f'{file_name}: ✗ ({errors} errors)', Colors.RED)}") |
||||
|
||||
return errors |
||||
|
||||
|
||||
# ============================================================================= |
||||
# Command: list-context |
||||
# ============================================================================= |
||||
|
||||
def cmd_list_context(args: argparse.Namespace) -> int: |
||||
"""List JSONL context entries.""" |
||||
repo_root = get_repo_root() |
||||
target_dir = resolve_task_dir(args.dir, repo_root) |
||||
|
||||
if not target_dir.is_dir(): |
||||
print(colored("Error: task directory required", Colors.RED)) |
||||
return 1 |
||||
|
||||
print(colored("=== Context Files ===", Colors.BLUE)) |
||||
print() |
||||
|
||||
for jsonl_name in ["implement.jsonl", "check.jsonl", "debug.jsonl"]: |
||||
jsonl_file = target_dir / jsonl_name |
||||
if not jsonl_file.is_file(): |
||||
continue |
||||
|
||||
print(colored(f"[{jsonl_name}]", Colors.CYAN)) |
||||
|
||||
count = 0 |
||||
for line in jsonl_file.read_text(encoding="utf-8").splitlines(): |
||||
if not line.strip(): |
||||
continue |
||||
|
||||
try: |
||||
data = json.loads(line) |
||||
except json.JSONDecodeError: |
||||
continue |
||||
|
||||
count += 1 |
||||
file_path = data.get("file", "?") |
||||
entry_type = data.get("type", "file") |
||||
reason = data.get("reason", "-") |
||||
|
||||
if entry_type == "directory": |
||||
print(f" {colored(f'{count}.', Colors.GREEN)} [DIR] {file_path}") |
||||
else: |
||||
print(f" {colored(f'{count}.', Colors.GREEN)} {file_path}") |
||||
print(f" {colored('→', Colors.YELLOW)} {reason}") |
||||
|
||||
print() |
||||
|
||||
return 0 |
||||
@ -0,0 +1,534 @@
@@ -0,0 +1,534 @@
|
||||
#!/usr/bin/env python3 |
||||
""" |
||||
Task CRUD operations. |
||||
|
||||
Provides: |
||||
ensure_tasks_dir - Ensure tasks directory exists |
||||
cmd_create - Create a new task |
||||
cmd_archive - Archive completed task |
||||
cmd_set_branch - Set git branch for task |
||||
cmd_set_base_branch - Set PR target branch |
||||
cmd_set_scope - Set scope for PR title |
||||
cmd_add_subtask - Link child task to parent |
||||
cmd_remove_subtask - Unlink child task from parent |
||||
""" |
||||
|
||||
from __future__ import annotations |
||||
|
||||
import argparse |
||||
import re |
||||
import sys |
||||
from datetime import datetime |
||||
from pathlib import Path |
||||
|
||||
from .config import ( |
||||
get_packages, |
||||
is_monorepo, |
||||
resolve_package, |
||||
validate_package, |
||||
) |
||||
from .git import run_git |
||||
from .io import read_json, write_json |
||||
from .log import Colors, colored |
||||
from .paths import ( |
||||
DIR_ARCHIVE, |
||||
DIR_TASKS, |
||||
DIR_WORKFLOW, |
||||
FILE_TASK_JSON, |
||||
clear_current_task, |
||||
generate_task_date_prefix, |
||||
get_current_task, |
||||
get_developer, |
||||
get_repo_root, |
||||
get_tasks_dir, |
||||
) |
||||
from .task_utils import ( |
||||
archive_task_complete, |
||||
find_task_by_name, |
||||
resolve_task_dir, |
||||
run_task_hooks, |
||||
) |
||||
|
||||
|
||||
# ============================================================================= |
||||
# Helper Functions |
||||
# ============================================================================= |
||||
|
||||
def _slugify(title: str) -> str: |
||||
"""Convert title to slug (only works with ASCII).""" |
||||
result = title.lower() |
||||
result = re.sub(r"[^a-z0-9]", "-", result) |
||||
result = re.sub(r"-+", "-", result) |
||||
result = result.strip("-") |
||||
return result |
||||
|
||||
|
||||
def ensure_tasks_dir(repo_root: Path) -> Path: |
||||
"""Ensure tasks directory exists.""" |
||||
tasks_dir = get_tasks_dir(repo_root) |
||||
archive_dir = tasks_dir / "archive" |
||||
|
||||
if not tasks_dir.exists(): |
||||
tasks_dir.mkdir(parents=True) |
||||
print(colored(f"Created tasks directory: {tasks_dir}", Colors.GREEN), file=sys.stderr) |
||||
|
||||
if not archive_dir.exists(): |
||||
archive_dir.mkdir(parents=True) |
||||
|
||||
return tasks_dir |
||||
|
||||
|
||||
# ============================================================================= |
||||
# Command: create |
||||
# ============================================================================= |
||||
|
||||
def cmd_create(args: argparse.Namespace) -> int: |
||||
"""Create a new task.""" |
||||
repo_root = get_repo_root() |
||||
|
||||
if not args.title: |
||||
print(colored("Error: title is required", Colors.RED), file=sys.stderr) |
||||
return 1 |
||||
|
||||
# Validate --package (CLI source: fail-fast) |
||||
package: str | None = getattr(args, "package", None) |
||||
if not is_monorepo(repo_root): |
||||
# Single-repo: ignore --package, no package prefix |
||||
if package: |
||||
print(colored(f"Warning: --package ignored in single-repo project", Colors.YELLOW), file=sys.stderr) |
||||
package = None |
||||
elif package: |
||||
if not validate_package(package, repo_root): |
||||
packages = get_packages(repo_root) |
||||
available = ", ".join(sorted(packages.keys())) if packages else "(none)" |
||||
print(colored(f"Error: unknown package '{package}'. Available: {available}", Colors.RED), file=sys.stderr) |
||||
return 1 |
||||
else: |
||||
# Inferred: default_package → None (no task.json yet for create) |
||||
package = resolve_package(repo_root=repo_root) |
||||
|
||||
# Default assignee to current developer |
||||
assignee = args.assignee |
||||
if not assignee: |
||||
assignee = get_developer(repo_root) |
||||
if not assignee: |
||||
print(colored("Error: No developer set. Run init_developer.py first or use --assignee", Colors.RED), file=sys.stderr) |
||||
return 1 |
||||
|
||||
ensure_tasks_dir(repo_root) |
||||
|
||||
# Get current developer as creator |
||||
creator = get_developer(repo_root) or assignee |
||||
|
||||
# Generate slug if not provided |
||||
slug = args.slug or _slugify(args.title) |
||||
if not slug: |
||||
print(colored("Error: could not generate slug from title", Colors.RED), file=sys.stderr) |
||||
return 1 |
||||
|
||||
# Create task directory with MM-DD-slug format |
||||
tasks_dir = get_tasks_dir(repo_root) |
||||
date_prefix = generate_task_date_prefix() |
||||
dir_name = f"{date_prefix}-{slug}" |
||||
task_dir = tasks_dir / dir_name |
||||
task_json_path = task_dir / FILE_TASK_JSON |
||||
|
||||
if task_dir.exists(): |
||||
print(colored(f"Warning: Task directory already exists: {dir_name}", Colors.YELLOW), file=sys.stderr) |
||||
else: |
||||
task_dir.mkdir(parents=True) |
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d") |
||||
|
||||
# Record current branch as base_branch (PR target) |
||||
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) |
||||
current_branch = branch_out.strip() or "main" |
||||
|
||||
task_data = { |
||||
"id": slug, |
||||
"name": slug, |
||||
"title": args.title, |
||||
"description": args.description or "", |
||||
"status": "planning", |
||||
"dev_type": None, |
||||
"scope": None, |
||||
"package": package, |
||||
"priority": args.priority, |
||||
"creator": creator, |
||||
"assignee": assignee, |
||||
"createdAt": today, |
||||
"completedAt": None, |
||||
"branch": None, |
||||
"base_branch": current_branch, |
||||
"worktree_path": None, |
||||
"current_phase": 0, |
||||
"next_action": [ |
||||
{"phase": 1, "action": "implement"}, |
||||
{"phase": 2, "action": "check"}, |
||||
{"phase": 3, "action": "finish"}, |
||||
{"phase": 4, "action": "create-pr"}, |
||||
], |
||||
"commit": None, |
||||
"pr_url": None, |
||||
"subtasks": [], |
||||
"children": [], |
||||
"parent": None, |
||||
"relatedFiles": [], |
||||
"notes": "", |
||||
"meta": {}, |
||||
} |
||||
|
||||
write_json(task_json_path, task_data) |
||||
|
||||
# Handle --parent: establish bidirectional link |
||||
if args.parent: |
||||
parent_dir = resolve_task_dir(args.parent, repo_root) |
||||
parent_json_path = parent_dir / FILE_TASK_JSON |
||||
if not parent_json_path.is_file(): |
||||
print(colored(f"Warning: Parent task.json not found: {args.parent}", Colors.YELLOW), file=sys.stderr) |
||||
else: |
||||
parent_data = read_json(parent_json_path) |
||||
if parent_data: |
||||
# Add child to parent's children list |
||||
parent_children = parent_data.get("children", []) |
||||
if dir_name not in parent_children: |
||||
parent_children.append(dir_name) |
||||
parent_data["children"] = parent_children |
||||
write_json(parent_json_path, parent_data) |
||||
|
||||
# Set parent in child's task.json |
||||
task_data["parent"] = parent_dir.name |
||||
write_json(task_json_path, task_data) |
||||
|
||||
print(colored(f"Linked as child of: {parent_dir.name}", Colors.GREEN), file=sys.stderr) |
||||
|
||||
print(colored(f"Created task: {dir_name}", Colors.GREEN), file=sys.stderr) |
||||
print("", file=sys.stderr) |
||||
print(colored("Next steps:", Colors.BLUE), file=sys.stderr) |
||||
print(" 1. Create prd.md with requirements", file=sys.stderr) |
||||
print(" 2. Run: python3 task.py init-context <dir> <dev_type>", file=sys.stderr) |
||||
print(" 3. Run: python3 task.py start <dir>", file=sys.stderr) |
||||
print("", file=sys.stderr) |
||||
|
||||
# Output relative path for script chaining |
||||
print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}") |
||||
|
||||
run_task_hooks("after_create", task_json_path, repo_root) |
||||
return 0 |
||||
|
||||
|
||||
# ============================================================================= |
||||
# Command: archive |
||||
# ============================================================================= |
||||
|
||||
def cmd_archive(args: argparse.Namespace) -> int: |
||||
"""Archive completed task.""" |
||||
repo_root = get_repo_root() |
||||
task_name = args.name |
||||
|
||||
if not task_name: |
||||
print(colored("Error: Task name is required", Colors.RED), file=sys.stderr) |
||||
return 1 |
||||
|
||||
tasks_dir = get_tasks_dir(repo_root) |
||||
|
||||
# Find task directory |
||||
task_dir = find_task_by_name(task_name, tasks_dir) |
||||
|
||||
if not task_dir or not task_dir.is_dir(): |
||||
print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr) |
||||
print("Active tasks:", file=sys.stderr) |
||||
# Import lazily to avoid circular dependency |
||||
from .tasks import iter_active_tasks |
||||
for t in iter_active_tasks(tasks_dir): |
||||
print(f" - {t.dir_name}/", file=sys.stderr) |
||||
return 1 |
||||
|
||||
dir_name = task_dir.name |
||||
task_json_path = task_dir / FILE_TASK_JSON |
||||
|
||||
# Update status before archiving |
||||
today = datetime.now().strftime("%Y-%m-%d") |
||||
if task_json_path.is_file(): |
||||
data = read_json(task_json_path) |
||||
if data: |
||||
data["status"] = "completed" |
||||
data["completedAt"] = today |
||||
write_json(task_json_path, data) |
||||
|
||||
# Handle subtask relationships on archive |
||||
task_parent = data.get("parent") |
||||
task_children = data.get("children", []) |
||||
|
||||
# If this is a child, remove from parent's children list |
||||
if task_parent: |
||||
parent_dir = find_task_by_name(task_parent, tasks_dir) |
||||
if parent_dir: |
||||
parent_json = parent_dir / FILE_TASK_JSON |
||||
if parent_json.is_file(): |
||||
parent_data = read_json(parent_json) |
||||
if parent_data: |
||||
parent_children = parent_data.get("children", []) |
||||
if dir_name in parent_children: |
||||
parent_children.remove(dir_name) |
||||
parent_data["children"] = parent_children |
||||
write_json(parent_json, parent_data) |
||||
|
||||
# If this is a parent, clear parent field in all children |
||||
if task_children: |
||||
for child_name in task_children: |
||||
child_dir_path = find_task_by_name(child_name, tasks_dir) |
||||
if child_dir_path: |
||||
child_json = child_dir_path / FILE_TASK_JSON |
||||
if child_json.is_file(): |
||||
child_data = read_json(child_json) |
||||
if child_data: |
||||
child_data["parent"] = None |
||||
write_json(child_json, child_data) |
||||
|
||||
# Clear if current task |
||||
current = get_current_task(repo_root) |
||||
if current and dir_name in current: |
||||
clear_current_task(repo_root) |
||||
|
||||
# Archive |
||||
result = archive_task_complete(task_dir, repo_root) |
||||
if "archived_to" in result: |
||||
archive_dest = Path(result["archived_to"]) |
||||
year_month = archive_dest.parent.name |
||||
print(colored(f"Archived: {dir_name} -> archive/{year_month}/", Colors.GREEN), file=sys.stderr) |
||||
|
||||
# Auto-commit unless --no-commit |
||||
if not getattr(args, "no_commit", False): |
||||
_auto_commit_archive(dir_name, repo_root) |
||||
|
||||
# Return the archive path |
||||
print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}") |
||||
|
||||
# Run hooks with the archived path |
||||
archived_json = archive_dest / FILE_TASK_JSON |
||||
run_task_hooks("after_archive", archived_json, repo_root) |
||||
return 0 |
||||
|
||||
return 1 |
||||
|
||||
|
||||
def _auto_commit_archive(task_name: str, repo_root: Path) -> None: |
||||
"""Stage .trellis/tasks/ changes and commit after archive.""" |
||||
tasks_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}" |
||||
run_git(["add", "-A", tasks_rel], cwd=repo_root) |
||||
|
||||
# Check if there are staged changes |
||||
rc, _, _ = run_git( |
||||
["diff", "--cached", "--quiet", "--", tasks_rel], cwd=repo_root |
||||
) |
||||
if rc == 0: |
||||
print("[OK] No task changes to commit.", file=sys.stderr) |
||||
return |
||||
|
||||
commit_msg = f"chore(task): archive {task_name}" |
||||
rc, _, err = run_git(["commit", "-m", commit_msg], cwd=repo_root) |
||||
if rc == 0: |
||||
print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr) |
||||
else: |
||||
print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr) |
||||
|
||||
|
||||
# ============================================================================= |
||||
# Command: add-subtask |
||||
# ============================================================================= |
||||
|
||||
def cmd_add_subtask(args: argparse.Namespace) -> int: |
||||
"""Link a child task to a parent task.""" |
||||
repo_root = get_repo_root() |
||||
|
||||
parent_dir = resolve_task_dir(args.parent_dir, repo_root) |
||||
child_dir = resolve_task_dir(args.child_dir, repo_root) |
||||
|
||||
parent_json_path = parent_dir / FILE_TASK_JSON |
||||
child_json_path = child_dir / FILE_TASK_JSON |
||||
|
||||
if not parent_json_path.is_file(): |
||||
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr) |
||||
return 1 |
||||
|
||||
if not child_json_path.is_file(): |
||||
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) |
||||
return 1 |
||||
|
||||
parent_data = read_json(parent_json_path) |
||||
child_data = read_json(child_json_path) |
||||
|
||||
if not parent_data or not child_data: |
||||
print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr) |
||||
return 1 |
||||
|
||||
# Check if child already has a parent |
||||
existing_parent = child_data.get("parent") |
||||
if existing_parent: |
||||
print(colored(f"Error: Child task already has a parent: {existing_parent}", Colors.RED), file=sys.stderr) |
||||
return 1 |
||||
|
||||
# Add child to parent's children list |
||||
parent_children = parent_data.get("children", []) |
||||
child_dir_name = child_dir.name |
||||
if child_dir_name not in parent_children: |
||||
parent_children.append(child_dir_name) |
||||
parent_data["children"] = parent_children |
||||
|
||||
# Set parent in child's task.json |
||||
child_data["parent"] = parent_dir.name |
||||
|
||||
# Write both |
||||
write_json(parent_json_path, parent_data) |
||||
write_json(child_json_path, child_data) |
||||
|
||||
print(colored(f"Linked: {child_dir.name} -> {parent_dir.name}", Colors.GREEN), file=sys.stderr) |
||||
return 0 |
||||
|
||||
|
||||
# ============================================================================= |
||||
# Command: remove-subtask |
||||
# ============================================================================= |
||||
|
||||
def cmd_remove_subtask(args: argparse.Namespace) -> int: |
||||
"""Unlink a child task from a parent task.""" |
||||
repo_root = get_repo_root() |
||||
|
||||
parent_dir = resolve_task_dir(args.parent_dir, repo_root) |
||||
child_dir = resolve_task_dir(args.child_dir, repo_root) |
||||
|
||||
parent_json_path = parent_dir / FILE_TASK_JSON |
||||
child_json_path = child_dir / FILE_TASK_JSON |
||||
|
||||
if not parent_json_path.is_file(): |
||||
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr) |
||||
return 1 |
||||
|
||||
if not child_json_path.is_file(): |
||||
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) |
||||
return 1 |
||||
|
||||
parent_data = read_json(parent_json_path) |
||||
child_data = read_json(child_json_path) |
||||
|
||||
if not parent_data or not child_data: |
||||
print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr) |
||||
return 1 |
||||
|
||||
# Remove child from parent's children list |
||||
parent_children = parent_data.get("children", []) |
||||
child_dir_name = child_dir.name |
||||
if child_dir_name in parent_children: |
||||
parent_children.remove(child_dir_name) |
||||
parent_data["children"] = parent_children |
||||
|
||||
# Clear parent in child's task.json |
||||
child_data["parent"] = None |
||||
|
||||
# Write both |
||||
write_json(parent_json_path, parent_data) |
||||
write_json(child_json_path, child_data) |
||||
|
||||
print(colored(f"Unlinked: {child_dir.name} from {parent_dir.name}", Colors.GREEN), file=sys.stderr) |
||||
return 0 |
||||
|
||||
|
||||
# ============================================================================= |
||||
# Command: set-branch |
||||
# ============================================================================= |
||||
|
||||
def cmd_set_branch(args: argparse.Namespace) -> int: |
||||
"""Set git branch for task.""" |
||||
repo_root = get_repo_root() |
||||
target_dir = resolve_task_dir(args.dir, repo_root) |
||||
branch = args.branch |
||||
|
||||
if not branch: |
||||
print(colored("Error: Missing arguments", Colors.RED)) |
||||
print("Usage: python3 task.py set-branch <task-dir> <branch-name>") |
||||
return 1 |
||||
|
||||
task_json = target_dir / FILE_TASK_JSON |
||||
if not task_json.is_file(): |
||||
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) |
||||
return 1 |
||||
|
||||
data = read_json(task_json) |
||||
if not data: |
||||
return 1 |
||||
|
||||
data["branch"] = branch |
||||
write_json(task_json, data) |
||||
|
||||
print(colored(f"✓ Branch set to: {branch}", Colors.GREEN)) |
||||
print() |
||||
print(colored("Now you can start the multi-agent pipeline:", Colors.BLUE)) |
||||
print(f" python3 ./.trellis/scripts/multi_agent/start.py {args.dir}") |
||||
return 0 |
||||
|
||||
|
||||
# ============================================================================= |
||||
# Command: set-base-branch |
||||
# ============================================================================= |
||||
|
||||
def cmd_set_base_branch(args: argparse.Namespace) -> int: |
||||
"""Set the base branch (PR target) for task.""" |
||||
repo_root = get_repo_root() |
||||
target_dir = resolve_task_dir(args.dir, repo_root) |
||||
base_branch = args.base_branch |
||||
|
||||
if not base_branch: |
||||
print(colored("Error: Missing arguments", Colors.RED)) |
||||
print("Usage: python3 task.py set-base-branch <task-dir> <base-branch>") |
||||
print("Example: python3 task.py set-base-branch <dir> develop") |
||||
print() |
||||
print("This sets the target branch for PR (the branch your feature will merge into).") |
||||
return 1 |
||||
|
||||
task_json = target_dir / FILE_TASK_JSON |
||||
if not task_json.is_file(): |
||||
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) |
||||
return 1 |
||||
|
||||
data = read_json(task_json) |
||||
if not data: |
||||
return 1 |
||||
|
||||
data["base_branch"] = base_branch |
||||
write_json(task_json, data) |
||||
|
||||
print(colored(f"✓ Base branch set to: {base_branch}", Colors.GREEN)) |
||||
print(f" PR will target: {base_branch}") |
||||
return 0 |
||||
|
||||
|
||||
# ============================================================================= |
||||
# Command: set-scope |
||||
# ============================================================================= |
||||
|
||||
def cmd_set_scope(args: argparse.Namespace) -> int: |
||||
"""Set scope for PR title.""" |
||||
repo_root = get_repo_root() |
||||
target_dir = resolve_task_dir(args.dir, repo_root) |
||||
scope = args.scope |
||||
|
||||
if not scope: |
||||
print(colored("Error: Missing arguments", Colors.RED)) |
||||
print("Usage: python3 task.py set-scope <task-dir> <scope>") |
||||
return 1 |
||||
|
||||
task_json = target_dir / FILE_TASK_JSON |
||||
if not task_json.is_file(): |
||||
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) |
||||
return 1 |
||||
|
||||
data = read_json(task_json) |
||||
if not data: |
||||
return 1 |
||||
|
||||
data["scope"] = scope |
||||
write_json(task_json, data) |
||||
|
||||
print(colored(f"✓ Scope set to: {scope}", Colors.GREEN)) |
||||
return 0 |
||||
@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
|
||||
""" |
||||
Task data access layer. |
||||
|
||||
Single source of truth for loading and iterating task directories. |
||||
Replaces scattered task.json parsing across 9+ files. |
||||
|
||||
Provides: |
||||
load_task — Load a single task by directory path |
||||
iter_active_tasks — Iterate all non-archived tasks (sorted) |
||||
get_all_statuses — Get {dir_name: status} map for children progress |
||||
""" |
||||
|
||||
from __future__ import annotations |
||||
|
||||
from collections.abc import Iterator |
||||
from pathlib import Path |
||||
|
||||
from .io import read_json |
||||
from .paths import FILE_TASK_JSON |
||||
from .types import TaskInfo |
||||
|
||||
|
||||
def load_task(task_dir: Path) -> TaskInfo | None: |
||||
"""Load task from a directory containing task.json. |
||||
|
||||
Args: |
||||
task_dir: Absolute path to the task directory. |
||||
|
||||
Returns: |
||||
TaskInfo if task.json exists and is valid, None otherwise. |
||||
""" |
||||
task_json = task_dir / FILE_TASK_JSON |
||||
if not task_json.is_file(): |
||||
return None |
||||
|
||||
data = read_json(task_json) |
||||
if not data: |
||||
return None |
||||
|
||||
return TaskInfo( |
||||
dir_name=task_dir.name, |
||||
directory=task_dir, |
||||
title=data.get("title") or data.get("name") or "unknown", |
||||
status=data.get("status", "unknown"), |
||||
assignee=data.get("assignee", ""), |
||||
priority=data.get("priority", "P2"), |
||||
children=tuple(data.get("children", [])), |
||||
parent=data.get("parent"), |
||||
package=data.get("package"), |
||||
raw=data, |
||||
) |
||||
|
||||
|
||||
def iter_active_tasks(tasks_dir: Path) -> Iterator[TaskInfo]: |
||||
"""Iterate all active (non-archived) tasks, sorted by directory name. |
||||
|
||||
Skips the "archive" directory and directories without valid task.json. |
||||
|
||||
Args: |
||||
tasks_dir: Path to the tasks directory. |
||||
|
||||
Yields: |
||||
TaskInfo for each valid task. |
||||
""" |
||||
if not tasks_dir.is_dir(): |
||||
return |
||||
|
||||
for d in sorted(tasks_dir.iterdir()): |
||||
if not d.is_dir() or d.name == "archive": |
||||
continue |
||||
info = load_task(d) |
||||
if info is not None: |
||||
yield info |
||||
|
||||
|
||||
def get_all_statuses(tasks_dir: Path) -> dict[str, str]: |
||||
"""Get a {dir_name: status} mapping for all active tasks. |
||||
|
||||
Useful for computing children progress without loading full TaskInfo. |
||||
|
||||
Args: |
||||
tasks_dir: Path to the tasks directory. |
||||
|
||||
Returns: |
||||
Dict mapping directory names to status strings. |
||||
""" |
||||
return {t.dir_name: t.status for t in iter_active_tasks(tasks_dir)} |
||||
|
||||
|
||||
def children_progress( |
||||
children: tuple[str, ...] | list[str], |
||||
all_statuses: dict[str, str], |
||||
) -> str: |
||||
"""Format children progress string like " [2/3 done]". |
||||
|
||||
Args: |
||||
children: List of child directory names. |
||||
all_statuses: Status map from get_all_statuses(). |
||||
|
||||
Returns: |
||||
Formatted string, or "" if no children. |
||||
""" |
||||
if not children: |
||||
return "" |
||||
done = sum( |
||||
1 for c in children |
||||
if all_statuses.get(c) in ("completed", "done") |
||||
) |
||||
return f" [{done}/{len(children)} done]" |
||||
@ -0,0 +1,112 @@
@@ -0,0 +1,112 @@
|
||||
""" |
||||
Core type definitions for Trellis task data. |
||||
|
||||
Provides: |
||||
TaskData — TypedDict for task.json shape (read-path type hints only) |
||||
TaskInfo — Frozen dataclass for loaded task (the public API type) |
||||
AgentRecord — TypedDict for registry.json agent entries |
||||
""" |
||||
|
||||
from __future__ import annotations |
||||
|
||||
from dataclasses import dataclass |
||||
from pathlib import Path |
||||
from typing import TypedDict |
||||
|
||||
|
||||
# ============================================================================= |
||||
# task.json shape (TypedDict — used only for read-path type hints) |
||||
# ============================================================================= |
||||
|
||||
class TaskData(TypedDict, total=False): |
||||
"""Shape of task.json on disk. |
||||
|
||||
Used only for type annotations when reading task.json. |
||||
Writes must use the original dict to avoid losing unknown fields. |
||||
""" |
||||
|
||||
id: str |
||||
name: str |
||||
title: str |
||||
description: str |
||||
status: str |
||||
dev_type: str |
||||
scope: str | None |
||||
package: str | None |
||||
priority: str |
||||
creator: str |
||||
assignee: str |
||||
createdAt: str |
||||
completedAt: str | None |
||||
branch: str | None |
||||
base_branch: str | None |
||||
worktree_path: str | None |
||||
current_phase: int |
||||
next_action: list[dict] |
||||
commit: str | None |
||||
pr_url: str | None |
||||
subtasks: list[str] |
||||
children: list[str] |
||||
parent: str | None |
||||
relatedFiles: list[str] |
||||
notes: str |
||||
meta: dict |
||||
|
||||
|
||||
# ============================================================================= |
||||
# Loaded task object (frozen dataclass — the public API type) |
||||
# ============================================================================= |
||||
|
||||
@dataclass(frozen=True) |
||||
class TaskInfo: |
||||
"""Immutable view of a loaded task. |
||||
|
||||
Created by load_task() / iter_active_tasks(). |
||||
Contains the commonly accessed fields; the original dict |
||||
is preserved in `raw` for write-back and uncommon field access. |
||||
""" |
||||
|
||||
dir_name: str |
||||
directory: Path |
||||
title: str |
||||
status: str |
||||
assignee: str |
||||
priority: str |
||||
children: tuple[str, ...] |
||||
parent: str | None |
||||
package: str | None |
||||
raw: dict # original dict — use for writes and uncommon fields |
||||
|
||||
@property |
||||
def name(self) -> str: |
||||
"""Task name (id or name field).""" |
||||
return self.raw.get("name") or self.raw.get("id") or self.dir_name |
||||
|
||||
@property |
||||
def description(self) -> str: |
||||
return self.raw.get("description", "") |
||||
|
||||
@property |
||||
def branch(self) -> str | None: |
||||
return self.raw.get("branch") |
||||
|
||||
@property |
||||
def meta(self) -> dict: |
||||
return self.raw.get("meta", {}) |
||||
|
||||
|
||||
# ============================================================================= |
||||
# registry.json agent entry |
||||
# ============================================================================= |
||||
|
||||
class AgentRecord(TypedDict, total=False): |
||||
"""Shape of an agent entry in registry.json.""" |
||||
|
||||
id: str |
||||
pid: int |
||||
task_dir: str |
||||
worktree_path: str |
||||
branch: str |
||||
platform: str |
||||
started_at: str |
||||
status: str |
||||
@ -0,0 +1,542 @@
@@ -0,0 +1,542 @@
|
||||
#!/usr/bin/env python3 |
||||
""" |
||||
Multi-Agent Pipeline: Status display and formatting. |
||||
|
||||
Provides: |
||||
cmd_help - Show help text |
||||
cmd_list - List worktrees and agents |
||||
cmd_summary - Summary of all tasks with agent status |
||||
cmd_detail - Detailed single-agent status |
||||
cmd_registry - Dump agent registry |
||||
|
||||
Also exports shared utilities used by status_monitor: |
||||
is_running, find_agent, get_registry_file, calc_elapsed, count_modified_files |
||||
""" |
||||
|
||||
from __future__ import annotations |
||||
|
||||
import json |
||||
import os |
||||
import subprocess |
||||
from datetime import datetime |
||||
from pathlib import Path |
||||
|
||||
from common.cli_adapter import get_cli_adapter |
||||
from common.io import read_json |
||||
from common.log import Colors |
||||
from common.developer import ensure_developer |
||||
from common.paths import ( |
||||
get_repo_root, |
||||
get_tasks_dir, |
||||
) |
||||
from common.phase import get_phase_info |
||||
from common.task_queue import format_task_stats, get_task_stats |
||||
from common.tasks import iter_active_tasks |
||||
from common.worktree import get_agents_dir |
||||
|
||||
|
||||
# ============================================================================= |
||||
# Shared Utilities |
||||
# ============================================================================= |
||||
|
||||
def is_running(pid: int | str | None) -> bool: |
||||
"""Check if PID is running.""" |
||||
if not pid: |
||||
return False |
||||
try: |
||||
pid_int = int(pid) |
||||
os.kill(pid_int, 0) |
||||
return True |
||||
except (ProcessLookupError, ValueError, PermissionError, TypeError): |
||||
return False |
||||
|
||||
|
||||
def status_color(status: str) -> str: |
||||
"""Get status color.""" |
||||
colors = { |
||||
"completed": Colors.GREEN, |
||||
"in_progress": Colors.BLUE, |
||||
"planning": Colors.YELLOW, |
||||
} |
||||
return colors.get(status, Colors.DIM) |
||||
|
||||
|
||||
def get_registry_file(repo_root: Path) -> Path | None: |
||||
"""Get registry file path.""" |
||||
agents_dir = get_agents_dir(repo_root) |
||||
if agents_dir: |
||||
return agents_dir / "registry.json" |
||||
return None |
||||
|
||||
|
||||
def find_agent(search: str, repo_root: Path) -> dict | None: |
||||
"""Find agent by task name or ID.""" |
||||
registry_file = get_registry_file(repo_root) |
||||
if not registry_file or not registry_file.is_file(): |
||||
return None |
||||
|
||||
data = read_json(registry_file) |
||||
if not data: |
||||
return None |
||||
|
||||
for agent in data.get("agents", []): |
||||
# Exact ID match |
||||
if agent.get("id") == search: |
||||
return agent |
||||
# Partial match on task_dir |
||||
task_dir = agent.get("task_dir", "") |
||||
if search in task_dir: |
||||
return agent |
||||
|
||||
return None |
||||
|
||||
|
||||
def calc_elapsed(started: str | None) -> str: |
||||
"""Calculate elapsed time from ISO timestamp.""" |
||||
if not started: |
||||
return "N/A" |
||||
|
||||
try: |
||||
# Parse ISO format |
||||
if "+" in started: |
||||
started = started.split("+")[0] |
||||
if "T" in started: |
||||
start_dt = datetime.fromisoformat(started) |
||||
else: |
||||
return "N/A" |
||||
|
||||
now = datetime.now() |
||||
elapsed = (now - start_dt).total_seconds() |
||||
|
||||
if elapsed < 60: |
||||
return f"{int(elapsed)}s" |
||||
elif elapsed < 3600: |
||||
mins = int(elapsed // 60) |
||||
secs = int(elapsed % 60) |
||||
return f"{mins}m {secs}s" |
||||
else: |
||||
hours = int(elapsed // 3600) |
||||
mins = int((elapsed % 3600) // 60) |
||||
return f"{hours}h {mins}m" |
||||
except (ValueError, TypeError): |
||||
return "N/A" |
||||
|
||||
|
||||
def count_modified_files(worktree: str) -> int: |
||||
"""Count modified files in worktree.""" |
||||
if not Path(worktree).is_dir(): |
||||
return 0 |
||||
|
||||
try: |
||||
result = subprocess.run( |
||||
["git", "status", "--short"], |
||||
cwd=worktree, |
||||
capture_output=True, |
||||
text=True, |
||||
encoding="utf-8", |
||||
errors="replace", |
||||
) |
||||
return len([line for line in result.stdout.splitlines() if line.strip()]) |
||||
except Exception: |
||||
return 0 |
||||
|
||||
|
||||
# ============================================================================= |
||||
# Commands |
||||
# ============================================================================= |
||||
|
||||
def cmd_help() -> int: |
||||
"""Show help.""" |
||||
print("""Multi-Agent Pipeline: Status Monitor |
||||
|
||||
Usage: |
||||
python3 status.py Show summary of all tasks |
||||
python3 status.py -a <assignee> Filter tasks by assignee |
||||
python3 status.py --list List all worktrees and agents |
||||
python3 status.py --detail <task> Detailed task status |
||||
python3 status.py --progress <task> Quick progress view with recent activity |
||||
python3 status.py --watch <task> Watch agent log in real-time |
||||
python3 status.py --log <task> Show recent log entries |
||||
python3 status.py --registry Show agent registry |
||||
|
||||
Examples: |
||||
python3 status.py -a taosu |
||||
python3 status.py --detail my-task |
||||
python3 status.py --progress my-task |
||||
python3 status.py --watch 01-16-worktree-support |
||||
python3 status.py --log worktree-support |
||||
""") |
||||
return 0 |
||||
|
||||
|
||||
def cmd_list(repo_root: Path) -> int: |
||||
"""List worktrees and agents.""" |
||||
print(f"{Colors.BLUE}=== Git Worktrees ==={Colors.NC}") |
||||
print() |
||||
|
||||
subprocess.run(["git", "worktree", "list"], cwd=repo_root) |
||||
print() |
||||
|
||||
print(f"{Colors.BLUE}=== Registered Agents ==={Colors.NC}") |
||||
print() |
||||
|
||||
registry_file = get_registry_file(repo_root) |
||||
if not registry_file or not registry_file.is_file(): |
||||
print(" (no registry found)") |
||||
return 0 |
||||
|
||||
data = read_json(registry_file) |
||||
if not data or not data.get("agents"): |
||||
print(" (no agents registered)") |
||||
return 0 |
||||
|
||||
for agent in data["agents"]: |
||||
agent_id = agent.get("id", "?") |
||||
pid = agent.get("pid") |
||||
wt = agent.get("worktree_path", "?") |
||||
started = agent.get("started_at", "?") |
||||
|
||||
if is_running(pid): |
||||
status_icon = f"{Colors.GREEN}●{Colors.NC}" |
||||
else: |
||||
status_icon = f"{Colors.RED}○{Colors.NC}" |
||||
|
||||
print(f" {status_icon} {agent_id} (PID: {pid})") |
||||
print(f" {Colors.DIM}Worktree: {wt}{Colors.NC}") |
||||
print(f" {Colors.DIM}Started: {started}{Colors.NC}") |
||||
print() |
||||
|
||||
return 0 |
||||
|
||||
|
||||
def cmd_summary(repo_root: Path, filter_assignee: str | None = None) -> int: |
||||
"""Show summary of all tasks.""" |
||||
# Import lazily to avoid circular import at module level |
||||
from .status_monitor import get_last_tool, get_last_message |
||||
|
||||
ensure_developer(repo_root) |
||||
|
||||
tasks_dir = get_tasks_dir(repo_root) |
||||
if not tasks_dir.is_dir(): |
||||
print("No tasks directory found") |
||||
return 0 |
||||
|
||||
registry_file = get_registry_file(repo_root) |
||||
|
||||
# Count running agents |
||||
running_count = 0 |
||||
total_agents = 0 |
||||
|
||||
if registry_file and registry_file.is_file(): |
||||
data = read_json(registry_file) |
||||
if data: |
||||
agents = data.get("agents", []) |
||||
total_agents = len(agents) |
||||
for agent in agents: |
||||
if is_running(agent.get("pid")): |
||||
running_count += 1 |
||||
|
||||
# Task queue stats |
||||
task_stats = get_task_stats(repo_root) |
||||
|
||||
print(f"{Colors.BLUE}=== Multi-Agent Status ==={Colors.NC}") |
||||
print( |
||||
f" Agents: {Colors.GREEN}{running_count}{Colors.NC} running / {total_agents} registered" |
||||
) |
||||
print(f" Tasks: {format_task_stats(task_stats)}") |
||||
print() |
||||
|
||||
# Process tasks |
||||
running_tasks = [] |
||||
stopped_tasks = [] |
||||
regular_tasks = [] |
||||
|
||||
registry_data = ( |
||||
read_json(registry_file) |
||||
if registry_file and registry_file.is_file() |
||||
else None |
||||
) |
||||
|
||||
for t in iter_active_tasks(tasks_dir): |
||||
name = t.dir_name |
||||
status = t.status |
||||
assignee = t.assignee or "unassigned" |
||||
priority = t.priority |
||||
|
||||
# Filter by assignee |
||||
if filter_assignee and assignee != filter_assignee: |
||||
continue |
||||
|
||||
# Check agent status |
||||
agent_info = None |
||||
if registry_data: |
||||
for agent in registry_data.get("agents", []): |
||||
if name in agent.get("task_dir", ""): |
||||
agent_info = agent |
||||
break |
||||
|
||||
if agent_info: |
||||
pid = agent_info.get("pid") |
||||
worktree = agent_info.get("worktree_path", "") |
||||
started = agent_info.get("started_at") |
||||
agent_platform = agent_info.get("platform", "claude") |
||||
|
||||
if is_running(pid): |
||||
# Running agent |
||||
task_dir_rel = agent_info.get("task_dir", "") |
||||
worktree_task_json = Path(worktree) / task_dir_rel / "task.json" |
||||
phase_source = t.directory / "task.json" |
||||
if worktree_task_json.is_file(): |
||||
phase_source = worktree_task_json |
||||
|
||||
phase_info_str = get_phase_info(phase_source) |
||||
elapsed = calc_elapsed(started) |
||||
modified = count_modified_files(worktree) |
||||
|
||||
worktree_data = read_json(phase_source) |
||||
branch = worktree_data.get("branch", "N/A") if worktree_data else "N/A" |
||||
|
||||
log_file = Path(worktree) / ".agent-log" |
||||
last_tool = get_last_tool(log_file, platform=agent_platform) |
||||
|
||||
running_tasks.append( |
||||
{ |
||||
"name": name, |
||||
"priority": priority, |
||||
"assignee": assignee, |
||||
"phase_info": phase_info_str, |
||||
"elapsed": elapsed, |
||||
"branch": branch, |
||||
"modified": modified, |
||||
"last_tool": last_tool, |
||||
"pid": pid, |
||||
} |
||||
) |
||||
else: |
||||
# Stopped agent |
||||
task_dir_rel = agent_info.get("task_dir", "") |
||||
worktree_task_json = Path(worktree) / task_dir_rel / "task.json" |
||||
worktree_status = "unknown" |
||||
|
||||
if worktree_task_json.is_file(): |
||||
wt_data = read_json(worktree_task_json) |
||||
if wt_data: |
||||
worktree_status = wt_data.get("status", "unknown") |
||||
|
||||
session_id_file = Path(worktree) / ".session-id" |
||||
log_file = Path(worktree) / ".agent-log" |
||||
|
||||
stopped_tasks.append( |
||||
{ |
||||
"name": name, |
||||
"worktree": worktree, |
||||
"status": worktree_status, |
||||
"session_id_file": session_id_file, |
||||
"log_file": log_file, |
||||
"platform": agent_info.get("platform", "claude"), |
||||
} |
||||
) |
||||
else: |
||||
# Regular task |
||||
regular_tasks.append( |
||||
{ |
||||
"name": name, |
||||
"status": status, |
||||
"priority": priority, |
||||
"assignee": assignee, |
||||
} |
||||
) |
||||
|
||||
# Output running agents |
||||
if running_tasks: |
||||
print(f"{Colors.CYAN}Running Agents:{Colors.NC}") |
||||
for t in running_tasks: |
||||
priority_color = ( |
||||
Colors.RED |
||||
if t["priority"] == "P0" |
||||
else (Colors.YELLOW if t["priority"] == "P1" else Colors.BLUE) |
||||
) |
||||
print( |
||||
f"{Colors.GREEN}▶{Colors.NC} {Colors.CYAN}{t['name']}{Colors.NC} {Colors.GREEN}[running]{Colors.NC} {priority_color}[{t['priority']}]{Colors.NC} @{t['assignee']}" |
||||
) |
||||
print(f" Phase: {t['phase_info']}") |
||||
print(f" Elapsed: {t['elapsed']}") |
||||
print(f" Branch: {Colors.DIM}{t['branch']}{Colors.NC}") |
||||
print(f" Modified: {t['modified']} file(s)") |
||||
if t["last_tool"]: |
||||
print(f" Activity: {Colors.YELLOW}{t['last_tool']}{Colors.NC}") |
||||
print(f" PID: {Colors.DIM}{t['pid']}{Colors.NC}") |
||||
print() |
||||
|
||||
# Output stopped agents |
||||
if stopped_tasks: |
||||
print(f"{Colors.RED}Stopped Agents:{Colors.NC}") |
||||
for t in stopped_tasks: |
||||
if t["status"] == "completed": |
||||
print( |
||||
f"{Colors.GREEN}✓{Colors.NC} {t['name']} {Colors.GREEN}[completed]{Colors.NC}" |
||||
) |
||||
else: |
||||
if t["session_id_file"].is_file(): |
||||
session_id = ( |
||||
t["session_id_file"].read_text(encoding="utf-8").strip() |
||||
) |
||||
last_msg = get_last_message(t["log_file"], 150, platform=t.get("platform", "claude")) |
||||
print( |
||||
f"{Colors.RED}○{Colors.NC} {t['name']} {Colors.RED}[stopped]{Colors.NC}" |
||||
) |
||||
if last_msg: |
||||
print(f'{Colors.DIM}"{last_msg}"{Colors.NC}') |
||||
# Use CLI adapter for platform-specific resume command |
||||
adapter = get_cli_adapter(t.get("platform", "claude")) |
||||
resume_cmd = adapter.get_resume_command_str(session_id, cwd=t["worktree"]) |
||||
print(f"{Colors.YELLOW}{resume_cmd}{Colors.NC}") |
||||
else: |
||||
print( |
||||
f"{Colors.RED}○{Colors.NC} {t['name']} {Colors.RED}[stopped]{Colors.NC} {Colors.DIM}(no session-id){Colors.NC}" |
||||
) |
||||
print() |
||||
|
||||
# Separator |
||||
if (running_tasks or stopped_tasks) and regular_tasks: |
||||
print(f"{Colors.DIM}───────────────────────────────────────{Colors.NC}") |
||||
print() |
||||
|
||||
# Output regular tasks grouped by assignee |
||||
if regular_tasks: |
||||
# Sort by assignee, priority, status |
||||
regular_tasks.sort( |
||||
key=lambda x: ( |
||||
x["assignee"], |
||||
{"P0": 0, "P1": 1, "P2": 2, "P3": 3}.get(x["priority"], 2), |
||||
{"in_progress": 0, "planning": 1, "completed": 2}.get(x["status"], 1), |
||||
) |
||||
) |
||||
|
||||
current_assignee = None |
||||
for t in regular_tasks: |
||||
if t["assignee"] != current_assignee: |
||||
if current_assignee is not None: |
||||
print() |
||||
print(f"{Colors.CYAN}@{t['assignee']}:{Colors.NC}") |
||||
current_assignee = t["assignee"] |
||||
|
||||
color = status_color(t["status"]) |
||||
priority_color = ( |
||||
Colors.RED |
||||
if t["priority"] == "P0" |
||||
else (Colors.YELLOW if t["priority"] == "P1" else Colors.BLUE) |
||||
) |
||||
print( |
||||
f" {color}●{Colors.NC} {t['name']} ({t['status']}) {priority_color}[{t['priority']}]{Colors.NC}" |
||||
) |
||||
|
||||
if running_tasks: |
||||
print() |
||||
print(f"{Colors.DIM}─────────────────────────────────────{Colors.NC}") |
||||
print(f"{Colors.DIM}Use --progress <name> for quick activity view{Colors.NC}") |
||||
print(f"{Colors.DIM}Use --detail <name> for more info{Colors.NC}") |
||||
|
||||
print() |
||||
return 0 |
||||
|
||||
|
||||
def cmd_detail(target: str, repo_root: Path) -> int: |
||||
"""Show detailed task status.""" |
||||
agent = find_agent(target, repo_root) |
||||
if not agent: |
||||
print(f"Agent not found: {target}") |
||||
return 1 |
||||
|
||||
agent_id = agent.get("id", "?") |
||||
pid = agent.get("pid") |
||||
worktree = agent.get("worktree_path", "?") |
||||
task_dir = agent.get("task_dir", "?") |
||||
started = agent.get("started_at", "?") |
||||
platform = agent.get("platform", "claude") |
||||
|
||||
# Check for session-id |
||||
session_id = "" |
||||
session_id_file = Path(worktree) / ".session-id" |
||||
if session_id_file.is_file(): |
||||
session_id = session_id_file.read_text(encoding="utf-8").strip() |
||||
|
||||
print(f"{Colors.BLUE}=== Agent Detail: {agent_id} ==={Colors.NC}") |
||||
print() |
||||
print(f" ID: {agent_id}") |
||||
print(f" PID: {pid}") |
||||
print(f" Session: {session_id or 'N/A'}") |
||||
print(f" Worktree: {worktree}") |
||||
print(f" Task Dir: {task_dir}") |
||||
print(f" Started: {started}") |
||||
print() |
||||
|
||||
# Status |
||||
if is_running(pid): |
||||
print(f" Status: {Colors.GREEN}Running{Colors.NC}") |
||||
else: |
||||
print(f" Status: {Colors.RED}Stopped{Colors.NC}") |
||||
if session_id: |
||||
print() |
||||
# Use CLI adapter for platform-specific resume command |
||||
adapter = get_cli_adapter(platform) |
||||
resume_cmd = adapter.get_resume_command_str(session_id, cwd=worktree) |
||||
print(f" {Colors.YELLOW}Resume:{Colors.NC} {resume_cmd}") |
||||
|
||||
# Task info |
||||
task_json = repo_root / task_dir / "task.json" |
||||
if task_json.is_file(): |
||||
print() |
||||
print(f"{Colors.BLUE}=== Task Info ==={Colors.NC}") |
||||
print() |
||||
data = read_json(task_json) |
||||
if data: |
||||
print(f" Status: {data.get('status', 'unknown')}") |
||||
print(f" Branch: {data.get('branch', 'N/A')}") |
||||
print(f" Base Branch: {data.get('base_branch', 'N/A')}") |
||||
|
||||
# Git changes |
||||
if Path(worktree).is_dir(): |
||||
print() |
||||
print(f"{Colors.BLUE}=== Git Changes ==={Colors.NC}") |
||||
print() |
||||
|
||||
result = subprocess.run( |
||||
["git", "status", "--short"], |
||||
cwd=worktree, |
||||
capture_output=True, |
||||
text=True, |
||||
encoding="utf-8", |
||||
errors="replace", |
||||
) |
||||
changes = result.stdout.strip() |
||||
if changes: |
||||
for line in changes.splitlines()[:10]: |
||||
print(f" {line}") |
||||
total = len(changes.splitlines()) |
||||
if total > 10: |
||||
print(f" ... and {total - 10} more") |
||||
else: |
||||
print(" (no changes)") |
||||
|
||||
print() |
||||
return 0 |
||||
|
||||
|
||||
def cmd_registry(repo_root: Path) -> int: |
||||
"""Show agent registry.""" |
||||
registry_file = get_registry_file(repo_root) |
||||
|
||||
print(f"{Colors.BLUE}=== Agent Registry ==={Colors.NC}") |
||||
print() |
||||
print(f"File: {registry_file}") |
||||
print() |
||||
|
||||
if registry_file and registry_file.is_file(): |
||||
data = read_json(registry_file) |
||||
if data: |
||||
print(json.dumps(data, indent=2)) |
||||
else: |
||||
print("(registry not found)") |
||||
|
||||
return 0 |
||||
@ -0,0 +1,225 @@
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3 |
||||
""" |
||||
Multi-Agent Pipeline: Process monitoring and log parsing. |
||||
|
||||
Provides: |
||||
tail_follow - Follow a file like 'tail -f' |
||||
get_last_tool - Get last tool call from agent log |
||||
get_last_message - Get last assistant text from agent log |
||||
cmd_watch - Watch agent log in real-time |
||||
cmd_log - Show recent log entries |
||||
""" |
||||
|
||||
from __future__ import annotations |
||||
|
||||
import json |
||||
import time |
||||
from pathlib import Path |
||||
|
||||
from common.log import Colors |
||||
|
||||
from .status_display import find_agent |
||||
|
||||
|
||||
# ============================================================================= |
||||
# Log Parsing |
||||
# ============================================================================= |
||||
|
||||
def tail_follow(file_path: Path) -> None: |
||||
"""Follow a file like 'tail -f', cross-platform compatible.""" |
||||
with open(file_path, "r", encoding="utf-8", errors="replace") as f: |
||||
# Seek to end of file |
||||
f.seek(0, 2) |
||||
|
||||
while True: |
||||
line = f.readline() |
||||
if line: |
||||
print(line, end="", flush=True) |
||||
else: |
||||
time.sleep(0.1) |
||||
|
||||
|
||||
def get_last_tool(log_file: Path, platform: str = "claude") -> str | None: |
||||
"""Get the last tool call from agent log. |
||||
|
||||
Supports both Claude Code and OpenCode log formats. |
||||
|
||||
Claude Code format: |
||||
{"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Read"}]}} |
||||
|
||||
OpenCode format: |
||||
{"type": "tool_use", "tool": "bash", "state": {"status": "completed"}} |
||||
""" |
||||
if not log_file.is_file(): |
||||
return None |
||||
|
||||
try: |
||||
lines = log_file.read_text(encoding="utf-8").splitlines() |
||||
for line in reversed(lines[-100:]): |
||||
try: |
||||
data = json.loads(line) |
||||
|
||||
if platform == "opencode": |
||||
# OpenCode format: {"type": "tool_use", "tool": "bash", ...} |
||||
if data.get("type") == "tool_use": |
||||
return data.get("tool") |
||||
else: |
||||
# Claude Code format: {"type": "assistant", "message": {"content": [...]}} |
||||
if data.get("type") == "assistant": |
||||
content = data.get("message", {}).get("content", []) |
||||
for item in content: |
||||
if item.get("type") == "tool_use": |
||||
return item.get("name") |
||||
except json.JSONDecodeError: |
||||
continue |
||||
except Exception: |
||||
pass |
||||
return None |
||||
|
||||
|
||||
def get_last_message(log_file: Path, max_len: int = 100, platform: str = "claude") -> str | None: |
||||
"""Get the last assistant text from agent log. |
||||
|
||||
Supports both Claude Code and OpenCode log formats. |
||||
|
||||
Claude Code format: |
||||
{"type": "assistant", "message": {"content": [{"type": "text", "text": "..."}]}} |
||||
|
||||
OpenCode format: |
||||
{"type": "text", "text": "..."} |
||||
""" |
||||
if not log_file.is_file(): |
||||
return None |
||||
|
||||
try: |
||||
lines = log_file.read_text(encoding="utf-8").splitlines() |
||||
for line in reversed(lines[-100:]): |
||||
try: |
||||
data = json.loads(line) |
||||
|
||||
if platform == "opencode": |
||||
# OpenCode format: {"type": "text", "text": "..."} |
||||
if data.get("type") == "text": |
||||
text = data.get("text", "") |
||||
if text: |
||||
return text[:max_len] |
||||
else: |
||||
# Claude Code format: {"type": "assistant", "message": {"content": [...]}} |
||||
if data.get("type") == "assistant": |
||||
content = data.get("message", {}).get("content", []) |
||||
for item in content: |
||||
if item.get("type") == "text": |
||||
text = item.get("text", "") |
||||
if text: |
||||
return text[:max_len] |
||||
except json.JSONDecodeError: |
||||
continue |
||||
except Exception: |
||||
pass |
||||
return None |
||||
|
||||
|
||||
# ============================================================================= |
||||
# Commands |
||||
# ============================================================================= |
||||
|
||||
def cmd_watch(target: str, repo_root: Path) -> int: |
||||
"""Watch agent log in real-time.""" |
||||
agent = find_agent(target, repo_root) |
||||
if not agent: |
||||
print(f"Agent not found: {target}") |
||||
return 1 |
||||
|
||||
worktree = agent.get("worktree_path", "") |
||||
log_file = Path(worktree) / ".agent-log" |
||||
|
||||
if not log_file.is_file(): |
||||
print(f"Log file not found: {log_file}") |
||||
return 1 |
||||
|
||||
print(f"{Colors.BLUE}Watching:{Colors.NC} {log_file}") |
||||
print(f"{Colors.DIM}Press Ctrl+C to stop{Colors.NC}") |
||||
print() |
||||
|
||||
try: |
||||
tail_follow(log_file) |
||||
except KeyboardInterrupt: |
||||
print() # Clean newline after Ctrl+C |
||||
return 0 |
||||
|
||||
|
||||
def cmd_log(target: str, repo_root: Path) -> int: |
||||
"""Show recent log entries.""" |
||||
agent = find_agent(target, repo_root) |
||||
if not agent: |
||||
print(f"Agent not found: {target}") |
||||
return 1 |
||||
|
||||
worktree = agent.get("worktree_path", "") |
||||
platform = agent.get("platform", "claude") |
||||
log_file = Path(worktree) / ".agent-log" |
||||
|
||||
if not log_file.is_file(): |
||||
print(f"Log file not found: {log_file}") |
||||
return 1 |
||||
|
||||
print(f"{Colors.BLUE}=== Recent Log: {target} ==={Colors.NC}") |
||||
print(f"{Colors.DIM}Platform: {platform}{Colors.NC}") |
||||
print() |
||||
|
||||
lines = log_file.read_text(encoding="utf-8").splitlines() |
||||
for line in lines[-50:]: |
||||
try: |
||||
data = json.loads(line) |
||||
msg_type = data.get("type", "") |
||||
|
||||
if platform == "opencode": |
||||
# OpenCode format |
||||
if msg_type == "text": |
||||
text = data.get("text", "") |
||||
if text: |
||||
display = text[:300] |
||||
if len(text) > 300: |
||||
display += "..." |
||||
print(f"{Colors.BLUE}[TEXT]{Colors.NC} {display}") |
||||
elif msg_type == "tool_use": |
||||
tool_name = data.get("tool", "unknown") |
||||
status = data.get("state", {}).get("status", "") |
||||
print(f"{Colors.YELLOW}[TOOL]{Colors.NC} {tool_name} ({status})") |
||||
elif msg_type == "step_start": |
||||
print(f"{Colors.CYAN}[STEP]{Colors.NC} Start") |
||||
elif msg_type == "step_finish": |
||||
reason = data.get("reason", "") |
||||
print(f"{Colors.CYAN}[STEP]{Colors.NC} Finish ({reason})") |
||||
elif msg_type == "error": |
||||
error_msg = data.get("message", "") |
||||
print(f"{Colors.RED}[ERROR]{Colors.NC} {error_msg}") |
||||
else: |
||||
# Claude Code format |
||||
if msg_type == "system": |
||||
subtype = data.get("subtype", "") |
||||
print(f"{Colors.CYAN}[SYSTEM]{Colors.NC} {subtype}") |
||||
elif msg_type == "user": |
||||
content = data.get("message", {}).get("content", "") |
||||
if content: |
||||
print(f"{Colors.GREEN}[USER]{Colors.NC} {content[:200]}") |
||||
elif msg_type == "assistant": |
||||
content = data.get("message", {}).get("content", []) |
||||
if content: |
||||
item = content[0] |
||||
text = item.get("text") |
||||
tool = item.get("name") |
||||
if text: |
||||
display = text[:300] |
||||
if len(text) > 300: |
||||
display += "..." |
||||
print(f"{Colors.BLUE}[ASSISTANT]{Colors.NC} {display}") |
||||
elif tool: |
||||
print(f"{Colors.YELLOW}[TOOL]{Colors.NC} {tool}") |
||||
elif msg_type == "result": |
||||
tool_name = data.get("tool", "unknown") |
||||
print(f"{Colors.DIM}[RESULT]{Colors.NC} {tool_name} completed") |
||||
except json.JSONDecodeError: |
||||
continue |
||||
|
||||
return 0 |
||||
@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
|
||||
# Migration Task: Upgrade to v0.4.0-beta.8 |
||||
|
||||
**Created**: 2026-04-07 |
||||
**From Version**: 0.3.10 |
||||
**To Version**: 0.4.0-beta.8 |
||||
**Assignee**: name=hechang27-sprt |
||||
initialized_at=2026-03-24T18:13:15.726010 |
||||
|
||||
## Status |
||||
|
||||
- [ ] Review migration guide |
||||
- [ ] Update custom files |
||||
- [ ] Run `trellis update --migrate` |
||||
- [ ] Test workflows |
||||
|
||||
--- |
||||
|
||||
## v0.4.0-beta.1 Migration Guide |
||||
|
||||
## Command Consolidation |
||||
|
||||
The following commands have been merged: |
||||
|
||||
| Old (removed) | New (replacement) | |
||||
|---------------|-------------------| |
||||
| `before-backend-dev` | `before-dev` | |
||||
| `before-frontend-dev` | `before-dev` | |
||||
| `check-backend` | `check` | |
||||
| `check-frontend` | `check` | |
||||
|
||||
### If you never customized these commands |
||||
|
||||
No action needed. `trellis update` will: |
||||
1. Auto-delete the old files (hash-verified, only if unmodified) |
||||
2. Write the new unified `before-dev` and `check` commands |
||||
|
||||
### If you customized the old commands |
||||
|
||||
The old files will NOT be deleted (hash mismatch protection). You need to: |
||||
1. Open the new `before-dev` / `check` command file |
||||
2. Merge your customizations from the old backend/frontend variants into the unified file |
||||
3. Delete the old files manually once merged |
||||
|
||||
### Affected paths per platform |
||||
|
||||
- **Claude**: `.claude/commands/trellis/` |
||||
- **Cursor**: `.cursor/commands/` |
||||
- **iFlow**: `.iflow/commands/trellis/` |
||||
- **OpenCode**: `.opencode/commands/trellis/` |
||||
- **Gemini**: `.gemini/commands/trellis/` |
||||
- **Kilo**: `.kilocode/workflows/` |
||||
- **Codex**: `.agents/skills/` |
||||
- **Kiro**: `.kiro/skills/` |
||||
- **Qoder**: `.qoder/skills/` |
||||
|
||||
## Python Scripts Refactoring |
||||
|
||||
All `.trellis/scripts/` files have been restructured internally. **No action needed** — all entry paths (`python3 .trellis/scripts/task.py`, etc.) remain unchanged. `trellis update` will sync the new scripts automatically. |
||||
|
||||
### AI Assistant Instructions |
||||
|
||||
When helping with this migration: |
||||
|
||||
When helping users migrate to v0.4.0-beta.1: |
||||
|
||||
1. **Check for customized old commands**: Run `git diff` or compare file hashes to see if the user modified before-backend-dev, before-frontend-dev, check-backend, or check-frontend files. |
||||
2. **If customized**: Help merge their customizations into the new unified `before-dev` and `check` files. The new files use `python3 ./.trellis/scripts/get_context.py --mode packages` to auto-detect which specs to load, replacing the hardcoded backend/frontend split. |
||||
3. **If not customized**: Just run `trellis update` — safe-file-delete will handle cleanup automatically. |
||||
4. **Python scripts**: No user action needed. The refactoring preserves all entry paths. If the user has custom scripts that import from `.trellis/scripts/common/`, they may need to update imports (e.g., `from common.io import read_json` instead of inline `_read_json_file`). |
||||
|
||||
@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
|
||||
{ |
||||
"title": "Migrate to v0.4.0-beta.8", |
||||
"description": "Breaking change migration from v0.3.10 to v0.4.0-beta.8", |
||||
"status": "planning", |
||||
"dev_type": null, |
||||
"scope": "migration", |
||||
"priority": "P1", |
||||
"creator": "trellis-update", |
||||
"assignee": "name=hechang27-sprt\ninitialized_at=2026-03-24T18:13:15.726010", |
||||
"createdAt": "2026-04-07", |
||||
"completedAt": null, |
||||
"branch": null, |
||||
"base_branch": null, |
||||
"worktree_path": null, |
||||
"current_phase": 0, |
||||
"next_action": [ |
||||
{ |
||||
"phase": 1, |
||||
"action": "review-guide" |
||||
}, |
||||
{ |
||||
"phase": 2, |
||||
"action": "update-files" |
||||
}, |
||||
{ |
||||
"phase": 3, |
||||
"action": "run-migrate" |
||||
}, |
||||
{ |
||||
"phase": 4, |
||||
"action": "test" |
||||
} |
||||
], |
||||
"commit": null, |
||||
"pr_url": null, |
||||
"subtasks": [], |
||||
"children": [], |
||||
"parent": null, |
||||
"meta": {} |
||||
} |
||||
Loading…
Reference in new issue