diff --git a/.opencode/agents/check.md b/.claude/agents/trellis-check.md similarity index 51% rename from .opencode/agents/check.md rename to .claude/agents/trellis-check.md index c7e7fed..0c0ffbc 100644 --- a/.opencode/agents/check.md +++ b/.claude/agents/trellis-check.md @@ -1,38 +1,13 @@ --- +name: trellis-check description: | Code quality check expert. Reviews code changes against specs and self-fixes issues. -mode: subagent -permission: - read: allow - write: allow - edit: allow - bash: allow - glob: allow - grep: allow - mcp__exa__*: allow +tools: Read, Write, Edit, Bash, Glob, Grep, mcp__exa__web_search_exa, mcp__exa__get_code_context_exa --- # Check Agent You are the Check Agent in the Trellis workflow. -## Context Self-Loading - -**If you see "# Check Agent Task" header with pre-loaded context above, skip this section.** - -Otherwise, load context yourself: - -1. Read `.trellis/.current-task` → get task directory (e.g., `.trellis/tasks/xxx`) -2. Read `{task_dir}/check.jsonl` (or `spec.jsonl` as fallback) -3. For each entry in JSONL: - - If `path` is a file → Read it - - If `path` is a directory → Read all `.md` files in it -4. Read `{task_dir}/prd.md` for requirements understanding -5. Read `.opencode/commands/trellis/finish-work.md` for checklist - -Then proceed with the workflow below using the loaded context. - ---- - ## Context Before checking, read: @@ -89,32 +64,6 @@ If failed, fix issues and re-run. --- -## Completion Markers (Ralph Loop) - -**CRITICAL**: You are in a loop controlled by the Ralph Loop system. -The loop will NOT stop until you output ALL required completion markers. - -Completion markers are generated from `check.jsonl` in the task directory. -Each entry's `reason` field becomes a marker: `{REASON}_FINISH` - -For example, if check.jsonl contains: -```json -{"file": "...", "reason": "TypeCheck"} -{"file": "...", "reason": "Lint"} -{"file": "...", "reason": "CodeReview"} -``` - -You MUST output these markers when each check passes: -- `TYPECHECK_FINISH` - After typecheck passes -- `LINT_FINISH` - After lint passes -- `CODEREVIEW_FINISH` - After code review passes - -If check.jsonl doesn't exist or has no reasons, output: `ALL_CHECKS_FINISH` - -**The loop will block you from stopping until all markers are present in your output.** - ---- - ## Report Format ```markdown @@ -136,11 +85,10 @@ If check.jsonl doesn't exist or has no reasons, output: `ALL_CHECKS_FINISH` ### Verification Results -- TypeCheck: Passed TYPECHECK_FINISH -- Lint: Passed LINT_FINISH +- TypeCheck: Passed +- Lint: Passed ### Summary Checked X files, found Y issues, all fixed. -ALL_CHECKS_FINISH ``` diff --git a/.opencode/agents/implement.md b/.claude/agents/trellis-implement.md similarity index 68% rename from .opencode/agents/implement.md rename to .claude/agents/trellis-implement.md index d481d66..02d8138 100644 --- a/.opencode/agents/implement.md +++ b/.claude/agents/trellis-implement.md @@ -1,38 +1,13 @@ --- +name: trellis-implement description: | Code implementation expert. Understands specs and requirements, then implements features. No git commit allowed. -mode: subagent -permission: - read: allow - write: allow - edit: allow - bash: allow - glob: allow - grep: allow - mcp__exa__*: allow +tools: Read, Write, Edit, Bash, Glob, Grep, mcp__exa__web_search_exa, mcp__exa__get_code_context_exa --- # Implement Agent You are the Implement Agent in the Trellis workflow. -## Context Self-Loading - -**If you see "# Implement Agent Task" header with pre-loaded context above, skip this section.** - -Otherwise, load context yourself: - -1. Read `.trellis/.current-task` → get task directory (e.g., `.trellis/tasks/xxx`) -2. Read `{task_dir}/implement.jsonl` (or `spec.jsonl` as fallback) -3. For each entry in JSONL: - - If `path` is a file → Read it - - If `path` is a directory → Read all `.md` files in it -4. Read `{task_dir}/prd.md` for requirements -5. Read `{task_dir}/info.md` for technical design (if exists) - -Then proceed with the workflow below using the loaded context. - ---- - ## Context Before implementing, read: @@ -65,9 +40,8 @@ Before implementing, read: Read relevant specs based on task type: -- Backend: `.trellis/spec/backend/` -- Frontend: `.trellis/spec/frontend/` -- Guides: `.trellis/spec/guides/` +- Spec layers: `.trellis/spec///` +- Shared guides: `.trellis/spec/guides/` ### 2. Understand Requirements diff --git a/.claude/agents/trellis-research.md b/.claude/agents/trellis-research.md new file mode 100644 index 0000000..ce9d5f6 --- /dev/null +++ b/.claude/agents/trellis-research.md @@ -0,0 +1,137 @@ +--- +name: trellis-research +description: | + Code and tech search expert. Finds files, patterns, and tech solutions, and PERSISTS every finding to the current task's research/ directory. No code modifications outside that directory. +tools: Read, Write, Glob, Grep, Bash, mcp__exa__web_search_exa, mcp__exa__get_code_context_exa, Skill, mcp__chrome-devtools__* +--- +# Research Agent + +You are the Research Agent in the Trellis workflow. + +## Core Principle + +**You do one thing: find, explain, and PERSIST information.** + +Conversations get compacted; files don't. Every research output MUST end up as a file under `{TASK_DIR}/research/`. Returning findings only through the chat reply is a failure — the caller cannot read them next session. + +--- + +## Core Responsibilities + +1. **Internal Search** — locate files/components, understand code logic, discover patterns (Glob, Grep, Read) +2. **External Search** — library docs, API references, best practices (web search) +3. **Persist** — write each research topic to `{TASK_DIR}/research/.md` +4. **Report** — return file paths + one-line summaries to the main agent (not full content) + +--- + +## Workflow + +### Step 1: Resolve Current Task + +Run `python3 ./.trellis/scripts/task.py current --source` → active task path. If no active task is set, ask the user where to write output; do NOT guess. + +Ensure `{TASK_DIR}/research/` exists: + +```bash +mkdir -p /research +``` + +### Step 2: Understand Search Request + +Classify: internal / external / mixed. Determine scope (global / specific directory) and expected shape (file list / pattern notes / tech comparison). + +### Step 3: Execute Search + +Run independent searches in parallel (Glob + Grep + web) for efficiency. + +### Step 4: Persist Each Topic + +For each distinct research topic, Write a markdown file at `{TASK_DIR}/research/.md`. Use the File Format below. + +### Step 5: Report to Main Agent + +Reply with ONLY: + +- List of files written (paths relative to repo root) +- One-line summary per file +- Any critical caveats that the main agent needs to know right now + +Do NOT paste full research content into the reply. The files are the contract. + +--- + +## Scope Limits (Strict) + +### Write ALLOWED + +- `{TASK_DIR}/research/*.md` — your own output +- Creating `{TASK_DIR}/research/` if it doesn't exist (via `mkdir -p`) + +### Write FORBIDDEN + +- Code files (`src/`, `lib/`, …) +- Spec files (`.trellis/spec/`) — main agent should use `update-spec` skill instead +- `.trellis/scripts/`, `.trellis/workflow.md`, platform config (`.claude/`, `.cursor/`, etc.) +- Other task directories +- Any git operation (commit / push / branch / merge) + +If the user asks you to edit code, decline and suggest spawning `implement` instead. + +--- + +## File Format + +Each `{TASK_DIR}/research/.md` should follow: + +```markdown +# Research: + +- **Query**: +- **Scope**: +- **Date**: + +## Findings + +### Files Found + +| File Path | Description | +|---|---| +| `src/services/xxx.ts` | Main implementation | +| `src/types/xxx.ts` | Type definitions | + +### Code Patterns + + + +### External References + +- [Library X docs](url) — + +### Related Specs + +- `.trellis/spec/xxx.md` — + +## Caveats / Not Found + + +``` + +--- + +## Guidelines + +### DO + +- Provide specific file paths and line numbers +- Quote actual code snippets +- Persist every topic to its own file +- Return file paths in your reply, not the full content +- Mark "not found" explicitly when searches come up empty + +### DON'T + +- Don't write code or modify files outside `{TASK_DIR}/research/` +- Don't guess uncertain info +- Don't paste full research text into the reply (files are the deliverable) +- Don't propose improvements or critique implementation (that's not your role) diff --git a/.claude/commands/trellis/continue.md b/.claude/commands/trellis/continue.md new file mode 100644 index 0000000..5261d97 --- /dev/null +++ b/.claude/commands/trellis/continue.md @@ -0,0 +1,55 @@ +# Continue Current Task + +Resume work on the current task — pick up at the right phase/step in `.trellis/workflow.md`. + +--- + +## Step 1: Load Current Context + +```bash +python3 ./.trellis/scripts/get_context.py +``` + +Confirms: current task, git state, recent commits. + +## Step 2: Load the Phase Index + +```bash +python3 ./.trellis/scripts/get_context.py --mode phase +``` + +Shows the Phase Index (Plan / Execute / Finish) with routing + skill mapping. + +## Step 3: Decide Where You Are + +`get_context.py` shows the active task's `status` field. Route by `status` + artifact presence: + +- `status=planning` + no `prd.md` → **1.1** (load `trellis-brainstorm`) +- `status=planning` + `prd.md` exists + `implement.jsonl` not curated (only the seed `_example` row) → **1.3** +- `status=planning` + `prd.md` + curated `implement.jsonl` → **1.4** (run `task.py start` to enter Phase 2) +- `status=in_progress` + implementation not started → **2.1** +- `status=in_progress` + implementation done, not yet checked → **2.2** +- `status=in_progress` + check passed → **3.1** +- `status=completed` (rare; usually archived immediately) → archive flow + +Phase rules (full detail in `.trellis/workflow.md`): + +1. Run steps **in order** within a phase — `[required]` steps must not be skipped +2. `[once]` steps are already done if the output exists (e.g., `prd.md` for 1.1; `implement.jsonl` with curated entries for 1.3) — skip them +3. You may go back to an earlier phase if discoveries require it + +## Step 4: Load the Specific Step + +Once you know which step to resume at: + +```bash +python3 ./.trellis/scripts/get_context.py --mode phase --step --platform claude +``` + +Follow the loaded instructions. After each `[required]` step completes, move to the next. + +--- + +## Reference + +Full workflow, skill routing table, and the DO-NOT-skip table live in `.trellis/workflow.md`. This command is only an entry point — the canonical guidance is there. diff --git a/.claude/commands/trellis/finish-work.md b/.claude/commands/trellis/finish-work.md new file mode 100644 index 0000000..ab751c6 --- /dev/null +++ b/.claude/commands/trellis/finish-work.md @@ -0,0 +1,66 @@ +# Finish Work + +Wrap up the current session: archive the active task (and any other completed-but-unarchived tasks the user wants to clean up) and record the session journal. Code commits are NOT done here — those happen in workflow Phase 3.4 before you invoke this command. + +## Step 1: Survey current state + +```bash +python3 ./.trellis/scripts/get_context.py --mode record +``` + +This prints: + +- **My active tasks** — review whether any besides the current one are actually done (code merged, AC met) and should be archived this round. +- **Git status** — quick visual on what's dirty. +- **Recent commits** — you'll need their hashes in Step 4 for `--commit`. + +If `--mode record` surfaces other completed tasks not tied to the current session, surface them to the user with a one-shot confirmation: "These N tasks look done — archive them too in this round? [y/N]". Default is no; the current active task is always archived in Step 3 regardless. + +## Step 2: Sanity check — classify dirty paths + +Run: + +```bash +git status --porcelain +``` + +Filter out paths under `.trellis/workspace/` and `.trellis/tasks/` — those are managed by `add_session.py` and `task.py archive` auto-commits and will appear dirty as part of this skill's own work. + +For each remaining dirty path, decide whether it belongs to **the current task** or to **other parallel work** (e.g., another terminal window editing the same repo). Heuristics: + +- Paths referenced in the current task's `prd.md` / `implement.jsonl` / `check.jsonl` → current task +- Paths in code areas matching the task's stated scope, or that you remember editing this session → current task +- Paths in unrelated areas you have no recollection of touching this session → other parallel work + +Then route: + +- **Any remaining path looks like current-task work** — bail out with: + > "Working tree has uncommitted code changes from this task: ``. Return to workflow Phase 3.4 to commit them before running `/trellis:finish-work`." + + Do NOT run `git commit` here. Do NOT prompt the user to commit. The user goes back to Phase 3.4 and the AI drives the batched commit there. +- **All remaining paths look unrelated** (other parallel-window work) — report them once and continue to Step 3: + > "FYI, dirty files outside this task's scope — leaving them for the other window: ``." +- **Genuinely unsure** — ask the user once: "Are `` this task's work I forgot to commit, or another window's? (commit / ignore)" — then route per their answer. + +## Step 3: Archive task(s) + +```bash +python3 ./.trellis/scripts/task.py archive +``` + +At minimum: the current active task (if any). Plus any extra tasks the user confirmed in Step 1. Each archive produces a `chore(task): archive ...` commit via the script's auto-commit. + +If there is no active task and the user did not confirm any cleanup archives, skip this step. + +## Step 4: Record session journal + +```bash +python3 ./.trellis/scripts/add_session.py \ + --title "Session Title" \ + --commit "hash1,hash2" \ + --summary "Brief summary" +``` + +Use the work-commit hashes produced in Phase 3.4 (visible in Step 1's `Recent commits` list, or via `git log --oneline`) for `--commit`. Do not include the archive commit hashes from Step 3. This produces a `chore: record journal` commit. + +Final git log order: `` → `chore(task): archive ...` (one or more) → `chore: record journal`. diff --git a/.claude/hooks/inject-subagent-context.py b/.claude/hooks/inject-subagent-context.py new file mode 100755 index 0000000..f6cd24e --- /dev/null +++ b/.claude/hooks/inject-subagent-context.py @@ -0,0 +1,746 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Multi-Platform Sub-Agent Context Injection Hook + +Injects task-specific context when sub-agents (implement, check, research) are spawned. + +Core Design Philosophy: +- Hook is responsible for injecting all context, subagent works autonomously with complete info +- Each agent has a dedicated jsonl file defining its context +- No resume needed, no segmentation, behavior controlled by code not prompt + +Trigger: PreToolUse (before Task tool call) + +Context Source: Trellis active task resolver points to task directory +- implement.jsonl - Implement agent dedicated context +- check.jsonl - Check agent dedicated context +- prd.md - Requirements document +- info.md - Technical design +- codex-review-output.txt - Code Review results +""" +from __future__ import annotations + +# IMPORTANT: Suppress all warnings FIRST +import warnings +warnings.filterwarnings("ignore") + +import json +import os +import sys +from pathlib import Path +from typing import Any + +# IMPORTANT: Force stdout to use UTF-8 on Windows +# This fixes UnicodeEncodeError when outputting non-ASCII characters +if sys.platform.startswith("win"): + import io as _io + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + elif hasattr(sys.stdout, "detach"): + sys.stdout = _io.TextIOWrapper(sys.stdout.detach(), encoding="utf-8", errors="replace") # type: ignore[union-attr] + + +# ============================================================================= +# Path Constants (change here to rename directories) +# ============================================================================= + +DIR_WORKFLOW = ".trellis" +DIR_SPEC = "spec" +FILE_TASK_JSON = "task.json" + +# ============================================================================= +# Subagent Constants (change here to rename subagent types) +# ============================================================================= + +AGENT_IMPLEMENT = "trellis-implement" +AGENT_CHECK = "trellis-check" +AGENT_RESEARCH = "trellis-research" + +# Agents that require a task directory +AGENTS_REQUIRE_TASK = (AGENT_IMPLEMENT, AGENT_CHECK) +# All supported agents +AGENTS_ALL = (AGENT_IMPLEMENT, AGENT_CHECK, AGENT_RESEARCH) + + +def find_repo_root(start_path: str) -> str | None: + """ + Find git repo root from start_path upwards + + Returns: + Repo root path, or None if not found + """ + current = Path(start_path).resolve() + while current != current.parent: + if (current / ".git").exists(): + return str(current) + current = current.parent + return None + + +def _detect_platform(input_data: dict) -> str | None: + if isinstance(input_data.get("cursor_version"), str): + return "cursor" + env_map = { + "CLAUDE_PROJECT_DIR": "claude", + "CURSOR_PROJECT_DIR": "cursor", + "CODEBUDDY_PROJECT_DIR": "codebuddy", + "FACTORY_PROJECT_DIR": "droid", + "GEMINI_PROJECT_DIR": "gemini", + "QODER_PROJECT_DIR": "qoder", + "KIRO_PROJECT_DIR": "kiro", + "COPILOT_PROJECT_DIR": "copilot", + } + for env_name, platform in env_map.items(): + if os.environ.get(env_name): + return platform + script_parts = set(Path(sys.argv[0]).parts) + if ".claude" in script_parts: + return "claude" + if ".cursor" in script_parts: + return "cursor" + if ".gemini" in script_parts: + return "gemini" + if ".qoder" in script_parts: + return "qoder" + if ".codebuddy" in script_parts: + return "codebuddy" + if ".factory" in script_parts: + return "droid" + if ".kiro" in script_parts: + return "kiro" + return None + + +def get_current_task(repo_root: str, input_data: dict) -> str | None: + """Resolve current task directory through the unified active task resolver.""" + scripts_dir = Path(repo_root) / DIR_WORKFLOW / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.active_task import resolve_active_task # type: ignore[import-not-found] + except Exception: + return None + + active = resolve_active_task( + Path(repo_root), + input_data, + platform=_detect_platform(input_data), + ) + return active.task_path + + +def read_file_content(base_path: str, file_path: str) -> str | None: + """Read file content, return None if file doesn't exist""" + full_path = os.path.join(base_path, file_path) + if os.path.exists(full_path) and os.path.isfile(full_path): + try: + with open(full_path, "r", encoding="utf-8") as f: + return f.read() + except Exception: + return None + return None + + +def read_directory_contents( + base_path: str, dir_path: str, max_files: int = 20 +) -> list[tuple[str, str]]: + """ + Read all .md files in a directory + + Args: + base_path: Base path (usually repo_root) + dir_path: Directory relative path + max_files: Max files to read (prevent huge directories) + + Returns: + [(file_path, content), ...] + """ + full_path = os.path.join(base_path, dir_path) + if not os.path.exists(full_path) or not os.path.isdir(full_path): + return [] + + results = [] + try: + # Only read .md files, sorted by filename + md_files = sorted( + [ + f + for f in os.listdir(full_path) + if f.endswith(".md") and os.path.isfile(os.path.join(full_path, f)) + ] + ) + + for filename in md_files[:max_files]: + file_full_path = os.path.join(full_path, filename) + relative_path = os.path.join(dir_path, filename) + try: + with open(file_full_path, "r", encoding="utf-8") as f: + content = f.read() + results.append((relative_path, content)) + except Exception: + continue + except Exception: + pass + + return results + + +def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[tuple[str, str]]: + """ + Read all file/directory contents referenced in jsonl file + + Schema: + {"file": "path/to/file.md", "reason": "..."} + {"file": "path/to/dir/", "type": "directory", "reason": "..."} + {"_example": "..."} # seed row — skipped (no `file` field) + + Rows without a ``file`` field (e.g. the self-describing seed line written + by ``task.py create`` before the agent has curated entries) are skipped + silently. If the resulting entry list is empty, a stderr warning is + emitted so the operator can debug missing context. + + Returns: + [(path, content), ...] + """ + full_path = os.path.join(base_path, jsonl_path) + if not os.path.exists(full_path): + print( + f"[inject-subagent-context] WARN: {jsonl_path} not found — " + f"sub-agent will receive only prd.md", + file=sys.stderr, + ) + return [] + + results = [] + saw_real_entry = False + try: + with open(full_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + item = json.loads(line) + file_path = item.get("file") or item.get("path") + entry_type = item.get("type", "file") + + if not file_path: + # Seed / comment row — skip silently + continue + + saw_real_entry = True + if entry_type == "directory": + # Read all .md files in directory + dir_contents = read_directory_contents(base_path, file_path) + results.extend(dir_contents) + else: + # Read single file + content = read_file_content(base_path, file_path) + if content: + results.append((file_path, content)) + except json.JSONDecodeError: + continue + except Exception: + pass + + if not saw_real_entry: + print( + f"[inject-subagent-context] WARN: {jsonl_path} has no curated " + f"entries (only seed / empty) — sub-agent will receive only " + f"prd.md. See workflow.md Phase 1.3 for curation guidance.", + file=sys.stderr, + ) + + return results + + + + +def get_agent_context(repo_root: str, task_dir: str, agent_type: str) -> str: + """ + Get context from {agent_type}.jsonl for the specified agent. + Only reads implement.jsonl or check.jsonl (the two JSONL files the task system creates). + """ + context_parts = [] + + agent_jsonl = f"{task_dir}/{agent_type}.jsonl" + for file_path, content in read_jsonl_entries(repo_root, agent_jsonl): + context_parts.append(f"=== {file_path} ===\n{content}") + + return "\n\n".join(context_parts) + + +def get_implement_context(repo_root: str, task_dir: str) -> str: + """ + Complete context for Implement Agent + + Read order: + 1. All files in implement.jsonl (dev specs) + 2. prd.md (requirements) + 3. info.md (technical design) + """ + context_parts = [] + + # 1. Read implement.jsonl + base_context = get_agent_context(repo_root, task_dir, "implement") + if base_context: + context_parts.append(base_context) + + # 2. Requirements document + prd_content = read_file_content(repo_root, f"{task_dir}/prd.md") + if prd_content: + context_parts.append(f"=== {task_dir}/prd.md (Requirements) ===\n{prd_content}") + + # 3. Technical design + info_content = read_file_content(repo_root, f"{task_dir}/info.md") + if info_content: + context_parts.append( + f"=== {task_dir}/info.md (Technical Design) ===\n{info_content}" + ) + + return "\n\n".join(context_parts) + + +def get_check_context(repo_root: str, task_dir: str) -> str: + """ + Context for Check Agent: check.jsonl + prd.md + """ + context_parts = [] + + for file_path, content in read_jsonl_entries(repo_root, f"{task_dir}/check.jsonl"): + context_parts.append(f"=== {file_path} ===\n{content}") + + prd_content = read_file_content(repo_root, f"{task_dir}/prd.md") + if prd_content: + context_parts.append(f"=== {task_dir}/prd.md (Requirements) ===\n{prd_content}") + + return "\n\n".join(context_parts) + + +def get_finish_context(repo_root: str, task_dir: str) -> str: + """ + Context for Finish phase: reuses check.jsonl + prd.md + (Finish is a final check, same context source.) + """ + return get_check_context(repo_root, task_dir) + + + +def build_implement_prompt(original_prompt: str, context: str) -> str: + """Build complete prompt for Implement""" + return f"""# Implement Agent Task + +You are the Implement Agent in the Multi-Agent Pipeline. + +## Your Context + +All the information you need has been prepared for you: + +{context} + +--- + +## Your Task + +{original_prompt} + +--- + +## Workflow + +1. **Understand specs** - All dev specs are injected above, understand them +2. **Understand requirements** - Read requirements document and technical design +3. **Implement feature** - Implement following specs and design +4. **Self-check** - Ensure code quality against check specs + +## Important Constraints + +- Do NOT execute git commit, only code modifications +- Follow all dev specs injected above +- Report list of modified/created files when done""" + + +def build_check_prompt(original_prompt: str, context: str) -> str: + """Build complete prompt for Check""" + return f"""# Check Agent Task + +You are the Check Agent in the Multi-Agent Pipeline (code and cross-layer checker). + +## Your Context + +All check specs and dev specs you need: + +{context} + +--- + +## Your Task + +{original_prompt} + +--- + +## Workflow + +1. **Get changes** - Run `git diff --name-only` and `git diff` to get code changes +2. **Check against specs** - Check item by item against specs above +3. **Self-fix** - Fix issues directly, don't just report +4. **Run verification** - Run project's lint and typecheck commands + +## Important Constraints + +- Fix issues yourself, don't just report +- Must execute complete checklist in check specs +- Pay special attention to impact radius analysis (L1-L5)""" + + +def build_finish_prompt(original_prompt: str, context: str) -> str: + """Build complete prompt for Finish (final check before PR)""" + return f"""# Finish Agent Task + +You are performing the final check before creating a PR. + +## Your Context + +Finish checklist and requirements: + +{context} + +--- + +## Your Task + +{original_prompt} + +--- + +## Workflow + +1. **Review changes** - Run `git diff --name-only` to see all changed files +2. **Verify requirements** - Check each requirement in prd.md is implemented +3. **Spec sync** - Analyze whether changes introduce new patterns, contracts, or conventions + - If new pattern/convention found: read target spec file → update it → update index.md if needed + - If infra/cross-layer change: follow the 7-section mandatory template from update-spec.md + - If pure code fix with no new patterns: skip this step +4. **Run final checks** - Execute lint and typecheck +5. **Confirm ready** - Ensure code is ready for PR + +## Important Constraints + +- You MAY update spec files when gaps are detected (use update-spec.md as guide) +- MUST read the target spec file BEFORE editing (avoid duplicating existing content) +- Do NOT update specs for trivial changes (typos, formatting, obvious fixes) +- If critical CODE issues found, report them clearly (fix specs, not code) +- Verify all acceptance criteria in prd.md are met""" + + + +def get_research_context(repo_root: str, task_dir: str | None) -> str: + """ + Context for Research Agent — project structure overview for spec directories. + + `task_dir` kept for signature parity with get_implement_context / get_check_context + so the dispatcher can call them uniformly. + """ + _ = task_dir + context_parts = [] + + # 1. Project structure overview (dynamically discover spec directories) + spec_path = f"{DIR_WORKFLOW}/{DIR_SPEC}" + spec_root = Path(repo_root) / DIR_WORKFLOW / DIR_SPEC + + # Build spec tree dynamically + tree_lines = [f"{spec_path}/"] + if spec_root.is_dir(): + pkg_dirs = sorted(d for d in spec_root.iterdir() if d.is_dir()) + for i, pkg_dir in enumerate(pkg_dirs): + is_last = i == len(pkg_dirs) - 1 + prefix = "└── " if is_last else "├── " + layers = sorted(d.name for d in pkg_dir.iterdir() if d.is_dir()) + layer_info = f" ({', '.join(layers)})" if layers else "" + tree_lines.append(f"{prefix}{pkg_dir.name}/{layer_info}") + + spec_tree = "\n".join(tree_lines) + + project_structure = f"""## Project Spec Directory Structure + +``` +{spec_tree} +``` + +To get structured package info, run: `python3 ./{DIR_WORKFLOW}/scripts/get_context.py --mode packages` + +## Search Tips + +- Spec files: `{spec_path}/**/*.md` +- Code search: Use Glob and Grep tools +- Tech solutions: Use mcp__exa__web_search_exa or mcp__exa__get_code_context_exa""" + + context_parts.append(project_structure) + + return "\n\n".join(context_parts) + + +def build_research_prompt(original_prompt: str, context: str) -> str: + """Build complete prompt for Research""" + return f"""# Research Agent Task + +You are the Research Agent in the Multi-Agent Pipeline (search researcher). + +## Core Principle + +**You do one thing: find and explain information.** + +You are a documenter, not a reviewer. + +## Project Info + +{context} + +--- + +## Your Task + +{original_prompt} + +--- + +## Workflow + +1. **Understand query** - Determine search type (internal/external) and scope +2. **Plan search** - List search steps for complex queries +3. **Execute search** - Execute multiple independent searches in parallel +4. **Organize results** - Output structured report + +## Search Tools + +| Tool | Purpose | +|------|---------| +| Glob | Search by filename pattern | +| Grep | Search by content | +| Read | Read file content | +| mcp__exa__web_search_exa | External web search | +| mcp__exa__get_code_context_exa | External code/doc search | + +## Strict Boundaries + +**Only allowed**: Describe what exists, where it is, how it works + +**Forbidden** (unless explicitly asked): +- Suggest improvements +- Criticize implementation +- Recommend refactoring +- Modify any files + +## Report Format + +Provide structured search results including: +- List of files found (with paths) +- Code pattern analysis (if applicable) +- Related spec documents +- External references (if any)""" + + +def _string_value(value: Any) -> str: + if isinstance(value, str): + stripped = value.strip() + return stripped + return "" + + +def _extract_subagent_name(value: Any) -> str: + """Extract a sub-agent name from common platform encodings. + + Cursor's native Task args encode custom sub-agents as a protobuf oneof, + which can appear in hook JSON as either ``{"custom": {"name": "..."}}`` + or ``{"type": {"case": "custom", "value": {"name": "..."}}}``. + """ + direct = _string_value(value) + if direct: + return direct + + if not isinstance(value, dict): + return "" + + for key in ("name", "subagent_type_name", "subagentTypeName"): + direct = _string_value(value.get(key)) + if direct: + return direct + + custom = value.get("custom") + if isinstance(custom, dict): + custom_name = _string_value(custom.get("name")) + if custom_name: + return custom_name + + oneof = value.get("type") + if isinstance(oneof, dict): + case_name = _string_value(oneof.get("case")) + if case_name == "custom": + nested_value = oneof.get("value") + if isinstance(nested_value, dict): + custom_name = _string_value(nested_value.get("name")) + if custom_name: + return custom_name + if case_name: + return case_name + + case_name = _string_value(value.get("case")) + if case_name == "custom": + nested_value = value.get("value") + if isinstance(nested_value, dict): + custom_name = _string_value(nested_value.get("name")) + if custom_name: + return custom_name + if case_name: + return case_name + + for agent_name in AGENTS_ALL: + if agent_name in value: + return agent_name + + return "" + + +def _extract_subagent_type(tool_input: dict) -> str: + for key in ( + "subagent_type", + "subagentType", + "subagent_type_name", + "subagentTypeName", + "agent_type", + "agentType", + "name", + ): + agent_name = _extract_subagent_name(tool_input.get(key)) + if agent_name: + return agent_name + return "" + + +def _parse_hook_input(input_data: dict) -> tuple[str, str, dict]: + """Parse hook input across different platform formats. + + Returns (subagent_type, original_prompt, tool_input). + Handles: + - Claude Code / Qoder / CodeBuddy / Droid: tool_name=Task|Agent, tool_input.subagent_type + - Cursor: tool_name=Task|Subagent, tool_input.subagent_type + - Copilot CLI: toolName=task (camelCase key, lowercase value) + - Gemini CLI: tool_name IS the agent name (BeforeTool matcher already filtered) + - Kiro: agentSpawn hook, agent_name field at top level + """ + tool_input = input_data.get("tool_input", {}) + + # Standard format: Task/Agent tool with subagent_type + tool_name = input_data.get("tool_name", "") or input_data.get("toolName", "") + if tool_name.lower() in ("task", "agent", "subagent"): + return ( + _extract_subagent_type(tool_input), + tool_input.get("prompt", ""), + tool_input, + ) + + # Kiro: agentSpawn hook passes agent_name at top level + agent_name = input_data.get("agent_name", "") + if agent_name: + return agent_name, tool_input.get("prompt", input_data.get("prompt", "")), tool_input + + # Gemini CLI: BeforeTool where tool_name IS the agent name + # (matcher already ensured it's one of our agents) + if tool_name in AGENTS_ALL: + return tool_name, tool_input.get("prompt", ""), tool_input + + # Copilot CLI: toolName field (camelCase), value might be the agent name + tool_name_camel = input_data.get("toolName", "") + if tool_name_camel in AGENTS_ALL: + return tool_name_camel, input_data.get("toolArgs", ""), tool_input + + return "", "", tool_input + + +def main(): + if os.environ.get("TRELLIS_HOOKS") == "0" or os.environ.get("TRELLIS_DISABLE_HOOKS") == "1": + sys.exit(0) + + try: + input_data = json.load(sys.stdin) + except json.JSONDecodeError: + sys.exit(0) + + subagent_type, original_prompt, tool_input = _parse_hook_input(input_data) + cwd = input_data.get("cwd", os.getcwd()) + + # Only handle subagent types we care about + if subagent_type not in AGENTS_ALL: + sys.exit(0) + + # Find repo root + repo_root = find_repo_root(cwd) + if not repo_root: + sys.exit(0) + + # Get current task directory (research doesn't require it) + task_dir = get_current_task(repo_root, input_data) + + # implement/check need task directory + if subagent_type in AGENTS_REQUIRE_TASK: + if not task_dir: + sys.exit(0) + # Check if task directory exists + task_dir_full = os.path.join(repo_root, task_dir) + if not os.path.exists(task_dir_full): + sys.exit(0) + + # Check for [finish] marker in prompt (check agent with finish context) + is_finish_phase = "[finish]" in original_prompt.lower() + + # Get context and build prompt based on subagent type + if subagent_type == AGENT_IMPLEMENT: + assert task_dir is not None # validated above + context = get_implement_context(repo_root, task_dir) + new_prompt = build_implement_prompt(original_prompt, context) + elif subagent_type == AGENT_CHECK: + assert task_dir is not None # validated above + if is_finish_phase: + # Finish phase: use finish context (lighter, focused on final verification) + context = get_finish_context(repo_root, task_dir) + new_prompt = build_finish_prompt(original_prompt, context) + else: + # Regular check phase: use check context (full specs for self-fix loop) + context = get_check_context(repo_root, task_dir) + new_prompt = build_check_prompt(original_prompt, context) + elif subagent_type == AGENT_RESEARCH: + # Research can work without task directory + context = get_research_context(repo_root, task_dir) + new_prompt = build_research_prompt(original_prompt, context) + else: + sys.exit(0) + + if not context: + sys.exit(0) + + # Return updated input — use a multi-format output that covers all platforms. + # Most platforms ignore unrecognized fields, so we include multiple formats. + # The platform picks whichever fields it understands. + updated = {**tool_input, "prompt": new_prompt} + output = { + # Claude Code / Qoder / CodeBuddy / Droid format + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "updatedInput": updated, + }, + # Cursor format + "permission": "allow", + "updated_input": updated, + # Gemini format + "updatedInput": updated, + } + + print(json.dumps(output, ensure_ascii=False)) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/inject-workflow-state.py b/.claude/hooks/inject-workflow-state.py new file mode 100755 index 0000000..1a8986b --- /dev/null +++ b/.claude/hooks/inject-workflow-state.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Trellis per-turn breadcrumb hook (UserPromptSubmit / BeforeAgent equivalent). + +Runs on every user prompt. Resolves the active task through Trellis' +session-aware active task resolver and emits a short +block reminding the main AI what task is active and its expected flow. + +The emitted ``hookEventName`` field is platform-aware: most hosts expect +``UserPromptSubmit`` (Claude Code naming, also accepted by Cursor / Qoder / +CodeBuddy / Droid / Codex / Copilot wiring), but Gemini CLI 0.40.x renamed +its per-turn event to ``BeforeAgent`` and its schema validator rejects the +legacy name. ``_detect_platform`` picks the right value at runtime. +Breadcrumb text is pulled exclusively from workflow.md +[workflow-state:STATUS] tag blocks — workflow.md is the single source of +truth. There are no fallback dicts in this script: when workflow.md is +missing or a tag is absent, the breadcrumb degrades to a generic +"Refer to workflow.md for current step." line so users see (and fix) +the broken state instead of the hook silently masking it. + +Shared across all hook-capable platforms (Claude, Cursor, Codex, Qoder, +CodeBuddy, Droid, Gemini, Copilot). Kiro is not wired (no per-turn +hook entry point). Written to each platform's hooks directory via +writeSharedHooks() at init time. + +Silent exit 0 cases (no output): + - No .trellis/ directory found (not a Trellis project) + - task.json malformed or missing status +""" +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path +from typing import Optional + + +# --------------------------------------------------------------------------- +# CWD-robust Trellis root discovery (fixes hook-path-robustness for this hook) +# --------------------------------------------------------------------------- + +def find_trellis_root(start: Path) -> Optional[Path]: + """Walk up from start to find directory containing .trellis/. + + Handles CWD drift: subdirectory launches, monorepo packages, etc. + Returns None if no .trellis/ found (silent no-op). + """ + cur = start.resolve() + while cur != cur.parent: + if (cur / ".trellis").is_dir(): + return cur + cur = cur.parent + return None + + +# --------------------------------------------------------------------------- +# Active task discovery +# --------------------------------------------------------------------------- + +def _detect_platform(input_data: dict) -> str | None: + if isinstance(input_data.get("cursor_version"), str): + return "cursor" + env_map = { + "CLAUDE_PROJECT_DIR": "claude", + "CURSOR_PROJECT_DIR": "cursor", + "CODEBUDDY_PROJECT_DIR": "codebuddy", + "FACTORY_PROJECT_DIR": "droid", + "GEMINI_PROJECT_DIR": "gemini", + "QODER_PROJECT_DIR": "qoder", + "KIRO_PROJECT_DIR": "kiro", + "COPILOT_PROJECT_DIR": "copilot", + } + for env_name, platform in env_map.items(): + if os.environ.get(env_name): + return platform + script_parts = set(Path(sys.argv[0]).parts) + if ".claude" in script_parts: + return "claude" + if ".cursor" in script_parts: + return "cursor" + if ".codex" in script_parts: + return "codex" + if ".gemini" in script_parts: + return "gemini" + if ".qoder" in script_parts: + return "qoder" + if ".codebuddy" in script_parts: + return "codebuddy" + if ".factory" in script_parts: + return "droid" + if ".kiro" in script_parts: + return "kiro" + return None + + +def _resolve_active_task(root: Path, input_data: dict): + scripts_dir = root / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + from common.active_task import resolve_active_task # type: ignore[import-not-found] + + return resolve_active_task(root, input_data, platform=_detect_platform(input_data)) + + +def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, str]]: + """Return (task_id, status, source) from the current active task.""" + active = _resolve_active_task(root, input_data) + if not active.task_path: + return None + + task_dir = Path(active.task_path) + if not task_dir.is_absolute(): + task_dir = root / task_dir + if active.stale: + return task_dir.name, f"stale_{active.source_type}", active.source + + task_json = task_dir / "task.json" + if not task_json.is_file(): + return None + try: + data = json.loads(task_json.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + + task_id = data.get("id") or task_dir.name + status = data.get("status", "") + if not isinstance(status, str) or not status: + return None + return task_id, status, active.source + + +# --------------------------------------------------------------------------- +# Breadcrumb loading: parse workflow.md, fall back to hardcoded defaults +# --------------------------------------------------------------------------- + +# Supports STATUS values with letters, digits, underscores, hyphens +# (so "in-review" / "blocked-by-team" work alongside "in_progress"). +_TAG_RE = re.compile( + r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n(.*?)\n\s*\[/workflow-state:\1\]", + re.DOTALL, +) + +def load_breadcrumbs(root: Path) -> dict[str, str]: + """Parse workflow.md for [workflow-state:STATUS] blocks. + + Returns {status: body_text}. workflow.md is the single source of + truth — there are no fallback dicts in this script. Missing tags + (or a missing/unreadable workflow.md) fall back to a generic line + in build_breadcrumb so users see the broken state and fix + workflow.md, rather than the hook silently masking the issue. + """ + workflow = root / ".trellis" / "workflow.md" + if not workflow.is_file(): + return {} + try: + content = workflow.read_text(encoding="utf-8") + except OSError: + return {} + + result: dict[str, str] = {} + for match in _TAG_RE.finditer(content): + status = match.group(1) + body = match.group(2).strip() + if body: + result[status] = body + return result + + +def build_breadcrumb( + task_id: Optional[str], + status: str, + templates: dict[str, str], + source: str | None = None, +) -> str: + """Build the ... block. + + - Known status (tag present in workflow.md) → detailed template body + - Unknown status (no tag, or workflow.md missing) → generic + "Refer to workflow.md for current step." line + - `no_task` pseudo-status (task_id is None) → header omits task info + """ + body = templates.get(status) + if body is None: + body = "Refer to workflow.md for current step." + header = f"Status: {status}" if task_id is None else f"Task: {task_id} ({status})" + if source: + header = f"{header}\nSource: {source}" + return f"\n{header}\n{body}\n" + + +# --------------------------------------------------------------------------- +# Entry +# --------------------------------------------------------------------------- + +def main() -> int: + if os.environ.get("TRELLIS_HOOKS") == "0" or os.environ.get("TRELLIS_DISABLE_HOOKS") == "1": + return 0 + + try: + data = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + data = {} + + cwd_str = data.get("cwd") or os.getcwd() + cwd = Path(cwd_str) + + root = find_trellis_root(cwd) + if root is None: + return 0 # not a Trellis project + + templates = load_breadcrumbs(root) + task = get_active_task(root, data) + if task is None: + # No active task — still emit a breadcrumb nudging AI toward + # trellis-brainstorm + task.py create when user describes real work. + breadcrumb = build_breadcrumb(None, "no_task", templates) + else: + task_id, status, source = task + breadcrumb = build_breadcrumb(task_id, status, templates, source) + + # Gemini CLI 0.40.x rejects "UserPromptSubmit" — its per-turn event is + # named "BeforeAgent". Other platforms (Claude/Cursor/Qoder/CodeBuddy/ + # Droid/Codex/Copilot) accept the original Claude-style name. + hook_event_name = ( + "BeforeAgent" if _detect_platform(data) == "gemini" else "UserPromptSubmit" + ) + + output = { + "hookSpecificOutput": { + "hookEventName": hook_event_name, + "additionalContext": breadcrumb, + } + } + print(json.dumps(output)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/hooks/session-start.py b/.claude/hooks/session-start.py new file mode 100755 index 0000000..229291b --- /dev/null +++ b/.claude/hooks/session-start.py @@ -0,0 +1,769 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Session Start Hook - Inject structured context +""" +from __future__ import annotations + +# IMPORTANT: Suppress all warnings FIRST +import warnings +warnings.filterwarnings("ignore") + +import json +import os +import re +import shlex +import subprocess +import sys +from io import StringIO +from pathlib import Path + + +def _normalize_windows_shell_path(path_str: str) -> str: + """Normalize Unix-style shell paths to real Windows paths. + + On Windows, shells like Git Bash / MSYS2 / Cygwin may report paths like + `/d/Users/...` or `/cygdrive/d/Users/...`. `Path.resolve()` will misinterpret + these as `D:/d/Users...` on drive D: (or similar), breaking repo root + detection. + + This function is intentionally conservative: it only rewrites patterns that + unambiguously represent a drive letter mount. + """ + if not isinstance(path_str, str) or not path_str: + return path_str + + # Only relevant on Windows; keep other platforms untouched. + if not sys.platform.startswith("win"): + return path_str + + p = path_str.strip() + + # Already a Windows drive path (C:\... or C:/...) + if re.match(r"^[A-Za-z]:[\/]", p): + return p + + # MSYS/Git-Bash style: /c/Users/... or /d/Work/... + m = re.match(r"^/([A-Za-z])/(.*)", p) + if m: + drive, rest = m.group(1).upper(), m.group(2) + return f"{drive}:\\{rest.replace('/', '\\')}" + + # Cygwin style: /cygdrive/c/Users/... + m = re.match(r"^/cygdrive/([A-Za-z])/(.*)", p) + if m: + drive, rest = m.group(1).upper(), m.group(2) + return f"{drive}:\\{rest.replace('/', '\\')}" + + # WSL mounted drive (sometimes leaked into env): /mnt/c/Users/... + m = re.match(r"^/mnt/([A-Za-z])/(.*)", p) + if m: + drive, rest = m.group(1).upper(), m.group(2) + return f"{drive}:\\{rest.replace('/', '\\')}" + + return path_str + + +FIRST_REPLY_NOTICE = """ +On the first visible assistant reply in this session, begin with exactly one short Chinese sentence: +Trellis SessionStart 已注入:workflow、当前任务状态、开发者身份、git 状态、active tasks、spec 索引已加载。 +Then continue directly with the user's request. This notice is one-shot: do not repeat it after the first assistant reply in the same session. +""" + +# IMPORTANT: Force stdout to use UTF-8 on Windows +# This fixes UnicodeEncodeError when outputting non-ASCII characters +if sys.platform.startswith("win"): + import io as _io + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + elif hasattr(sys.stdout, "detach"): + sys.stdout = _io.TextIOWrapper(sys.stdout.detach(), encoding="utf-8", errors="replace") # type: ignore[union-attr] + + + +def _has_curated_jsonl_entry(jsonl_path: Path) -> bool: + """Return True iff jsonl has at least one row with a ``file`` field. + + A freshly seeded jsonl only contains a ``{"_example": ...}`` row (no + ``file`` key) — that is NOT "ready". Readiness requires at least one + curated entry. Matches the contract used by hook-inject and pull-based + sub-agent context loaders. + """ + try: + for line in jsonl_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(row, dict) and row.get("file"): + return True + except (OSError, UnicodeDecodeError): + return False + return False + + +def should_skip_injection() -> bool: + """Check if any platform's non-interactive flag is set, or if Trellis + hooks are explicitly disabled via TRELLIS_HOOKS=0 / TRELLIS_DISABLE_HOOKS=1. + """ + if os.environ.get("TRELLIS_HOOKS") == "0": + return True + if os.environ.get("TRELLIS_DISABLE_HOOKS") == "1": + return True + non_interactive_vars = [ + "CLAUDE_NON_INTERACTIVE", + "QODER_NON_INTERACTIVE", + "CODEBUDDY_NON_INTERACTIVE", + "FACTORY_NON_INTERACTIVE", + "CURSOR_NON_INTERACTIVE", + "GEMINI_NON_INTERACTIVE", + "KIRO_NON_INTERACTIVE", + "COPILOT_NON_INTERACTIVE", + ] + return any(os.environ.get(var) == "1" for var in non_interactive_vars) + + +def read_file(path: Path, fallback: str = "") -> str: + try: + return path.read_text(encoding="utf-8") + except (FileNotFoundError, PermissionError): + return fallback + + +def _detect_platform(input_data: dict) -> str | None: + if isinstance(input_data.get("cursor_version"), str): + return "cursor" + env_map = { + "CLAUDE_PROJECT_DIR": "claude", + "CURSOR_PROJECT_DIR": "cursor", + "CODEBUDDY_PROJECT_DIR": "codebuddy", + "FACTORY_PROJECT_DIR": "droid", + "GEMINI_PROJECT_DIR": "gemini", + "QODER_PROJECT_DIR": "qoder", + "KIRO_PROJECT_DIR": "kiro", + "COPILOT_PROJECT_DIR": "copilot", + } + for env_name, platform in env_map.items(): + if os.environ.get(env_name): + return platform + script_parts = set(Path(sys.argv[0]).parts) + if ".claude" in script_parts: + return "claude" + if ".cursor" in script_parts: + return "cursor" + if ".codex" in script_parts: + return "codex" + if ".gemini" in script_parts: + return "gemini" + if ".qoder" in script_parts: + return "qoder" + if ".codebuddy" in script_parts: + return "codebuddy" + if ".factory" in script_parts: + return "droid" + if ".kiro" in script_parts: + return "kiro" + return None + + +def _resolve_context_key(trellis_dir: Path, input_data: dict) -> str | None: + scripts_dir = trellis_dir / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + from common.active_task import resolve_context_key # type: ignore[import-not-found] + + return resolve_context_key(input_data, platform=_detect_platform(input_data)) + + +def _persist_context_key_for_bash(context_key: str | None) -> None: + """Expose Trellis session identity to later Claude Code Bash commands. + + Claude Code SessionStart hooks can append exports to CLAUDE_ENV_FILE; those + variables are then available to Bash tools in the same conversation. Without + this bridge, `task.py start` has hook stdin during SessionStart but no + session identity when the AI later runs it as a normal shell command. + """ + if not context_key: + return + env_file = os.environ.get("CLAUDE_ENV_FILE") + if not env_file: + return + try: + with open(env_file, "a", encoding="utf-8") as handle: + handle.write(f"export TRELLIS_CONTEXT_ID={shlex.quote(context_key)}\n") + except OSError: + pass + + +def _resolve_active_task(trellis_dir: Path, input_data: dict): + scripts_dir = trellis_dir / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + from common.active_task import resolve_active_task # type: ignore[import-not-found] + + return resolve_active_task( + trellis_dir.parent, + input_data, + platform=_detect_platform(input_data), + ) + + +def run_script(script_path: Path, context_key: str | None = None) -> str: + try: + if script_path.suffix == ".py": + # Add PYTHONIOENCODING to force UTF-8 in subprocess + env = os.environ.copy() + env["PYTHONIOENCODING"] = "utf-8" + if context_key: + env["TRELLIS_CONTEXT_ID"] = context_key + cmd = [sys.executable, "-W", "ignore", str(script_path)] + else: + env = os.environ.copy() + if context_key: + env["TRELLIS_CONTEXT_ID"] = context_key + cmd = [str(script_path)] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=5, + cwd=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 _normalize_task_ref(task_ref: str) -> str: + normalized = task_ref.strip() + if not normalized: + return "" + + path_obj = Path(normalized) + if path_obj.is_absolute(): + return str(path_obj) + + normalized = normalized.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + + if normalized.startswith("tasks/"): + return f".trellis/{normalized}" + + return normalized + + +def _resolve_task_dir(trellis_dir: Path, task_ref: str) -> Path: + normalized = _normalize_task_ref(task_ref) + path_obj = Path(normalized) + if path_obj.is_absolute(): + return path_obj + if normalized.startswith(".trellis/"): + return trellis_dir.parent / path_obj + return trellis_dir / "tasks" / path_obj + + +def _get_task_status(trellis_dir: Path, input_data: dict) -> str: + """Check current task status and return structured status string with explicit next action. + + Returns a block with three fields: + - Status: current state + - Task: task identifier (when applicable) + - Next-Action: explicit skill/command/tool call the AI should invoke + """ + active = _resolve_active_task(trellis_dir, input_data) + + # Case 1: No active task — waiting for user to describe intent + if not active.task_path: + return ( + "Status: NO ACTIVE TASK\n" + f"Source: {active.source}\n" + "Next-Action: After the user describes their intent, load skill `trellis-brainstorm` " + "to clarify requirements and create a task via `python3 ./.trellis/scripts/task.py create`.\n" + "Research reminder: for research-heavy tasks (comparing tools, reading external docs, " + "cross-platform surveys), spawn `trellis-research` sub-agents via the Task tool — " + "they persist findings to `{TASK_DIR}/research/*.md` and keep main context clean. " + "Do NOT do 10+ inline WebFetch/WebSearch in the main conversation.\n" + "User override (per-turn escape hatch): if the user's first message explicitly opts " + "out of the workflow (\"跳过 trellis\" / \"别走流程\" / \"小修一下\" / \"直接改\" / " + "\"skip trellis\" / \"no task\" / \"just do it\"), honor it for this turn — " + "acknowledge briefly and proceed without creating a task. Per-turn only." + ) + + # Case 2: Stale pointer — task dir was deleted + task_ref = active.task_path + task_dir = _resolve_task_dir(trellis_dir, task_ref) + if active.stale or not task_dir.is_dir(): + return ( + f"Status: STALE POINTER\nTask: {task_ref}\n" + f"Source: {active.source}\n" + f"Next-Action: Run `python3 ./.trellis/scripts/task.py finish` to clear the stale pointer, " + "then ask the user what to work on next." + ) + + # Read task.json + task_json_path = task_dir / "task.json" + task_data = {} + 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") + + # Case 3: Task completed — time to archive + if task_status == "completed": + return ( + f"Status: COMPLETED\nTask: {task_title}\n" + f"Source: {active.source}\n" + f"Next-Action: Load skill `trellis-update-spec` to capture learnings, " + f"then archive with `python3 ./.trellis/scripts/task.py archive {task_dir.name}`." + ) + + has_prd = (task_dir / "prd.md").is_file() + + # Case 4: No PRD — still in Plan phase + if not has_prd: + return ( + f"Status: PLANNING\nTask: {task_title}\n" + f"Source: {active.source}\n" + "Next-Action: Load skill `trellis-brainstorm` to clarify requirements with the user " + "and produce prd.md in the task directory.\n" + "Research reminder: when the task needs external research (tool comparison, docs, " + "conventions survey), spawn `trellis-research` sub-agents — don't WebFetch/WebSearch " + "inline in the main session. Findings go to `{task_dir}/research/*.md`; PRD only links to them." + ) + + # Case 4b: PRD exists but implement.jsonl has only seed (no curated entries) — Phase 1.3 gate + implement_jsonl = task_dir / "implement.jsonl" + if implement_jsonl.is_file() and not _has_curated_jsonl_entry(implement_jsonl): + return ( + f"Status: PLANNING (Phase 1.3)\nTask: {task_title}\n" + f"Source: {active.source}\n" + "Next-Action: Curate `implement.jsonl` and `check.jsonl` with the spec + research files " + "the Phase 2 sub-agents will need. Only spec paths (`.trellis/spec/**/*.md`) and research " + "files (`{TASK_DIR}/research/*.md`) — no code paths. Run " + "`python3 ./.trellis/scripts/get_context.py --mode packages` to list available specs, " + "then edit the jsonl files or use `python3 ./.trellis/scripts/task.py add-context`. " + "See `.trellis/workflow.md` Phase 1.3 for details." + ) + + # Case 5: PRD + curated jsonl (or agent-less platform with no jsonl) — enter Execute phase + return ( + f"Status: READY\nTask: {task_title}\n" + f"Source: {active.source}\n" + "Next required action: dispatch `trellis-implement` per Phase 2.1. " + "For agent-capable platforms, the default is to NOT edit code in the main session. " + "After implementation, dispatch `trellis-check` per Phase 2.2 before reporting completion.\n" + "Sub-agent roster: `trellis-implement` (writes code), `trellis-check` (verifies + self-fixes), " + "`trellis-research` (persists findings to `research/*.md` — use when you'd otherwise do " + "multiple WebFetch/WebSearch inline).\n" + "User override (per-turn escape hatch): if the user's CURRENT message explicitly tells the " + "main session to handle it directly (\"你直接改\" / \"别派 sub-agent\" / \"main session 写就行\" / " + "\"do it inline\" / \"不用 sub-agent\"), honor it for this turn and edit code directly. " + "Per-turn only; do NOT invent an override the user did not say." + ) + + +def _load_trellis_config(trellis_dir: Path, input_data: dict) -> tuple: + """Load Trellis config for session-start decisions. + + Returns: + (is_mono, packages_dict, spec_scope, task_pkg, default_pkg) + """ + scripts_dir = trellis_dir / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + + try: + from common.config import get_default_package, get_packages, get_spec_scope, is_monorepo # type: ignore[import-not-found] + from common.paths import get_current_task # type: ignore[import-not-found] + + repo_root = trellis_dir.parent + is_mono = is_monorepo(repo_root) + packages = get_packages(repo_root) or {} + scope = get_spec_scope(repo_root) + + # Get active task's package + task_pkg = None + current = get_current_task( + repo_root, + input_data, + platform=_detect_platform(input_data), + ) + if current: + task_json = repo_root / current / "task.json" + if task_json.is_file(): + try: + data = json.loads(task_json.read_text(encoding="utf-8")) + if isinstance(data, dict): + tp = data.get("package") + if isinstance(tp, str) and tp: + task_pkg = tp + except (json.JSONDecodeError, OSError): + pass + + default_pkg = get_default_package(repo_root) + return is_mono, packages, scope, task_pkg, default_pkg + except Exception: + return False, {}, None, None, None + + +def _check_legacy_spec(trellis_dir: Path, is_mono: bool, packages: dict) -> str | None: + """Check for legacy spec directory structure in monorepo. + + Returns warning message if legacy structure detected, None otherwise. + """ + if not is_mono or not packages: + return None + + spec_dir = trellis_dir / "spec" + if not spec_dir.is_dir(): + return None + + # Check for legacy flat spec dirs (spec/backend/, spec/frontend/ with index.md) + has_legacy = False + for legacy_name in ("backend", "frontend"): + legacy_dir = spec_dir / legacy_name + if legacy_dir.is_dir() and (legacy_dir / "index.md").is_file(): + has_legacy = True + break + + if not has_legacy: + return None + + # Check which packages are missing spec// directory + missing = [ + name for name in sorted(packages.keys()) + if not (spec_dir / name).is_dir() + ] + + if not missing: + return None # All packages have spec dirs + + if len(missing) == len(packages): + return ( + f"[!] Legacy spec structure detected: found `spec/backend/` or `spec/frontend/` " + f"but no package-scoped `spec//` directories.\n" + f"Monorepo packages: {', '.join(sorted(packages.keys()))}\n" + f"Please reorganize: `spec/backend/` -> `spec//backend/`" + ) + return ( + f"[!] Partial spec migration detected: packages {', '.join(missing)} " + f"still missing `spec//` directory.\n" + f"Please complete migration for all packages." + ) + + +def _resolve_spec_scope( + is_mono: bool, + packages: dict, + scope, + task_pkg: str | None, + default_pkg: str | None, +) -> set | None: + """Resolve which packages should have their specs injected. + + Returns: + Set of package names to include, or None for full scan. + """ + if not is_mono or not packages: + return None # Single-repo: full scan + + if scope is None: + return None # No scope configured: full scan + + if isinstance(scope, str) and scope == "active_task": + if task_pkg and task_pkg in packages: + return {task_pkg} + if default_pkg and default_pkg in packages: + return {default_pkg} + return None # Fallback to full scan + + if isinstance(scope, list): + valid = set() + for entry in scope: + if entry in packages: + valid.add(entry) + else: + print( + f"Warning: spec_scope contains unknown package: {entry}, ignoring", + file=sys.stderr, + ) + + if valid: + # Warn if active task is out of scope + if task_pkg and task_pkg not in valid: + print( + f"Warning: active task package '{task_pkg}' is out of configured spec_scope", + file=sys.stderr, + ) + return valid + + # All entries invalid: fallback chain + print( + "Warning: all spec_scope entries invalid, falling back to task/default/full", + file=sys.stderr, + ) + if task_pkg and task_pkg in packages: + return {task_pkg} + if default_pkg and default_pkg in packages: + return {default_pkg} + return None # Full scan + + return None # Unknown scope type: full scan + + +def _extract_range(content: str, start_header: str, end_header: str) -> str: + """Extract lines starting at `## start_header` up to (but excluding) `## end_header`. + + Both parameters are full header lines WITHOUT the `## ` prefix (e.g. "Phase Index"). + Returns empty string if start header is not found. + End header missing → extracts to end of file. + """ + lines = content.splitlines() + start: int | None = None + end: int = len(lines) + start_match = f"## {start_header}" + end_match = f"## {end_header}" + for i, line in enumerate(lines): + stripped = line.strip() + if start is None and stripped == start_match: + start = i + continue + if start is not None and stripped == end_match: + end = i + break + if start is None: + return "" + return "\n".join(lines[start:end]).rstrip() + + +_BREADCRUMB_TAG_RE = re.compile( + r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n.*?\n\s*\[/workflow-state:\1\]", + re.DOTALL, +) + + +def _strip_breadcrumb_tag_blocks(content: str) -> str: + """Remove `[workflow-state:STATUS]...[/workflow-state:STATUS]` blocks. + + The tag blocks live inside `## Phase Index` (since v0.5.0-rc.0, when + they were colocated with their phase summaries) and are consumed by the + UserPromptSubmit hook (`inject-workflow-state.py`). The session-start + payload already covers the full step bodies, so re-inlining the + breadcrumbs here would just duplicate context. + """ + return _BREADCRUMB_TAG_RE.sub("", content) + + +def _build_workflow_overview(workflow_path: Path) -> str: + """Inject the workflow guide for the session. + + Contents: + 1. Section index (all `## ` headings — navigation) + 2. Phase Index section (rules, skill routing table, anti-rationalization table) + 3. Phase 1/2/3 step-level details (the actual how-to for each step) + + The meta sections (Core Principles / Trellis System / Customizing + Trellis) are NOT injected — Core Principles is short prose the AI can + Read on demand; Trellis System lists reference commands duplicated in + step bodies; Customizing Trellis is for forks. Workflow-state breadcrumb + tag blocks (which now live inside Phase Index since v0.5.0-rc.0) are + stripped from the extracted range — they're consumed by the + UserPromptSubmit hook, not the session-start preamble. + + Total budget: Phase Index ~2 KB + Phase 1/2/3 ~7 KB = ~9 KB. + """ + content = read_file(workflow_path) + if not content: + return "No workflow.md found" + + out_lines = [ + "# Development Workflow — Section Index", + "Full guide: .trellis/workflow.md (read on demand)", + "", + "## Table of Contents", + ] + for line in content.splitlines(): + if line.startswith("## "): + out_lines.append(line) + out_lines += ["", "---", ""] + + # Extract Phase Index through the end of Phase 3 (before "Customizing + # Trellis" — the docs-for-forks footer added in v0.5.0-rc.0). Since + # sections appear in order Phase Index → Phase 1 → Phase 2 → Phase 3 → + # Customizing Trellis, a single range grab captures all four. The + # breadcrumb tag blocks now embedded inside Phase Index are stripped so + # they don't duplicate the per-turn UserPromptSubmit injection. + phases = _extract_range( + content, "Phase Index", "Customizing Trellis (for forks)" + ) + if phases: + out_lines.append(_strip_breadcrumb_tag_blocks(phases).rstrip()) + + return "\n".join(out_lines).rstrip() + + +def main(): + if should_skip_injection(): + sys.exit(0) + + try: + hook_input = json.loads(sys.stdin.read()) + if not isinstance(hook_input, dict): + hook_input = {} + except (json.JSONDecodeError, ValueError): + hook_input = {} + + # Try platform-specific env vars, hook cwd, fallback to cwd + project_dir_env_vars = [ + "CLAUDE_PROJECT_DIR", + "QODER_PROJECT_DIR", + "CODEBUDDY_PROJECT_DIR", + "FACTORY_PROJECT_DIR", + "CURSOR_PROJECT_DIR", + "GEMINI_PROJECT_DIR", + "KIRO_PROJECT_DIR", + "COPILOT_PROJECT_DIR", + ] + project_dir = None + for var in project_dir_env_vars: + val = os.environ.get(var) + if val: + project_dir = Path(_normalize_windows_shell_path(val)).resolve() + break + if project_dir is None: + project_dir = Path(_normalize_windows_shell_path(hook_input.get("cwd", "."))).resolve() + + trellis_dir = project_dir / ".trellis" + context_key = _resolve_context_key(trellis_dir, hook_input) + _persist_context_key_for_bash(context_key) + + # Load config for scope filtering and legacy detection + is_mono, packages, scope_config, task_pkg, default_pkg = _load_trellis_config( + trellis_dir, + hook_input, + ) + allowed_pkgs = _resolve_spec_scope(is_mono, packages, scope_config, task_pkg, default_pkg) + + output = StringIO() + + output.write(""" +You are starting a new session in a Trellis-managed project. +Read and follow all instructions below carefully. + + +""") + output.write(FIRST_REPLY_NOTICE) + output.write("\n\n") + + # Legacy migration warning + legacy_warning = _check_legacy_spec(trellis_dir, is_mono, packages) + if legacy_warning: + output.write(f"\n{legacy_warning}\n\n\n") + + output.write("\n") + context_script = trellis_dir / "scripts" / "get_context.py" + output.write(run_script(context_script, context_key)) + output.write("\n\n\n") + + output.write("\n") + output.write(_build_workflow_overview(trellis_dir / "workflow.md")) + output.write("\n\n\n") + + output.write("\n") + output.write( + "Project spec indexes are listed by path below. Each index contains a " + "**Pre-Development Checklist** listing the specific guideline files to " + "read before coding.\n\n" + "- If you're spawning an implement/check sub-agent, context is injected " + "or loaded by the sub-agent via `{task}/implement.jsonl` / `check.jsonl`. " + "You do NOT need to read these indexes yourself.\n" + "- For agent-capable platforms, the default is to dispatch " + "`trellis-implement` and `trellis-check` (so JSONL context is loaded by " + "the sub-agents) rather than editing code in the main session. " + "Honor a per-turn user override only if the user's current message " + "explicitly opts out (see below for override phrases).\n\n" + ) + + # guides/ is cross-package thinking — always include inline (small, broadly useful) + guides_index = trellis_dir / "spec" / "guides" / "index.md" + if guides_index.is_file(): + output.write("## guides (inlined — cross-package thinking guides)\n") + output.write(read_file(guides_index)) + output.write("\n\n") + + # Other spec indexes — paths only (main agent reads on demand; + # sub-agents get their specific specs via jsonl injection) + paths: list[str] = [] + 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": + continue # already inlined above + + index_file = sub / "index.md" + if index_file.is_file(): + # Flat spec dir (single-repo layer like spec/backend/) + paths.append(f".trellis/spec/{sub.name}/index.md") + else: + # Nested package dirs (monorepo: spec///index.md) + # Apply scope filter + if allowed_pkgs is not None and sub.name not in allowed_pkgs: + continue + for nested in sorted(sub.iterdir()): + if not nested.is_dir(): + continue + nested_index = nested / "index.md" + if nested_index.is_file(): + paths.append( + f".trellis/spec/{sub.name}/{nested.name}/index.md" + ) + + if paths: + output.write("## Available spec indexes (read on demand)\n") + for p in paths: + output.write(f"- {p}\n") + output.write("\n") + + output.write( + "Discover more via: " + "`python3 ./.trellis/scripts/get_context.py --mode packages`\n" + ) + output.write("\n\n") + + # Check task status and inject structured tag + task_status = _get_task_status(trellis_dir, hook_input) + output.write(f"\n{task_status}\n\n\n") + + output.write(""" +Context loaded. Workflow index, project state, and guidelines are already injected above — do NOT re-read them. +When the user sends the first message, follow and the workflow guide. +If a task is READY, execute its Next required action without asking whether to continue. +""") + + result = { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": output.getvalue(), + } + } + + # Output JSON - stdout is already configured for UTF-8 + print(json.dumps(result, ensure_ascii=False), flush=True) + + +if __name__ == "__main__": + main() diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..fa84a55 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,73 @@ +{ + "env": { + "CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR": "1" + }, + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/session-start.py", + "timeout": 10 + } + ] + }, + { + "matcher": "clear", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/session-start.py", + "timeout": 10 + } + ] + }, + { + "matcher": "compact", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/session-start.py", + "timeout": 10 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Task", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/inject-subagent-context.py", + "timeout": 30 + } + ] + }, + { + "matcher": "Agent", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/inject-subagent-context.py", + "timeout": 30 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/inject-workflow-state.py", + "timeout": 5 + } + ] + } + ] + }, + "enabledPlugins": {} +} diff --git a/.claude/skills/trellis-before-dev/SKILL.md b/.claude/skills/trellis-before-dev/SKILL.md new file mode 100644 index 0000000..9c6ec9c --- /dev/null +++ b/.claude/skills/trellis-before-dev/SKILL.md @@ -0,0 +1,34 @@ +--- +name: trellis-before-dev +description: "Discovers and injects project-specific coding guidelines from .trellis/spec/ before implementation begins. Reads spec indexes, pre-development checklists, and shared thinking guides for the target package. Use when starting a new coding task, before writing any code, switching to a different package, or needing to refresh project conventions and standards." +--- + +Read the relevant development guidelines before starting your task. + +Execute these steps: + +1. **Discover packages and their spec layers**: + ```bash + python3 ./.trellis/scripts/get_context.py --mode packages + ``` + +2. **Identify which specs apply** to your task based on: + - Which package you're modifying (e.g., `cli/`, `docs-site/`) + - What type of work (backend, frontend, unit-test, docs, etc.) + +3. **Read the spec index** for each relevant module: + ```bash + cat .trellis/spec///index.md + ``` + Follow the **"Pre-Development Checklist"** section in the index. + +4. **Read the specific guideline files** listed in the Pre-Development Checklist that are relevant to your task. The index is NOT the goal — it points you to the actual guideline files (e.g., `error-handling.md`, `conventions.md`, `mock-strategies.md`). Read those files to understand the coding standards and patterns. + +5. **Always read shared guides**: + ```bash + cat .trellis/spec/guides/index.md + ``` + +6. Understand the coding standards and patterns you need to follow, then proceed with your development plan. + +This step is **mandatory** before writing any code. diff --git a/.opencode/commands/trellis/brainstorm.md b/.claude/skills/trellis-brainstorm/SKILL.md similarity index 81% rename from .opencode/commands/trellis/brainstorm.md rename to .claude/skills/trellis-brainstorm/SKILL.md index bc2b8af..e160187 100644 --- a/.opencode/commands/trellis/brainstorm.md +++ b/.claude/skills/trellis-brainstorm/SKILL.md @@ -1,3 +1,8 @@ +--- +name: trellis-brainstorm +description: "Guides collaborative requirements discovery before implementation. Creates task directory, seeds PRD, asks high-value questions one at a time, researches technical choices, and converges on MVP scope. Use when requirements are unclear, there are multiple valid approaches, or the user describes a new feature or complex task." +--- + # Brainstorm - Requirements Discovery (AI Coding Enhanced) Guide AI through collaborative requirements discovery **before implementation**, optimized for AI coding workflows: @@ -11,7 +16,7 @@ Guide AI through collaborative requirements discovery **before implementation**, ## When to Use -Triggered from `/trellis:start` when the user describes a development task, especially when: +Triggered from /trellis:start when the user describes a development task, especially when: * requirements are unclear or evolving * there are multiple valid implementation paths @@ -57,6 +62,9 @@ Before any Q&A, ensure a task exists. If none exists, create one immediately. TASK_DIR=$(python3 ./.trellis/scripts/task.py create "brainstorm: " --slug ) ``` +Use a slug without a date prefix. `task.py create` adds the `MM-DD-` +directory prefix automatically. + Create/seed `prd.md` immediately with what you know: ```markdown @@ -184,18 +192,61 @@ Examples: * The user asks for "best practice", "how others do it", "recommendation" * The user can't reasonably enumerate options -### Research steps +### Delegate to `trellis-research` sub-agent (don't research inline) -1. Identify 2–4 comparable tools/patterns +For each research topic, **spawn a `trellis-research` sub-agent via the Task tool** — don't do WebFetch / WebSearch / `gh api` inline in the main conversation. + +Why: +- The sub-agent has its own context window → doesn't pollute brainstorm context with raw tool output +- It persists findings to `{TASK_DIR}/research/.md` (the contract — see `workflow.md` Phase 1.2) +- It returns only `{file path, one-line summary}` to the main agent +- Independent topics can be **parallelized** — spawn multiple sub-agents in one tool call + +Agent type: `trellis-research` +Task description template: "Research ; persist findings to `{TASK_DIR}/research/.md`." + +❌ Bad (what you must NOT do): +``` +Main agent: WebFetch(url-A) → WebFetch(url-B) → Bash(gh api ...) + → WebSearch(q1) → WebSearch(q2) → ... (10+ inline calls) + → Write(research/topic.md) +``` +→ Pollutes main context with raw HTML/JSON, burns tokens. + +✅ Good: +``` +Main agent: Task(subagent_type="trellis-research", + prompt="Research topic A; persist to research/topic-a.md") + + Task(subagent_type="trellis-research", + prompt="Research topic B; persist to research/topic-b.md") + + Task(subagent_type="trellis-research", + prompt="Research topic C; persist to research/topic-c.md") +→ Reads research/topic-{a,b,c}.md after they finish. +``` + +### Research steps (to pass into each sub-agent prompt) + +Each `trellis-research` sub-agent should: + +1. Identify 2–4 comparable tools/patterns for its topic 2. Summarize common conventions and why they exist 3. Map conventions onto our repo constraints -4. Produce **2–3 feasible approaches** for our project +4. Write findings to `{TASK_DIR}/research/.md` + +Main agent then reads the persisted files and produces **2–3 feasible approaches** in PRD. ### Research output format (PRD) -Add a section in PRD (either within Technical Notes or as its own): +The PRD itself should only reference the persisted research files, not duplicate their content. Add a `## Research References` section pointing at `research/*.md`. + +Optionally, add a convergence section with feasible approaches derived from the research: ```markdown +## Research References + +* [`research/.md`](research/.md) — +* [`research/.md`](research/.md) — + ## Research Notes ### What similar tools do diff --git a/.opencode/commands/trellis/break-loop.md b/.claude/skills/trellis-break-loop/SKILL.md similarity index 88% rename from .opencode/commands/trellis/break-loop.md rename to .claude/skills/trellis-break-loop/SKILL.md index 9905751..ef2b50c 100644 --- a/.opencode/commands/trellis/break-loop.md +++ b/.claude/skills/trellis-break-loop/SKILL.md @@ -1,6 +1,11 @@ +--- +name: trellis-break-loop +description: "Deep bug analysis to break the fix-forget-repeat cycle. Analyzes root cause category, why fixes failed, prevention mechanisms, and captures knowledge into specs. Use after fixing a bug to prevent the same class of bugs." +--- + # Break the Loop - Deep Bug Analysis -When debug is complete, use this command for deep analysis to break the "fix bug -> forget -> repeat" cycle. +When debug is complete, use this for deep analysis to break the "fix bug -> forget -> repeat" cycle. --- @@ -37,7 +42,7 @@ What mechanisms would prevent this from happening again? |------|-------------|---------| | **Documentation** | Write it down so people know | Update thinking guide | | **Architecture** | Make the error impossible structurally | Type-safe wrappers | -| **Compile-time** | TypeScript strict, no any | Signature change causes compile error | +| **Compile-time** | Strict type checking, no escape hatches | Signature change causes compile error | | **Runtime** | Monitoring, alerts, scans | Detect orphan entities | | **Test Coverage** | E2E tests, integration tests | Verify full flow | | **Code Review** | Checklist, PR template | "Did you check X?" | @@ -56,10 +61,10 @@ What broader problems does this bug reveal? Solidify insights into the system: - [ ] Update `.trellis/spec/guides/` thinking guides -- [ ] Update `.trellis/spec/backend/` or `frontend/` docs +- [ ] Update relevant `.trellis/spec/` docs - [ ] Create issue record (if applicable) - [ ] Create feature ticket for root fix -- [ ] Update check commands if needed +- [ ] Update check guidelines if needed --- diff --git a/.claude/skills/trellis-check/SKILL.md b/.claude/skills/trellis-check/SKILL.md new file mode 100644 index 0000000..16b3dc4 --- /dev/null +++ b/.claude/skills/trellis-check/SKILL.md @@ -0,0 +1,92 @@ +--- +name: trellis-check +description: "Comprehensive quality verification: spec compliance, lint, type-check, tests, cross-layer data flow, code reuse, and consistency checks. Use when code is written and needs quality verification, before committing changes, or to catch context drift during long sessions." +--- + +# Code Quality Check + +Comprehensive quality verification for recently written code. Combines spec compliance, cross-layer safety, and pre-commit checks. + +--- + +## Step 1: Identify What Changed + +```bash +git diff --name-only HEAD +git status +``` + +## Step 2: Read Applicable Specs + +```bash +python3 ./.trellis/scripts/get_context.py --mode packages +``` + +For each changed package/layer, read the spec index and follow its **Quality Check** section: + +```bash +cat .trellis/spec///index.md +``` + +Read the specific guideline files referenced — the index is a pointer, not the goal. + +## Step 3: Run Project Checks + +Run the project's lint, type-check, and test commands. Fix any failures before proceeding. + +## Step 4: Review Against Checklist + +### Code Quality + +- [ ] Linter passes? +- [ ] Type checker passes (if applicable)? +- [ ] Tests pass? +- [ ] No debug logging left in? +- [ ] No suppressed warnings or type-safety bypasses? + +### Test Coverage + +- [ ] New function → unit test added? +- [ ] Bug fix → regression test added? +- [ ] Changed behavior → existing tests updated? + +### Spec Sync + +- [ ] Does `.trellis/spec/` need updates? (new patterns, conventions, lessons learned) + +> "If I fixed a bug or discovered something non-obvious, should I document it so future me won't hit the same issue?" → If YES, update the relevant spec doc. + +## Step 5: Cross-Layer Dimensions (if applicable) + +Skip this step if your change is confined to a single layer. + +### A. Data Flow (changes touch 3+ layers) + +- [ ] Read flow traces correctly: Storage → Service → API → UI +- [ ] Write flow traces correctly: UI → API → Service → Storage +- [ ] Types/schemas correctly passed between layers? +- [ ] Errors properly propagated to caller? + +### B. Code Reuse (modifying constants, creating utilities) + +- [ ] Searched for existing similar code before creating new? + ```bash + grep -r "pattern" src/ + ``` +- [ ] If 2+ places define same value → extracted to shared constant? +- [ ] After batch modification, all occurrences updated? + +### C. Import/Dependency (creating new files) + +- [ ] Correct import paths (relative vs absolute)? +- [ ] No circular dependencies? + +### D. Same-Layer Consistency + +- [ ] Other places using the same concept are consistent? + +--- + +## Step 6: Report and Fix + +Report violations found and fix them directly. Re-run project checks after fixes. diff --git a/.claude/skills/trellis-meta/SKILL.md b/.claude/skills/trellis-meta/SKILL.md new file mode 100644 index 0000000..590bfac --- /dev/null +++ b/.claude/skills/trellis-meta/SKILL.md @@ -0,0 +1,73 @@ +--- +name: trellis-meta +description: "Understand and customize the local Trellis architecture inside a user project. Use when modifying .trellis plus platform hooks, settings, agents, skills, commands, prompts, or workflows generated by trellis init." +--- + +# Trellis Meta + +This skill is for local Trellis users who have already run `trellis init` in a project. After reading it, an AI should understand the Trellis architecture, operating model, and customization entry points inside that user project, then modify the generated `.trellis/` and platform directory files according to the user's request. + +The default operating scope is local files in the user project: + +- `.trellis/`: workflow, config, tasks, spec, workspace, scripts, and runtime state. +- Platform directories: `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, `.kiro/`, `.gemini/`, `.qoder/`, `.codebuddy/`, `.github/`, `.factory/`, `.pi/`, `.kilocode/`, `.agent/`, `.windsurf/`, and similar directories. +- Shared skill layer: `.agents/skills/`. + +Do not assume the user has the Trellis source repository. Do not default to modifying the global npm install directory or `node_modules`. + +## How To Use + +1. Read `references/local-architecture/overview.md` first to establish the local Trellis system model. +2. If the request involves a specific AI tool, read `references/platform-files/platform-map.md` and the relevant platform file notes. +3. If the user wants to change behavior, read `references/customize-local/overview.md`, then open the specific customization topic. +4. Before editing, read the actual files in the user project and treat local content as authoritative. + +## References + +### Local Architecture + +- `references/local-architecture/overview.md`: The three-layer local Trellis architecture and customization principles. +- `references/local-architecture/generated-files.md`: Files generated by `trellis init` and their customization boundaries. +- `references/local-architecture/workflow.md`: Phases, routing, and workflow-state blocks in `.trellis/workflow.md`. +- `references/local-architecture/task-system.md`: Task directories, active tasks, JSONL context, and task runtime. +- `references/local-architecture/spec-system.md`: How `.trellis/spec/` is organized and injected. +- `references/local-architecture/workspace-memory.md`: `.trellis/workspace/`, journals, and cross-session memory. +- `references/local-architecture/context-injection.md`: Hooks, sub-agent preludes, and context injection paths. + +### Platform Files + +- `references/platform-files/overview.md`: How shared `.trellis/` files relate to platform directories. +- `references/platform-files/platform-map.md`: Platform directories and paths for skills, agents, hooks, and extensions. +- `references/platform-files/hooks-and-settings.md`: How settings/config files, hooks, plugins, and extensions connect to Trellis. +- `references/platform-files/agents.md`: Local file responsibilities for `trellis-research`, `trellis-implement`, and `trellis-check`. +- `references/platform-files/skills-and-commands.md`: Differences between skills, commands, prompts, and workflows, plus how to change them. + +### Local Customization + +- `references/customize-local/overview.md`: Choose the right local customization entry point for the user's request. +- `references/customize-local/change-workflow.md`: Change phases, routing, next actions, and workflow-state. +- `references/customize-local/change-task-lifecycle.md`: Change task creation, status, archive behavior, and hooks. +- `references/customize-local/change-context-loading.md`: Change how tasks, specs, journals, and hook context are loaded. +- `references/customize-local/change-hooks.md`: Change platform hooks, settings, and shell session bridges. +- `references/customize-local/change-agents.md`: Change research, implement, and check agent behavior. +- `references/customize-local/change-skills-or-commands.md`: Add or modify local skills, commands, prompts, and workflows. +- `references/customize-local/change-spec-structure.md`: Adjust the project spec structure under `.trellis/spec/`. +- `references/customize-local/add-project-local-conventions.md`: Put team rules into project-local specs or local skills. + +## Current Rules + +- `.trellis/workflow.md` is the local workflow source of truth. +- `.trellis/config.yaml` is the project-level Trellis configuration and task hook configuration entry point. +- `.trellis/spec/` stores the user's project-specific coding conventions and design constraints. +- `.trellis/tasks/` stores task PRDs, technical notes, research files, and JSONL context. +- `.trellis/workspace/` stores developer journals and cross-session memory. +- Platform settings/config files decide which hooks, agents, skills, commands, prompts, and workflows actually run. +- `.trellis/.template-hashes.json` and `.trellis/.runtime/` are management/runtime state files. Confirm necessity before editing them. + +## Do Not + +- Do not treat Trellis upstream source code as the default target for local customization. +- Do not modify the global npm install directory or `node_modules/@mindfoldhq/trellis` to implement project needs. +- Do not overwrite user-modified local files with default templates. +- Do not put team-private project rules into the public `trellis-meta`; put project rules in `.trellis/spec/` or a project-local skill. +- Do not describe removed historical mechanisms as current Trellis behavior. diff --git a/.claude/skills/trellis-meta/references/customize-local/add-project-local-conventions.md b/.claude/skills/trellis-meta/references/customize-local/add-project-local-conventions.md new file mode 100644 index 0000000..608aaa6 --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/add-project-local-conventions.md @@ -0,0 +1,83 @@ +# Add Project-Local Conventions + +Often the user does not need to change Trellis mechanics; they need local AI to understand their team's conventions. In that case, prefer `.trellis/spec/` or a project-local skill instead of editing `trellis-meta`. + +## Where To Put Things + +| Content type | Location | +| --- | --- | +| Rules code must follow | `.trellis/spec//` | +| Cross-layer thinking methods | `.trellis/spec/guides/` | +| AI capability for a project-specific flow | Platform-local skill | +| One-off task material | `.trellis/tasks//` | +| Session summary | `.trellis/workspace//journal-N.md` | + +## Create A Project-Local Skill + +If the user wants AI to know "how this project customizes Trellis," create a local skill: + +```text +.claude/skills/trellis-local/ +└── SKILL.md +``` + +Example: + +```md +--- +name: trellis-local +description: "Project-local Trellis customizations for this repository. Use when changing this project's Trellis workflow, hooks, local agents, or team-specific conventions." +--- + +# Trellis Local + +## Local Scope + +This skill documents this repository's Trellis customizations only. + +## Custom Workflow Rules + +- ... + +## Local Hook Changes + +- ... + +## Local Agent Changes + +- ... +``` + +For multi-platform projects, place equivalent versions in other platform skill directories, or use `.agents/skills/` for platforms that support the shared layer. + +## Write To `.trellis/spec/` + +If the content is a coding convention, write it to spec. Examples: + +```text +.trellis/spec/backend/error-handling.md +.trellis/spec/frontend/components.md +.trellis/spec/guides/cross-platform-thinking-guide.md +``` + +After writing it, update the corresponding `index.md` so AI can find the new rule from the entry point. + +## Make The Current Task Use New Conventions + +After writing a spec, add it to the current task context: + +```bash +python3 ./.trellis/scripts/task.py add-context implement ".trellis/spec/backend/error-handling.md" "Error handling conventions" +python3 ./.trellis/scripts/task.py add-context check ".trellis/spec/backend/error-handling.md" "Review error handling" +``` + +## Do Not Store Project-Private Rules In `trellis-meta` + +`trellis-meta` is a public skill for understanding Trellis architecture and local customization entry points. Put project-private content in: + +- `.trellis/spec/` +- a project-local skill +- the current task +- workspace journal + +This prevents future updates to Trellis's built-in `trellis-meta` from overwriting the team's own conventions. diff --git a/.claude/skills/trellis-meta/references/customize-local/change-agents.md b/.claude/skills/trellis-meta/references/customize-local/change-agents.md new file mode 100644 index 0000000..9b63531 --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/change-agents.md @@ -0,0 +1,54 @@ +# Change Local Agents + +When the user wants to change `trellis-research`, `trellis-implement`, or `trellis-check` behavior, edit platform agent files in the user project. + +## Read These Files First + +1. Target platform agent directory +2. `.trellis/workflow.md` Phase 2 / research routing +3. Current task `prd.md` +4. Current task `implement.jsonl` / `check.jsonl` +5. Relevant hook or agent prelude + +## Common Paths + +| Platform | Path | +| --- | --- | +| Claude Code | `.claude/agents/trellis-*.md` | +| Cursor | `.cursor/agents/trellis-*.md` | +| OpenCode | `.opencode/agents/trellis-*.md` | +| Codex | `.codex/agents/trellis-*.toml` | +| Kiro | `.kiro/agents/trellis-*.json` | +| Gemini CLI | `.gemini/agents/trellis-*.md` | +| Qoder | `.qoder/agents/trellis-*.md` | +| CodeBuddy | `.codebuddy/agents/trellis-*.md` | +| Factory Droid | `.factory/droids/trellis-*.md` | +| Pi Agent | `.pi/agents/trellis-*.md` | + +Use the actual paths in the user project as authoritative. + +## Common Needs + +| Need | Which agent to edit | +| --- | --- | +| Research must write files, not only reply in chat | `trellis-research` | +| Certain local specs must be read before implementation | `trellis-implement` + `implement.jsonl` configuration rules | +| Specific commands must run during checking | `trellis-check` | +| Agent must not modify certain directories | The corresponding agent's write boundary instructions | +| Agent output format must be fixed | The corresponding agent's final/reporting instructions | + +## Modification Principles + +1. **Preserve role boundaries**: research investigates and persists; implement writes implementation; check reviews and fixes. +2. **Do not hard-code project specs into agents**: long-term specs belong in `.trellis/spec/`; agents are responsible for reading them. +3. **Make read order explicit**: active task -> PRD -> info -> JSONL -> spec/research. +4. **Make write boundaries explicit**: which directories may be written and which may not. +5. **Synchronize across platforms**: when the user configured multiple platforms, decide whether to change only the current platform or all platform agents. + +## Agent Pull Platforms + +If an agent file contains a prelude for "read task/context after startup," do not remove those steps when editing. Otherwise the agent will work only from chat context and bypass Trellis's core mechanism. + +## Hook Push Platforms + +If context is injected by a hook, the agent file should still retain responsibility boundaries. Do not remove PRD/spec requirements from the agent just because a hook injects context. diff --git a/.claude/skills/trellis-meta/references/customize-local/change-context-loading.md b/.claude/skills/trellis-meta/references/customize-local/change-context-loading.md new file mode 100644 index 0000000..556b4e3 --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/change-context-loading.md @@ -0,0 +1,81 @@ +# Change Local Context Loading + +Context loading determines when AI reads workflow, task, spec, research, workspace, and git status. Read this page when the user says "AI does not know the current task," "the agent did not read specs," or "there is too much/too little context." + +## Read These Files First + +1. `.trellis/workflow.md` +2. `.trellis/scripts/get_context.py` +3. `.trellis/scripts/common/session_context.py` +4. `.trellis/scripts/common/task_context.py` +5. `.trellis/scripts/common/active_task.py` +6. Current platform hooks or agent files +7. The current task's `implement.jsonl` / `check.jsonl` + +## Context Sources + +| Source | Purpose | +| --- | --- | +| `.trellis/workflow.md` | Workflow and next-action hints. | +| `.trellis/tasks//prd.md` | Current task requirements. | +| `.trellis/tasks//implement.jsonl` | Spec/research to read before implementation. | +| `.trellis/tasks//check.jsonl` | Spec/research to read during checking. | +| `.trellis/spec/` | Project specs. | +| `.trellis/workspace/` | Session records. | +| git status | Current working tree changes. | + +## Common Needs And Edit Points + +| Need | Edit point | +| --- | --- | +| Inject more/less information in new sessions | `session_context.py` or the platform `session-start` hook. | +| Change hints on each user input | `[workflow-state:STATUS]` block in `.trellis/workflow.md`. The `inject-workflow-state` hook is parser-only and reads the block verbatim. | +| Agent did not read specs | Task JSONL, agent prelude, `inject-subagent-context` hook. | +| Active task is lost | `active_task.py` and platform session identity propagation. | +| Change JSONL validation rules | `task_context.py`. | + +## JSONL Rules + +`implement.jsonl` / `check.jsonl` are the key context loading interface: + +```jsonl +{"file": ".trellis/spec/backend/index.md", "reason": "Backend conventions"} +{"file": ".trellis/tasks/04-28-x/research/api.md", "reason": "API research"} +``` + +Include only spec/research files. Do not put code files that will be modified into these manifests; agents read code files themselves during implementation. + +## Change Session Context + +If the user wants every new session to see more project state, edit: + +- `.trellis/scripts/common/session_context.py` +- the corresponding platform `session-start` hook + +Context cannot grow without bound. Prefer injecting indexes and paths so the AI can read detailed files on demand. + +## Change Sub-Agent Context + +First determine which mode the platform uses: + +- hook push: edit the `inject-subagent-context` hook. +- agent pull: edit the read steps in the corresponding `trellis-implement` / `trellis-check` agent file. + +In both modes, make sure the agent ultimately reads: + +1. active task +2. `prd.md` +3. `info.md` if present +4. the corresponding JSONL +5. spec/research referenced by the JSONL + +## Troubleshooting Order + +```bash +python3 ./.trellis/scripts/task.py current --source +python3 ./.trellis/scripts/task.py list-context +python3 ./.trellis/scripts/task.py validate +python3 ./.trellis/scripts/get_context.py --mode packages +``` + +Confirm the task and JSONL are correct before editing hooks/agents. diff --git a/.claude/skills/trellis-meta/references/customize-local/change-hooks.md b/.claude/skills/trellis-meta/references/customize-local/change-hooks.md new file mode 100644 index 0000000..5c1ed7a --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/change-hooks.md @@ -0,0 +1,57 @@ +# Change Local Hooks + +Hooks are the automation layer that connects a platform to Trellis. When the user wants to change "when context is injected," "how shell commands inherit a session," or "which files are read before an agent starts," hooks are usually the edit point. + +## Read These Files First + +1. Target platform settings/config, such as `.claude/settings.json`, `.codex/hooks.json`, `.cursor/hooks.json` +2. Target platform hooks directory +3. `.trellis/scripts/common/active_task.py` +4. `.trellis/scripts/common/session_context.py` +5. `.trellis/workflow.md` + +## Common Hook Types + +| Hook | Purpose | +| --- | --- | +| session-start | Injects a Trellis overview when a session starts, clears, or compacts. | +| workflow-state | Injects a state hint on each user input. | +| sub-agent context | Injects PRD/spec/research before an agent starts. | +| shell session bridge | Lets `task.py` commands in shell see the same session identity. | + +## Modification Steps + +1. Find the hook registration in settings/config. +2. Confirm the registered script path exists. +3. Read the hook script and identify inputs, outputs, and called `.trellis/scripts/`. +4. Modify hook behavior. +5. If the hook depends on workflow content, synchronize `.trellis/workflow.md`. + +## Example: Change New-Session Injection Content + +First find the session-start hook: + +```text +.claude/settings.json +.claude/hooks/session-start.py +``` + +If the hook ultimately calls `.trellis/scripts/get_context.py` or `session_context.py`, editing the local script is usually more robust than hard-coding content in the hook. + +## Example: Agent Did Not Read JSONL + +First confirm: + +```bash +python3 ./.trellis/scripts/task.py current --source +python3 ./.trellis/scripts/task.py validate +``` + +If the task and JSONL are correct, determine whether the platform uses hook push or agent pull. For hook push, edit `inject-subagent-context`; for agent pull, edit the agent file. + +## Notes + +- Settings handle registration, hook scripts handle behavior; inspect both together. +- Different platforms support different hook events. Do not directly copy another platform's settings. +- Hooks should read project-local `.trellis/`; they should not depend on Trellis upstream source paths. +- Hook failures should produce visible errors so AI does not silently lose context. diff --git a/.claude/skills/trellis-meta/references/customize-local/change-skills-or-commands.md b/.claude/skills/trellis-meta/references/customize-local/change-skills-or-commands.md new file mode 100644 index 0000000..84590a1 --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/change-skills-or-commands.md @@ -0,0 +1,78 @@ +# Change Local Skills, Commands, Prompts, And Workflows + +When the user wants to change AI entry points, auto-trigger rules, or explicit command behavior, edit skills, commands, prompts, or workflows in local platform directories. + +## Read These Files First + +1. `.trellis/workflow.md` +2. Target platform skill/command/prompt/workflow directory +3. Related agent or hook files +4. Whether project rules already exist in `.trellis/spec/` + +## Which Entry Type To Choose + +| Goal | Recommendation | +| --- | --- | +| AI should automatically know a capability | Add or modify a skill. | +| User wants to trigger manually with a command | Add or modify a command/prompt/workflow. | +| Team project conventions | Prefer `.trellis/spec/` or a project-local skill. | +| Change Trellis flow semantics | Synchronize `.trellis/workflow.md`. | + +## Modify A Skill + +A skill is usually: + +```text +/ +├── SKILL.md +└── references/ +``` + +`SKILL.md` should be short and responsible for triggering/routing. Put long content in `references/` so AI can read it on demand. + +The frontmatter description should specify when to use the skill. Example: + +```yaml +description: "Use when customizing this project's deployment workflow and release checklist." +``` + +Do not write vague descriptions such as "helpful project skill"; they can trigger incorrectly. + +## Modify A Command/Prompt/Workflow + +Explicit entry points should state: + +- How the user triggers it. +- Which `.trellis/` files to read. +- Which scripts to run. +- How to report after completion. + +If a command only repeats workflow rules, prefer making it reference/read `.trellis/workflow.md` instead of maintaining a second copy of the flow. + +## Common Paths + +| Platform | Entry directories | +| --- | --- | +| Claude Code | `.claude/skills/`, `.claude/commands/` | +| Cursor | `.cursor/skills/`, `.cursor/commands/` | +| OpenCode | `.opencode/skills/`, `.opencode/commands/` | +| Codex | `.agents/skills/`, `.codex/skills/` | +| GitHub Copilot | `.github/skills/`, `.github/prompts/` | +| Kilo / Antigravity / Windsurf | workflows + skills | + +## Add A Project-Local Skill + +If the user wants to document team-private customizations, create a project-local skill, for example: + +```text +.claude/skills/project-trellis-local/ +└── SKILL.md +``` + +For multi-platform projects, add equivalent versions in each platform skill directory, or use `.agents/skills/` on platforms that support the shared layer. + +## Notes + +- Do not mix every platform's syntax into one file. +- Do not change only one platform entry point while claiming all platforms are supported. +- Do not hide long-term engineering conventions inside a command; write them to `.trellis/spec/`. diff --git a/.claude/skills/trellis-meta/references/customize-local/change-spec-structure.md b/.claude/skills/trellis-meta/references/customize-local/change-spec-structure.md new file mode 100644 index 0000000..358de51 --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/change-spec-structure.md @@ -0,0 +1,83 @@ +# Change Local Spec Structure + +When the user wants to change the engineering conventions AI follows, add new spec layers, or adjust monorepo package mapping, edit `.trellis/spec/` and `.trellis/config.yaml`. + +## Read These Files First + +1. `.trellis/config.yaml` +2. `.trellis/spec/` +3. `.trellis/workflow.md` Phase 1.3 and Phase 3.3 +4. Current task `implement.jsonl` / `check.jsonl` + +## Common Needs + +| Need | Edit location | +| --- | --- | +| Add backend/frontend/docs/test spec layer | `.trellis/spec//` or `.trellis/spec///` | +| Add shared thinking guides | `.trellis/spec/guides/` | +| Adjust monorepo packages | `packages` in `.trellis/config.yaml` | +| Change default package | `default_package` in `.trellis/config.yaml` | +| Control spec scanning scope | `spec_scope` in `.trellis/config.yaml` | +| Make a task read a new spec | Task `implement.jsonl` / `check.jsonl` | + +## Add A Spec Layer + +Single-repository example: + +```text +.trellis/spec/security/ +├── index.md +└── auth.md +``` + +Monorepo example: + +```text +.trellis/spec/webapp/security/ +├── index.md +└── auth.md +``` + +`index.md` should include: + +- What code this layer applies to. +- Pre-Development Checklist. +- Quality Check. +- Links to specific guideline files. + +## Update Context + +Adding a spec does not mean every task automatically reads it. The current task must reference it in JSONL: + +```bash +python3 ./.trellis/scripts/task.py add-context implement ".trellis/spec/webapp/security/index.md" "Security conventions" +python3 ./.trellis/scripts/task.py add-context check ".trellis/spec/webapp/security/index.md" "Security review rules" +``` + +## Change Monorepo Packages + +Example `.trellis/config.yaml`: + +```yaml +packages: + webapp: + path: apps/web + api: + path: apps/api +default_package: webapp +``` + +After editing, run: + +```bash +python3 ./.trellis/scripts/get_context.py --mode packages +``` + +Use this output to confirm AI can see the correct packages and spec layers. + +## Notes + +- Specs are user project conventions and can be changed according to project needs. +- Do not put temporary task information into specs; put temporary information in the task. +- Do not put long-term conventions only in agents or commands; preserve them in specs. +- After changing spec structure, check whether existing task JSONL files still point to files that exist. diff --git a/.claude/skills/trellis-meta/references/customize-local/change-task-lifecycle.md b/.claude/skills/trellis-meta/references/customize-local/change-task-lifecycle.md new file mode 100644 index 0000000..a7a340f --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/change-task-lifecycle.md @@ -0,0 +1,90 @@ +# Change Local Task Lifecycle + +Task lifecycle includes creation, start, context configuration, finish, archive, parent/child tasks, and lifecycle hooks. The default customization targets are `.trellis/tasks/`, `.trellis/config.yaml`, and `.trellis/scripts/`. + +## Read These Files First + +1. `.trellis/workflow.md` +2. `.trellis/config.yaml` +3. `.trellis/scripts/task.py` +4. `.trellis/scripts/common/task_store.py` +5. `.trellis/scripts/common/task_utils.py` +6. The current task's `.trellis/tasks//task.json` + +## Common Needs And Edit Points + +| Need | Edit point | +| --- | --- | +| Automatically sync an external system after task creation | `hooks.after_create` in `.trellis/config.yaml`. | +| Automatically update status after task start | `hooks.after_start` in `.trellis/config.yaml`. | +| Run a script after task finish | `hooks.after_finish` in `.trellis/config.yaml`. | +| Clean external resources after archive | `hooks.after_archive` in `.trellis/config.yaml`. | +| Change default task fields | `.trellis/scripts/common/task_store.py`. | +| Change task parsing/search | `.trellis/scripts/common/task_utils.py`. | +| Change active task behavior | `.trellis/scripts/common/active_task.py`. | + +## lifecycle hooks + +`.trellis/config.yaml` supports: + +```yaml +hooks: + after_create: + - "python3 .trellis/scripts/hooks/my_sync.py create" + after_start: + - "python3 .trellis/scripts/hooks/my_sync.py start" + after_finish: + - "python3 .trellis/scripts/hooks/my_sync.py finish" + after_archive: + - "python3 .trellis/scripts/hooks/my_sync.py archive" +``` + +Hook commands receive the `TASK_JSON_PATH` environment variable, pointing to the current task's `task.json`. Hook failures should usually warn, but not block the main task operation. + +## Change Task Fields + +If the user wants to add project-local fields, prefer putting them under `meta` in `task.json` to avoid breaking existing scripts' assumptions about standard fields. + +Example: + +```json +"meta": { + "linearIssue": "ENG-123", + "risk": "high" +} +``` + +If standard fields really need to change, inspect every local script that reads `task.json`. + +## Change Active Task + +Active task is session-level state stored in `.trellis/.runtime/sessions/`. Do not fall back to a global `.current-task` model. If the user wants to change active task behavior, edit: + +- `.trellis/scripts/common/active_task.py` +- platform hooks or shell session bridges +- active task descriptions in `.trellis/workflow.md` + +### `task.py create` Sets the Active Pointer + +`cmd_create` in `.trellis/scripts/common/task_store.py` calls `set_active_task` best-effort right after writing the new task directory. The behavior: + +- When the calling shell carries session identity (`TRELLIS_CONTEXT_ID` env var, or any platform-specific session env that `resolve_context_key` recognizes — see `active_task.py:_ENV_SESSION_KEYS`), the per-session pointer at `.trellis/.runtime/sessions/.json` is rewritten to point at the new task. The task's `status=planning` and `[workflow-state:planning]` fires on the very next `UserPromptSubmit`. +- When session identity is unavailable (raw CLI invocation outside an AI session, or a platform that doesn't propagate identity to shell), the task directory is still created and `status=planning` is still written, but the active pointer is left untouched. The user can attach the task later with `task.py start ` once they're back in an AI session. + +This makes `[workflow-state:planning]` the live breadcrumb during the brainstorm and JSONL curation work that follows `task.py create`. The pre-R7 behavior left the breadcrumb stuck on `no_task` until `task.py start`, so the planning block was effectively dead text. + +If you fork `task.py` to add a new creation path (e.g. an external import that bypasses `cmd_create`), audit whether your path also calls `set_active_task`. Without that call, your created tasks will not surface as active. The full status writer table is in `.trellis/spec/cli/backend/workflow-state-contract.md`. + +## Modification Steps + +1. Confirm the current task with `python3 ./.trellis/scripts/task.py current --source`. +2. Read the current task's `task.json` and confirm status and fields. +3. For configuration needs, edit `.trellis/config.yaml` first. +4. For script behavior needs, then edit `.trellis/scripts/`. +5. If the AI flow changed, synchronize `.trellis/workflow.md`. + +## Do Not + +- Do not directly edit `.trellis/.runtime/sessions/` to "fix" business state. +- Do not hard-code project-private fields into scripts; prefer `meta`. +- Do not default to asking the user to fork Trellis CLI. diff --git a/.claude/skills/trellis-meta/references/customize-local/change-workflow.md b/.claude/skills/trellis-meta/references/customize-local/change-workflow.md new file mode 100644 index 0000000..4231845 --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/change-workflow.md @@ -0,0 +1,64 @@ +# Change Local Workflow + +When the user wants to change Trellis phases, next-action hints, whether to create tasks, whether to use sub-agents, or when to check/wrap up, edit `.trellis/workflow.md` first. + +## Read These Files First + +1. `.trellis/workflow.md` +2. Entry files for the current platform, such as skills/commands/prompts/workflows +3. The current task's `task.json` and `prd.md` + +## Common Needs And Edit Points + +| Need | Edit point | +| --- | --- | +| Change phase names or phase order | `Phase Index` and the corresponding Phase sections. | +| Change whether to create a task when there is no task | `[workflow-state:no_task]` state block. | +| Change the next step during planning | Phase 1 and `[workflow-state:planning]`. | +| Change whether an agent is required during in_progress | Phase 2 and `[workflow-state:in_progress]`. | +| Change wrap-up after completion | Phase 3 and `[workflow-state:completed]`. | +| Change which skill a user intent triggers | `Skill Routing` table. | + +## Modification Steps + +1. Find the relevant section in `.trellis/workflow.md`. +2. When changing rules, keep explicit trigger conditions and next actions. +3. If adding or renaming a skill/agent, synchronize the corresponding files in platform directories. +4. Workflow-state changes only need an edit to the `[workflow-state:STATUS]` block in `.trellis/workflow.md`. The hook is parser-only — it reads whatever you put in the block. Keep the opening and closing tags' STATUS strings identical (`[workflow-state:foo]…[/workflow-state:foo]`); mismatched STATUS pairs are silently dropped. +5. Make the AI reread `.trellis/workflow.md`; do not keep using rules from the old conversation. + +## Example: Relax Task Creation Requirements + +To change when task creation can be skipped, usually edit `[workflow-state:no_task]`: + +```md +[workflow-state:no_task] +Task is not required when the answer is a one-reply explanation, no files are changed, and no research is needed. +[/workflow-state:no_task] +``` + +If the formal Phase 1 flow also needs to change, synchronize the Phase 1 section. + +## Example: One Platform Does Not Use Sub-Agents + +If the user wants only one platform to avoid sub-agents, first confirm whether that platform has a separate group in the workflow. Then change Phase 2 routing for that platform group instead of deleting all `trellis-implement` / `trellis-check` instructions across platforms. + +## `/trellis:continue` Route Table + +`/trellis:continue` resumes a task by deciding which phase step to load next. The decision combines `task.json.status` with the presence of artifacts inside the task directory. The mapping is fixed in the command itself; forks that add custom statuses must extend both the workflow.md tag block and this table. + +| `status` | Artifact state | Resume at | +| --- | --- | --- | +| `planning` | `prd.md` missing | Phase 1.1 (load `trellis-brainstorm`) | +| `planning` | `prd.md` exists, `implement.jsonl` only has the seed `_example` row | Phase 1.3 (curate JSONL context) | +| `planning` | `prd.md` exists, `implement.jsonl` curated | Phase 1.4 (run `task.py start`) | +| `in_progress` | no implementation in conversation history | Phase 2.1 (`trellis-implement`) | +| `in_progress` | implementation done, no `trellis-check` run | Phase 2.2 (`trellis-check`) | +| `in_progress` | check passed | Phase 3.1 (verify quality + spec update) | +| `completed` | task is still in active tree | Phase 3.5 (run `/trellis:finish-work` to archive) | + +When you add a custom status (e.g. `in-review`), add a `[workflow-state:in-review]` block in `.trellis/workflow.md` for the per-turn breadcrumb AND extend this route table — usually by editing the `/trellis:continue` command file (`.{platform}/commands/trellis/continue.md` or equivalent) to add a row that decides where to resume from. Without the route entry, `/trellis:continue` will fall through to a default branch and the user will not land on the step you intended. + +## Notes + +`.trellis/workflow.md` is the local project workflow, not an immutable template. The user can adapt it to team habits. After editing it, platform entry files may still contain old descriptions, so inspect them too. diff --git a/.claude/skills/trellis-meta/references/customize-local/overview.md b/.claude/skills/trellis-meta/references/customize-local/overview.md new file mode 100644 index 0000000..b53b090 --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/overview.md @@ -0,0 +1,55 @@ +# Local Customization Overview + +This directory is for local AI working in a user project where Trellis was installed through npm and `trellis init` has already been run. The AI should modify generated `.trellis/` and platform directories inside the project, not Trellis CLI upstream source code. + +## First Determine What The User Actually Wants To Change + +| User wording | Read first | +| --- | --- | +| "Change the Trellis flow / phases / next prompt" | `change-workflow.md` | +| "Change task creation, status, archive, or hooks" | `change-task-lifecycle.md` | +| "AI did not read context / change injected content" | `change-context-loading.md` | +| "A platform hook is not behaving as expected" | `change-hooks.md` | +| "Change implement/check/research agent behavior" | `change-agents.md` | +| "Add a skill/command/workflow/prompt" | `change-skills-or-commands.md` | +| "Adjust the project spec structure" | `change-spec-structure.md` | +| "Add team conventions and local notes" | `add-project-local-conventions.md` | + +## General Operation Order + +1. **Confirm platform and directories**: inspect which directories exist, such as `.claude/`, `.codex/`, `.cursor/`. +2. **Confirm the current active task**: run `python3 ./.trellis/scripts/task.py current --source`. +3. **Read the local source of truth**: prefer `.trellis/workflow.md`, `.trellis/config.yaml`, and relevant platform files. +4. **Modify narrowly**: edit only files related to the user's request. +5. **Synchronize semantics**: if a shared flow changes, check whether platform entry points also need changes; if a platform entry changes, check whether `.trellis/workflow.md` still agrees. + +## Local File Priority + +| Layer | Files | +| --- | --- | +| Workflow | `.trellis/workflow.md` | +| Project configuration | `.trellis/config.yaml` | +| Task material | `.trellis/tasks//` | +| Project specs | `.trellis/spec/` | +| Runtime scripts | `.trellis/scripts/` | +| Platform integration | `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, and similar directories | +| Shared skill | `.agents/skills/` | + +## Things Not To Do By Default + +- Do not edit the global npm install directory. +- Do not edit `node_modules/@mindfoldhq/trellis`. +- Do not assume the user has the Trellis GitHub repository. +- Do not overwrite local files already modified by the user with default templates. +- Do not put team project rules into public `trellis-meta`; project rules belong in `.trellis/spec/` or a local skill. + +## When To Inspect Upstream Source + +Switch to an upstream source-code perspective only when the user explicitly expresses one of these goals: + +- "I want to open a PR to Trellis" +- "I want to change npm package publish contents" +- "I want to fork Trellis" +- "I want to modify the generation logic for `trellis init/update`" + +Otherwise, default to modifying local Trellis files inside the user project. diff --git a/.claude/skills/trellis-meta/references/local-architecture/context-injection.md b/.claude/skills/trellis-meta/references/local-architecture/context-injection.md new file mode 100644 index 0000000..fae6fa5 --- /dev/null +++ b/.claude/skills/trellis-meta/references/local-architecture/context-injection.md @@ -0,0 +1,68 @@ +# Local Context Injection System + +Trellis context injection aims to make AI read the right files at the right time instead of relying on model memory. In a user project, injection is implemented by `.trellis/` scripts together with platform hooks, agents, and skills. + +## Injected Context Types + +| Type | Source | Purpose | +| --- | --- | --- | +| session context | `.trellis/scripts/get_context.py` | Current developer, git status, active task, active tasks, journal, packages. | +| workflow context | `.trellis/workflow.md` | Current Trellis flow and next action. | +| spec context | `.trellis/spec/` + task JSONL | Specs that must be followed during implementation/checking. | +| task context | `.trellis/tasks//prd.md`, `info.md`, `research/` | Current task requirements, design, and research. | +| platform context | Platform hooks/settings/agents | Lets different AI tools read the files above through their own mechanisms. | + +## session-start + +Platforms with session-start support inject a Trellis overview when a session starts, clears, compacts, or receives a similar event. Injected content usually includes: + +- workflow summary. +- current task status. +- active tasks. +- spec index paths. +- developer identity and git status. + +If the user feels the AI does not know the current task in a new session, first check whether the platform's session-start hook or equivalent mechanism is installed and running. + +## workflow-state + +workflow-state is a lightweight hint injected around each user turn. Based on current task status, it selects a block from `.trellis/workflow.md`, such as `no_task`, `planning`, `in_progress`, or `completed`. + +If the user wants to change "what the AI should do next in a given state," edit the corresponding state block in `.trellis/workflow.md` first. + +## sub-agent context + +Implement and check agents need task context. Trellis has two loading modes: + +1. **hook push**: a platform hook injects `prd.md` and the files referenced by `implement.jsonl` / `check.jsonl` before the agent starts. +2. **agent pull**: the agent definition instructs the agent to read the active task, PRD, and JSONL context after startup. + +In both modes, JSONL files in the task directory are the key interface. + +## JSONL Reading Rules + +`implement.jsonl` and `check.jsonl` contain one JSON object per line: + +```jsonl +{"file": ".trellis/spec/backend/index.md", "reason": "Backend rules"} +``` + +Readers should skip seed rows without a `file` field. When configuring JSONL, the AI should include only spec/research files, not pre-register code files that will be modified. + +## Active Task And Context Key + +Active task state lives in `.trellis/.runtime/sessions/` and is isolated per session. Hooks try to resolve the context key from platform events, environment variables, transcript paths, or `TRELLIS_CONTEXT_ID`. + +If shell commands cannot see the same context key, `task.py current --source` may report no active task. In that case, check whether the platform passes session identity into the shell instead of hand-writing a global current-task file. + +## Local Customization Points + +| Need | Edit location | +| --- | --- | +| Change session-start injected content | The platform's `session-start` hook or plugin file. | +| Change per-turn workflow-state rules | `[workflow-state:STATUS]` block in `.trellis/workflow.md`. The platform workflow-state hook parses these blocks verbatim and embeds no fallback text. | +| Change how sub-agents read context | Platform agent definitions, the `inject-subagent-context` hook, or agent preludes. | +| Change JSONL validation/display | `.trellis/scripts/common/task_context.py`. | +| Change active task resolution | `.trellis/scripts/common/active_task.py`. | + +When modifying context injection, verify two things: new sessions can see the correct task, and sub-agents can see the correct PRD/spec/research. diff --git a/.claude/skills/trellis-meta/references/local-architecture/generated-files.md b/.claude/skills/trellis-meta/references/local-architecture/generated-files.md new file mode 100644 index 0000000..66f832d --- /dev/null +++ b/.claude/skills/trellis-meta/references/local-architecture/generated-files.md @@ -0,0 +1,80 @@ +# Local Files Generated After Init + +`trellis init` writes the Trellis runtime into the user project. Later, `trellis update` tries to update Trellis-managed template files, but it uses `.trellis/.template-hashes.json` to determine which files have already been modified by the user. + +This page only describes files that are visible and editable inside the user project. + +## `.trellis/` + +```text +.trellis/ +├── workflow.md +├── config.yaml +├── .developer +├── .version +├── .template-hashes.json +├── .runtime/ +├── scripts/ +├── spec/ +├── tasks/ +└── workspace/ +``` + +| Path | Usually editable? | Notes | +| --- | --- | --- | +| `.trellis/workflow.md` | Yes | Local workflow documentation and AI routing rules. | +| `.trellis/config.yaml` | Yes | Project configuration, hooks, packages, journal line limits, and related settings. | +| `.trellis/spec/` | Yes | Project specs, intended to be updated regularly by users and AI. | +| `.trellis/tasks/` | Yes | Task material and research artifacts, maintained by the task workflow. | +| `.trellis/workspace/` | Yes | Session records, usually written by `add_session.py`. | +| `.trellis/scripts/` | Carefully | Local runtime. It can be customized, but only after understanding the call chain. | +| `.trellis/.runtime/` | No | Runtime state, usually written automatically by hooks/scripts. | +| `.trellis/.developer` | Carefully | Current developer identity. | +| `.trellis/.version` | No | Trellis version record used by update/migration logic. | +| `.trellis/.template-hashes.json` | No | Template hash record. Do not hand-write business rules here. | + +## Platform Directories + +Different platforms generate different directories. Common categories: + +| Category | Example paths | Purpose | +| --- | --- | --- | +| hooks | `.claude/hooks/`, `.codex/hooks/`, `.cursor/hooks/` | Inject session context, workflow-state, and sub-agent context. | +| settings | `.claude/settings.json`, `.codex/hooks.json`, `.qoder/settings.json` | Tell the platform when to run hooks or plugins. | +| agents | `.claude/agents/`, `.codex/agents/`, `.kiro/agents/` | Define agents such as `trellis-research`, `trellis-implement`, and `trellis-check`. | +| skills | `.claude/skills/`, `.agents/skills/`, `.qoder/skills/` | Skills that auto-trigger or can be read by AI. | +| commands/prompts/workflows | `.cursor/commands/`, `.github/prompts/`, `.windsurf/workflows/` | Explicit user-invoked command or workflow entry points. | + +When modifying a platform directory, also confirm whether `.trellis/workflow.md` still describes the same flow. + +## Meaning Of Template Hashes + +`.trellis/.template-hashes.json` records the content hash from the last time Trellis wrote a template file. `trellis update` uses it to distinguish three cases: + +| Case | Update behavior | +| --- | --- | +| File was not modified by the user | It can be updated automatically. | +| File was modified by the user | Prompt the user to overwrite, keep, or generate `.new`. | +| File is no longer a current template | It may be deleted, renamed, or preserved according to migration rules. | + +When an AI customizes local Trellis files, it does not need to maintain hashes manually. It is normal for Trellis update to recognize the result as "modified by the user." + +## Local Customization Boundaries + +Editable by default: + +- `.trellis/workflow.md` +- `.trellis/config.yaml` +- `.trellis/spec/**` +- `.trellis/scripts/**` +- Platform hooks, settings, agents, skills, commands, prompts, and workflows + +Do not edit by default: + +- Global npm install directory +- `node_modules/@mindfoldhq/trellis` +- Trellis GitHub repository source code +- Concrete state files under `.trellis/.runtime/**` +- Hash contents inside `.trellis/.template-hashes.json` + +Switch to the Trellis CLI source-code perspective only when the user explicitly wants to contribute upstream. diff --git a/.claude/skills/trellis-meta/references/local-architecture/overview.md b/.claude/skills/trellis-meta/references/local-architecture/overview.md new file mode 100644 index 0000000..99c7f73 --- /dev/null +++ b/.claude/skills/trellis-meta/references/local-architecture/overview.md @@ -0,0 +1,51 @@ +# Local Trellis Architecture Overview + +`trellis-meta` is for user projects that have already run `trellis init`. The user's machine usually has only the npm-installed `trellis` command plus the Trellis files generated inside the project; it may not have the Trellis CLI source code. + +Therefore, when an AI uses this skill, the default customization target is local files inside the user project: + +- `.trellis/`: workflow, tasks, specs, memory, scripts, and runtime state. +- Platform directories: `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, `.kiro/`, `.gemini/`, `.qoder/`, `.codebuddy/`, `.github/`, `.factory/`, `.pi/`, `.kilocode/`, `.agent/`, `.windsurf/`, and similar directories. +- Shared skill layer: `.agents/skills/`. + +Do not default to guiding the user to fork the Trellis CLI repository. Treat upstream source code as the operating target only when the user explicitly says they want to change Trellis upstream source, publish an npm package, or contribute a PR. + +## Local System Model + +Trellis provides three layers inside a user project: + +1. **Workflow layer**: `.trellis/workflow.md` defines phases, routing, next actions, and prompt blocks. +2. **Persistence layer**: `.trellis/tasks/`, `.trellis/spec/`, and `.trellis/workspace/` store tasks, specs, and session memory. +3. **Platform integration layer**: hooks, settings, agents, skills, commands, prompts, and workflows in platform directories connect the Trellis workflow to different AI tools. + +All three layers live inside the user project, so an AI can read and modify them directly. + +## Core Paths + +| Path | Purpose | +| --- | --- | +| `.trellis/workflow.md` | Workflow phases, skill routing, and workflow-state prompt blocks. | +| `.trellis/config.yaml` | Project configuration, task lifecycle hooks, monorepo package configuration, and journal configuration. | +| `.trellis/spec/` | The user's project-specific coding conventions and thinking guides. | +| `.trellis/tasks/` | Each task's PRD, technical notes, research files, and JSONL context. | +| `.trellis/workspace/` | Per-developer journals and cross-session memory. | +| `.trellis/scripts/` | Local Python runtime used by commands, hooks, and context injection. | +| `.trellis/.runtime/` | Session-level runtime state, such as the current task pointer. | +| `.trellis/.template-hashes.json` | Template hashes for Trellis-managed files, used by update to determine whether local files were modified by the user. | + +## AI Customization Principles + +1. **Find the local source of truth first**: Do not edit from memory. Read `.trellis/workflow.md`, `.trellis/config.yaml`, the relevant platform directory, and related task files first. +2. **Edit the user project, not the npm package cache**: Modify generated files inside the project, not `node_modules` or the global npm install directory. +3. **Keep platform files aligned with `.trellis/`**: If workflow routing changes, also check whether platform skills or commands still describe the same flow. +4. **Put project-specific rules in `.trellis/spec/` or a local skill**: Do not put team conventions into `trellis-meta`. +5. **Preserve user changes**: If a file was already modified locally, work from the current content instead of overwriting it with a default template. + +## How To Use This Directory + +- To understand which files exist after init, read `generated-files.md`. +- To change phases, routing, or next actions, read `workflow.md`. +- To change the task model, JSONL context, or active task behavior, read `task-system.md`. +- To change coding convention injection, read `spec-system.md`. +- To understand journals and cross-session memory, read `workspace-memory.md`. +- To change hooks or sub-agent context loading, read `context-injection.md`. diff --git a/.claude/skills/trellis-meta/references/local-architecture/spec-system.md b/.claude/skills/trellis-meta/references/local-architecture/spec-system.md new file mode 100644 index 0000000..61281f0 --- /dev/null +++ b/.claude/skills/trellis-meta/references/local-architecture/spec-system.md @@ -0,0 +1,102 @@ +# Local Spec System + +`.trellis/spec/` is the user's project-specific engineering spec library. Trellis is not about making AI memorize conventions; it injects relevant specs or requires the AI to read them at the right time. + +## Directory Model + +A common single-repository structure: + +```text +.trellis/spec/ +├── backend/ +│ ├── index.md +│ └── ... +├── frontend/ +│ ├── index.md +│ └── ... +└── guides/ + ├── index.md + └── ... +``` + +A common monorepo structure: + +```text +.trellis/spec/ +├── cli/ +│ ├── backend/ +│ │ ├── index.md +│ │ └── ... +│ └── unit-test/ +│ ├── index.md +│ └── ... +├── docs-site/ +│ └── docs/ +│ ├── index.md +│ └── ... +└── guides/ + ├── index.md + └── ... +``` + +`index.md` is the entry point for each layer. It should list the Pre-Development Checklist and Quality Check. Specific guidelines live in other Markdown files in the same directory. + +## Package Configuration + +`.trellis/config.yaml` can declare packages: + +```yaml +packages: + cli: + path: packages/cli + docs-site: + path: docs-site + type: submodule +default_package: cli +``` + +The AI can run: + +```bash +python3 ./.trellis/scripts/get_context.py --mode packages +``` + +This command lists packages and spec layers for the current project. Use this output as the reference when configuring context JSONL. + +## How Specs Enter Tasks + +Before a task enters implementation, Phase 1.3 should write relevant specs into `implement.jsonl` / `check.jsonl`: + +```jsonl +{"file": ".trellis/spec/cli/backend/index.md", "reason": "CLI backend conventions"} +{"file": ".trellis/spec/cli/unit-test/conventions.md", "reason": "Test expectations"} +``` + +Sub-agents or platform preludes read these JSONL files and load the referenced specs. On platforms without sub-agent support, the AI should read the relevant specs directly according to the workflow. + +## What Specs Should Contain + +Specs should contain executable engineering conventions for the project, not generic best practices: + +- Where files should live. +- How error handling should be expressed. +- Input/output contracts for APIs, hooks, and commands. +- Patterns that are forbidden. +- Cases that require tests. +- Project-specific pitfalls and how to avoid them. + +When the AI learns a new rule during implementation or debugging, it should update `.trellis/spec/` rather than only summarizing it in chat. + +## Local Customization Points + +| Need | Edit location | +| --- | --- | +| Add a new spec layer | `.trellis/spec///index.md` and corresponding guideline files. | +| Change monorepo spec mapping | `packages` / `default_package` / `spec_scope` in `.trellis/config.yaml`. | +| Change which specs AI reads before implementation | The task's `implement.jsonl`. | +| Change which specs AI reads during checking | The task's `check.jsonl`. | +| Change when specs should be updated | Phase 3.3 in `.trellis/workflow.md` and the `trellis-update-spec` skill. | + +## Boundaries + +`.trellis/spec/` is the user's project specification, not a permanent copy of Trellis built-in templates. The AI should encourage the user to update it according to the actual project code instead of treating Trellis default templates as immutable documents. diff --git a/.claude/skills/trellis-meta/references/local-architecture/task-system.md b/.claude/skills/trellis-meta/references/local-architecture/task-system.md new file mode 100644 index 0000000..64ad00d --- /dev/null +++ b/.claude/skills/trellis-meta/references/local-architecture/task-system.md @@ -0,0 +1,101 @@ +# Local Task System + +The Trellis task system is stored entirely under `.trellis/tasks/` in the user project. Each task is a directory containing requirements, context, research, state, and relationship information. + +## Task Directory Structure + +```text +.trellis/tasks/ +├── 04-28-example-task/ +│ ├── task.json +│ ├── prd.md +│ ├── info.md +│ ├── implement.jsonl +│ ├── check.jsonl +│ └── research/ +└── archive/ + └── 2026-04/ +``` + +| File | Purpose | +| --- | --- | +| `task.json` | Task metadata: status, assignee, priority, branch, parent/child tasks, and similar fields. | +| `prd.md` | Requirements document; the most important business context during implementation. | +| `info.md` | Optional technical design. | +| `implement.jsonl` | List of spec/research files the implement agent must read first. | +| `check.jsonl` | List of spec/research files the check agent must read first. | +| `research/` | Research artifacts. Complex findings should not live only in chat. | + +## `task.json` + +`task.json` records task status and metadata. Common fields: + +| Field | Meaning | +| --- | --- | +| `id` / `name` / `title` | Task identity and title. | +| `status` | Status such as `planning`, `in_progress`, `review`, or `completed`. | +| `priority` | `P0`, `P1`, `P2`, `P3`. | +| `creator` / `assignee` | Creator and assignee. | +| `package` | Target package in a monorepo; may be empty. | +| `branch` / `base_branch` | Working branch and PR target branch. | +| `children` / `parent` | Parent/child task relationships. | +| `commit` / `pr_url` | Commit and PR information after completion. | +| `meta` | Extension fields. | + +The AI should not treat phase numbers as task status. Task progress is mainly determined by `status`, `prd.md`, whether JSONL context is configured, and the phase descriptions in `workflow.md`. + +## Active Task + +The user sees a "current task," but Trellis stores active task state per session. + +```text +.trellis/.runtime/sessions/.json +``` + +`task.py start` writes the task path into the runtime session file for the current session. `task.py current --source` shows the current task and where it came from. Different AI windows can point to different tasks without overwriting each other. + +If the platform or shell environment has no stable session identity, `task.py start` may be unable to set the active task. The AI should read the error, inspect the platform hook/session environment, and not fall back to a shared global pointer. + +## JSONL Context + +`implement.jsonl` and `check.jsonl` are context manifests for sub-agents to read first. + +Format: + +```jsonl +{"file": ".trellis/spec/cli/backend/index.md", "reason": "Backend conventions"} +{"file": ".trellis/tasks/04-28-example/research/api.md", "reason": "API research"} +``` + +Rules: + +- Include spec and research files. +- Do not include code files that are about to be modified. +- Do not treat temporary conclusions in chat as the only context. +- Seed rows have no `file` field; they only prompt the AI to fill in real entries. + +## Common Commands + +```bash +python3 ./.trellis/scripts/task.py create "" --slug <slug> +python3 ./.trellis/scripts/task.py start <task> +python3 ./.trellis/scripts/task.py current --source +python3 ./.trellis/scripts/task.py add-context <task> implement <file> <reason> +python3 ./.trellis/scripts/task.py validate <task> +python3 ./.trellis/scripts/task.py finish +python3 ./.trellis/scripts/task.py archive <task> +``` + +When modifying the task system, the AI should prefer script commands to maintain structure. Edit JSON/Markdown directly only when scripts do not cover the need. + +## Local Customization Points + +| Need | Edit location | +| --- | --- | +| Change the default task template | `.trellis/scripts/common/task_store.py` and task creation instructions. | +| Change status semantics | `.trellis/workflow.md`, workflow-state hook logic, and task usage conventions. | +| Add task lifecycle actions | `hooks.after_*` in `.trellis/config.yaml`. | +| Change context rules | Phase 1.3 in `.trellis/workflow.md` and related platform agent/hook instructions. | +| Change archive policy | `.trellis/scripts/common/task_store.py` / `task_utils.py`. | + +These are local files in the user project. Do not default to editing Trellis CLI source code unless the user wants to contribute upstream. diff --git a/.claude/skills/trellis-meta/references/local-architecture/workflow.md b/.claude/skills/trellis-meta/references/local-architecture/workflow.md new file mode 100644 index 0000000..f0659ff --- /dev/null +++ b/.claude/skills/trellis-meta/references/local-architecture/workflow.md @@ -0,0 +1,75 @@ +# Local Workflow System + +`.trellis/workflow.md` is the Trellis workflow source of truth inside the user project. An AI does not need Trellis source code to understand how the current project should move tasks forward; this file is enough. + +## File Responsibilities + +`.trellis/workflow.md` has three responsibilities: + +1. **Explain workflow phases**: Plan, Execute, Finish. +2. **Define skill routing**: which skill or agent the AI should use when the user expresses a certain intent. +3. **Provide workflow-state prompt blocks**: hooks can inject the prompt block for the current state into the conversation. + +## Current Phase Model + +```text +Phase 1: Plan -> clarify what to build, produce prd.md and required research +Phase 2: Execute -> implement against the PRD and specs, then check +Phase 3: Finish -> final verification, preserve lessons, and wrap up +``` + +Each phase contains numbered steps, such as `1.3 Configure context`. These numbers are not runtime fields in `task.json`; they are workflow structure for AI and humans to read. + +## Skill Routing + +`workflow.md` separates routing by platform capability: + +- Platforms with sub-agent support: dispatch `trellis-implement` by default for implementation and `trellis-check` for checking. +- Platforms without sub-agent support: the main session reads skills such as `trellis-before-dev`, then executes directly. + +When changing local AI behavior, update the routing descriptions in `workflow.md` first, then check whether the corresponding platform skill, command, or agent files need to stay in sync. + +## Workflow-State Prompt Blocks + +The bottom of `workflow.md` can contain state blocks like this: + +```text +[workflow-state:no_task] +... +[/workflow-state:no_task] +``` + +Hooks choose the right block based on current task status and inject it into the conversation. Common states include: + +| State | Meaning | +| --- | --- | +| `no_task` | The current session has no active task. | +| `planning` | The task is still in requirements, research, or context configuration. | +| `in_progress` | The task has entered implementation and checking. | +| `completed` | The task is complete and waiting for wrap-up or archive. | + +If the user wants to change policies such as "whether to create a task when there is no task," "when task creation may be skipped," or "whether sub-agents are required," edit these state blocks and the routing table above them. + +## Local Modification Patterns + +Common changes: + +| Goal | Edit point | +| --- | --- | +| Add a phase | Update the Phase Index, phase body, routing, and state blocks. | +| Change task creation policy | Update the `no_task` state block and Phase 1 description. | +| Change the default implementation/check path | Update Phase 2 and skill routing. | +| Change the wrap-up flow | Update Phase 3 and `finish-work` related descriptions. Note the current split: Phase 3.4 = AI-driven code commits (batched, user-confirmed), Phase 3.5 = `/finish-work` (archive + record session). `/finish-work` refuses to run if the working tree is dirty. | +| Change platform differences | Update routing descriptions grouped by platform. | + +After editing, make the AI reread `.trellis/workflow.md`; do not assume the flow from the old conversation is still valid. + +## Relationship To Platform Files + +`workflow.md` is the semantic center of the local workflow, but each platform can also have its own entry files: + +- skills, such as `trellis-brainstorm` and `trellis-check`. +- commands/prompts/workflows, such as continue and finish-work. +- hooks, such as session-start or workflow-state injection. + +If only `workflow.md` changes, platform entry files may still contain old language. When the user wants to change "what the AI actually does," also inspect the relevant platform directory. diff --git a/.claude/skills/trellis-meta/references/local-architecture/workspace-memory.md b/.claude/skills/trellis-meta/references/local-architecture/workspace-memory.md new file mode 100644 index 0000000..c2958f2 --- /dev/null +++ b/.claude/skills/trellis-meta/references/local-architecture/workspace-memory.md @@ -0,0 +1,71 @@ +# Local Workspace Memory System + +`.trellis/workspace/` stores cross-session memory. Its purpose is to let AI and humans understand what happened before across different windows and different days. + +## Directory Structure + +```text +.trellis/workspace/ +├── index.md +└── <developer>/ + ├── index.md + ├── journal-1.md + └── journal-2.md +``` + +| File | Purpose | +| --- | --- | +| `.trellis/.developer` | Current developer identity. | +| `.trellis/workspace/index.md` | Global workspace overview. | +| `.trellis/workspace/<developer>/index.md` | Session index for a developer. | +| `.trellis/workspace/<developer>/journal-N.md` | Session journal. | + +## Developer Identity + +Run this the first time: + +```bash +python3 ./.trellis/scripts/init_developer.py <name> +``` + +This creates `.trellis/.developer` and the corresponding workspace directory. The AI should not change developer identity casually; if the identity is wrong, first confirm who is using the current project. + +## Journal + +`journal-N.md` records completed or partially completed work from each session. By default, each journal holds about 2000 lines; after that it rotates to the next file. + +Common command for recording a session: + +```bash +python3 ./.trellis/scripts/add_session.py \ + --title "Session title" \ + --summary "What changed" \ + --commit "abc1234" +``` + +Planning or review work without a commit can also be recorded by using `--no-commit` or an empty commit value. + +## Relationship Between Workspace Memory And Tasks + +| System | What it stores | +| --- | --- | +| `.trellis/tasks/` | Requirements, design, research, and state for a specific task. | +| `.trellis/workspace/` | Work records across tasks and sessions. | +| `.trellis/spec/` | Engineering knowledge preserved as long-term conventions. | + +If information is only useful for the current task, put it in the task directory. +If information describes what happened in the current session, put it in the workspace journal. +If information should be followed every time code is written in the future, put it in spec. + +## Local Customization Points + +| Need | Edit location | +| --- | --- | +| Change maximum journal lines | `max_journal_lines` in `.trellis/config.yaml`. | +| Change session auto-commit message | `session_commit_message` in `.trellis/config.yaml`. | +| Change session content format | `.trellis/scripts/add_session.py`. | +| Change how workspace is displayed in context | `.trellis/scripts/common/session_context.py`. | + +## AI Usage Rules + +The AI should not treat workspace as the only source of truth. When resuming a task, read the current task first, then use workspace for background. After a task is complete, record important process notes in workspace; if long-term rules emerged, update spec. diff --git a/.claude/skills/trellis-meta/references/platform-files/agents.md b/.claude/skills/trellis-meta/references/platform-files/agents.md new file mode 100644 index 0000000..efbacfa --- /dev/null +++ b/.claude/skills/trellis-meta/references/platform-files/agents.md @@ -0,0 +1,79 @@ +# Agents + +Trellis agent files define specialized roles. Common Trellis agents in a user project are: + +- `trellis-research` +- `trellis-implement` +- `trellis-check` + +File locations and formats differ by platform, but responsibility boundaries should stay consistent. + +## Agent Responsibilities + +| Agent | Responsibility | +| --- | --- | +| `trellis-research` | Investigate the question and write findings into the current task's `research/`. | +| `trellis-implement` | Implement against `prd.md`, `info.md`, `implement.jsonl`, and related spec/research. | +| `trellis-check` | Review changes, fix discovered issues, and run necessary checks. | + +Agent files should not become generic chat prompts. They should define input sources, write boundaries, whether code may be changed, and how results are reported. + +## Common Paths + +| Platform | Agent path | +| --- | --- | +| Claude Code | `.claude/agents/trellis-*.md` | +| Cursor | `.cursor/agents/trellis-*.md` | +| OpenCode | `.opencode/agents/trellis-*.md` | +| Codex | `.codex/agents/trellis-*.toml` | +| Kiro | `.kiro/agents/trellis-*.json` | +| Gemini CLI | `.gemini/agents/trellis-*.md` | +| Qoder | `.qoder/agents/trellis-*.md` | +| CodeBuddy | `.codebuddy/agents/trellis-*.md` | +| Factory Droid | `.factory/droids/trellis-*.md` | +| Pi Agent | `.pi/agents/trellis-*.md` | + +GitHub Copilot agent/prompt support is provided by a combination of directories such as `.github/agents/`, `.github/prompts/`, and `.github/skills/`; inspect the files actually generated in the user project. + +Main-session workflow platforms such as Kilo, Antigravity, and Windsurf may not have Trellis sub-agent files. They usually rely on workflows/skills to guide the main session. + +## Two Context Loading Modes + +### hook push + +The platform hook injects task context before the agent starts. The agent file itself can focus more on responsibilities and boundaries. + +Common on platforms that support agent hooks. + +### agent pull + +The agent file instructs the agent to read after startup: + +- `python3 ./.trellis/scripts/task.py current --source` +- current task `prd.md` +- `info.md` +- `implement.jsonl` or `check.jsonl` +- spec/research files referenced by JSONL + +This mode fits platforms whose hooks cannot reliably rewrite sub-agent prompts. + +## Local Change Scenarios + +| User need | Edit location | +| --- | --- | +| Implement agent must follow extra restrictions | The platform's `trellis-implement` agent file. | +| Check agent must run project-specific commands | `trellis-check` agent file, and `.trellis/spec/` if needed. | +| Research agent must output a fixed format | `trellis-research` agent file. | +| Agent cannot read task context | Agent prelude or `inject-subagent-context` hook. | +| Add a project-specific agent | Platform agent directory + related workflow/command/skill entry point. | + +## Modification Principles + +1. **Keep responsibilities single-purpose**. Do not mix research, implement, and check responsibilities into one agent. +2. **Specify the read order**. Agents must know to start from the active task and then find the PRD and JSONL. +3. **Specify write boundaries**. Research usually only writes `research/`; implement can write code; check can fix issues. +4. **Keep semantics synchronized in multi-platform projects**. If the user configured Claude, Codex, and Cursor together, decide whether changes to one platform's agent also need to be applied to others. + +## Do Not Default To Editing Upstream Templates + +Local AI should default to modifying platform agent files inside the user project. Discuss upstream template source only when the user explicitly wants to contribute the change back to Trellis. diff --git a/.claude/skills/trellis-meta/references/platform-files/hooks-and-settings.md b/.claude/skills/trellis-meta/references/platform-files/hooks-and-settings.md new file mode 100644 index 0000000..94156a8 --- /dev/null +++ b/.claude/skills/trellis-meta/references/platform-files/hooks-and-settings.md @@ -0,0 +1,69 @@ +# Hooks And Settings + +Hooks/settings are the entry layer that connects a platform to Trellis. They decide which scripts, plugins, or extensions a platform runs for which events. + +## Settings Responsibilities + +settings/config files usually register: + +- session-start hook: injects a Trellis overview when a new session starts or context resets. +- workflow-state hook: parses `[workflow-state:STATUS]` blocks from `.trellis/workflow.md` and emits the body matching the current task `status` on each user input. Parser-only; the script does not embed fallback content. +- sub-agent context hook: injects task context when implementation/check/research agents start. +- shell/session bridge: lets shell commands see the same Trellis session identity. +- platform plugin or extension entry points. + +Common files: + +| Platform | settings/config | +| --- | --- | +| Claude Code | `.claude/settings.json` | +| Cursor | `.cursor/hooks.json` | +| Codex | `.codex/hooks.json`, `.codex/config.toml` | +| OpenCode | `.opencode/package.json`, `.opencode/plugins/*` | +| Kiro | `.kiro/hooks/` + platform config | +| Gemini CLI | `.gemini/settings.json` | +| Qoder | `.qoder/settings.json` | +| CodeBuddy | `.codebuddy/settings.json` | +| GitHub Copilot | `.github/copilot/hooks.json` | +| Factory Droid | `.factory/settings.json` | +| Pi Agent | `.pi/settings.json`, `.pi/extensions/trellis/` | + +Whether these files exist in a project depends on which `trellis init --<platform>` flags the user ran. + +## Hook Script Types + +| Script | Purpose | +| --- | --- | +| `session-start.py` | Generates session-start context. | +| `inject-workflow-state.py` | Parses `[workflow-state:STATUS]` blocks in `.trellis/workflow.md` and emits the body matching the current task status. Falls back to `Refer to workflow.md for current step.` when no matching block exists. | +| `inject-subagent-context.py` | Injects PRD, JSONL context, and related spec/research into sub-agents. | +| `inject-shell-session-context.py` | Lets shell commands inherit Trellis session identity. | + +Not every platform has every hook. Do not copy files from another platform just because a platform lacks a hook; first confirm whether that platform supports the corresponding event. + +## Local Change Scenarios + +| User need | Edit location | +| --- | --- | +| AI should see more/less context in a new session | Platform `session-start` hook. | +| Per-turn hint policy should change | `[workflow-state:STATUS]` block in `.trellis/workflow.md`. The hook parses workflow.md verbatim — no script edit required. | +| Sub-agent cannot read PRD/spec | `inject-subagent-context` hook or agent prelude. | +| `task.py current` in shell has no active task | Shell/session bridge hook or platform environment variable configuration. | +| Disable an automatic injection | The corresponding hook registration in settings/config. | + +## Modification Principles + +1. **Settings wire things up; hooks define behavior**. If only the hook changes, the platform may never call it. If only settings change, behavior may not change. +2. **Confirm platform event names first**. Different platforms use different names for SessionStart, UserPromptSubmit, AgentSpawn, shell execution, and similar events. +3. **Hooks read local `.trellis/`, not upstream source**. `.trellis/scripts/` and `.trellis/workflow.md` in the user project are the default targets. +4. **Errors must be visible**. Hook failures should tell the user what was not injected instead of silently leaving the AI without context. + +## Troubleshooting Path + +If the user says "AI did not read Trellis state": + +1. Check whether the platform settings register the hook. +2. Check whether the hook file exists. +3. Manually run the `.trellis/scripts/get_context.py` or `task.py current --source` command that the hook depends on. +4. Check whether active task state exists in `.trellis/.runtime/sessions/`. +5. Check whether the platform shell passes session identity. diff --git a/.claude/skills/trellis-meta/references/platform-files/overview.md b/.claude/skills/trellis-meta/references/platform-files/overview.md new file mode 100644 index 0000000..60ae1df --- /dev/null +++ b/.claude/skills/trellis-meta/references/platform-files/overview.md @@ -0,0 +1,59 @@ +# Platform Files Overview + +Trellis connects the same local architecture to different AI tools. `.trellis/` stores the shared runtime; platform directories store adapter files that define how each AI tool enters Trellis. + +When a local AI modifies Trellis, it should distinguish two file categories first: + +- **Shared files**: `.trellis/workflow.md`, `.trellis/tasks/`, `.trellis/spec/`, `.trellis/scripts/`. +- **Platform files**: `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, `.kiro/`, `.gemini/`, `.qoder/`, `.codebuddy/`, `.github/`, `.factory/`, `.pi/`, `.kilocode/`, `.agent/`, `.windsurf/`, and similar directories. + +Platform files do not store business state. They let the corresponding AI tool read Trellis state, call Trellis scripts, and load Trellis skills/agents/hooks. + +## Platform File Categories + +| Category | Common paths | Purpose | +| --- | --- | --- | +| settings/config | `.claude/settings.json`, `.codex/hooks.json`, `.qoder/settings.json` | Register hooks, plugins, extensions, or platform behavior. | +| hooks/plugins/extensions | `.claude/hooks/`, `.opencode/plugins/`, `.pi/extensions/` | Inject context at session start, user input, agent startup, shell execution, and similar events. | +| agents | `.claude/agents/`, `.codex/agents/`, `.kiro/agents/` | Define `trellis-research`, `trellis-implement`, and `trellis-check`. | +| skills | `.claude/skills/`, `.agents/skills/`, `.qoder/skills/` | Capability descriptions that auto-trigger or can be read on demand. | +| commands/prompts/workflows | `.cursor/commands/`, `.github/prompts/`, `.windsurf/workflows/` | Entry points explicitly invoked by the user. | + +## Three Platform Integration Modes + +### 1. Hook / Extension Driven + +These platforms can trigger scripts or plugins on specific events and actively inject Trellis context into AI. + +Common capabilities: + +- session-start injection of a `.trellis/` overview. +- workflow-state hints for each user turn. +- PRD/spec/research injection when sub-agents start. +- Shell commands inheriting session identity. + +To change "when the AI knows what," inspect hooks/plugins/extensions and settings first. + +### 2. Agent Prelude / Pull-Based + +Some platforms cannot reliably let hooks rewrite sub-agent prompts, so the agent file itself instructs the agent to read the active task, PRD, and JSONL context after startup. + +To change how sub-agents load context, inspect the agent files themselves. + +### 3. Main-Session Workflow + +Some platforms do not have Trellis sub-agent or hook capabilities. They rely on workflows/skills/commands to guide the main-session AI to read files, run scripts, and move tasks forward. + +To change behavior, inspect platform workflows/skills/commands and `.trellis/workflow.md`. + +## Local Modification Order + +When the user asks to customize behavior for a platform, the AI should inspect files in this order: + +1. Read `.trellis/workflow.md` to confirm the shared flow. +2. Read the target platform's settings/config to see which hooks/agents/skills/commands are registered. +3. Read the target platform's agents/skills/commands/hooks. +4. Modify the local file closest to the user's need. +5. If the change affects the shared flow, synchronize `.trellis/workflow.md` or `.trellis/spec/`. + +Do not modify only platform files and forget the shared workflow. Do not modify only `.trellis/workflow.md` and forget that platform entry points may still contain old descriptions. diff --git a/.claude/skills/trellis-meta/references/platform-files/platform-map.md b/.claude/skills/trellis-meta/references/platform-files/platform-map.md new file mode 100644 index 0000000..b5576f4 --- /dev/null +++ b/.claude/skills/trellis-meta/references/platform-files/platform-map.md @@ -0,0 +1,74 @@ +# Platform File Map + +This page lists common Trellis file locations in a user project by platform. Whether a platform directory exists in an actual project depends on which `trellis init --<platform>` commands the user ran. + +## Matrix + +| Platform | CLI flag | Main directory | Skill directory | Agent directory | Hooks/extensions | +| --- | --- | --- | --- | --- | --- | +| Claude Code | `--claude` | `.claude/` | `.claude/skills/` | `.claude/agents/` | `.claude/hooks/` + `.claude/settings.json` | +| Cursor | `--cursor` | `.cursor/` | `.cursor/skills/` | `.cursor/agents/` | `.cursor/hooks.json` + `.cursor/hooks/` | +| OpenCode | `--opencode` | `.opencode/` | `.opencode/skills/` | `.opencode/agents/` | `.opencode/plugins/` | +| Codex | `--codex` | `.codex/` | `.agents/skills/` | `.codex/agents/` | `.codex/hooks/` + `.codex/hooks.json` | +| Kilo | `--kilo` | `.kilocode/` | `.kilocode/skills/` | Usually none | `.kilocode/workflows/` | +| Kiro | `--kiro` | `.kiro/` | `.kiro/skills/` | `.kiro/agents/` | `.kiro/hooks/` | +| Gemini CLI | `--gemini` | `.gemini/` | `.agents/skills/` | `.gemini/agents/` | `.gemini/settings.json` + `.gemini/hooks/` | +| Antigravity | `--antigravity` | `.agent/` | `.agent/skills/` | Usually none | `.agent/workflows/` | +| Windsurf | `--windsurf` | `.windsurf/` | `.windsurf/skills/` | Usually none | `.windsurf/workflows/` | +| Qoder | `--qoder` | `.qoder/` | `.qoder/skills/` | `.qoder/agents/` | `.qoder/hooks/` + `.qoder/settings.json` | +| CodeBuddy | `--codebuddy` | `.codebuddy/` | `.codebuddy/skills/` | `.codebuddy/agents/` | `.codebuddy/hooks/` + `.codebuddy/settings.json` | +| GitHub Copilot | `--copilot` | `.github/` | `.github/skills/` | `.github/agents/` | `.github/copilot/hooks/` + prompts | +| Factory Droid | `--droid` | `.factory/` | `.factory/skills/` | `.factory/droids/` | `.factory/hooks/` + settings | +| Pi Agent | `--pi` | `.pi/` | `.pi/skills/` | `.pi/agents/` | `.pi/extensions/trellis/` + `.pi/settings.json` | + +## Capability Groups + +### Trellis Sub-Agent Support + +These platforms usually have `trellis-research`, `trellis-implement`, and `trellis-check` files: + +- Claude Code +- Cursor +- OpenCode +- Codex +- Kiro +- Gemini CLI +- Qoder +- CodeBuddy +- GitHub Copilot +- Factory Droid +- Pi Agent + +When changing implementation/check/research behavior, look for the corresponding platform agent files first. + +### Main-Session Workflow Platforms + +These platforms rely more on workflows/skills to guide the main session: + +- Kilo +- Antigravity +- Windsurf + +When changing behavior, inspect workflows and skills first. Do not assume Trellis sub-agents exist. + +### Shared `.agents/skills/` + +Codex writes the shared `.agents/skills/` layer. Some tools that support agentskills.io can also read this directory. If the user wants multiple compatible tools to share one skill, consider `.agents/skills/` first, but do not assume every platform reads it. + +## Decision Rules When Modifying Platform Files + +1. User specified a platform: modify only that platform directory unless shared workflow/spec files must also change. +2. User says "all platforms should do this": synchronize equivalent entry points platform by platform; do not modify only one directory. +3. User only says "my AI": inspect the configuration directories that actually exist in the project and infer the current AI platform. +4. User wants project rules: prefer `.trellis/spec/` or a project-local skill. +5. User wants Trellis behavior: edit `.trellis/workflow.md` plus platform hooks/agents/skills/commands. + +## When Paths Differ + +Platform ecosystems change, and user projects may already be customized. If this table disagrees with local files, use the actual settings/config in the user project as authoritative: + +- Check the hook that settings registers. +- Check the script that a command/prompt/workflow points to. +- Judge behavior by the read rules currently written in the agent file. + +Do not delete a custom file just because it is not listed in this path table. diff --git a/.claude/skills/trellis-meta/references/platform-files/skills-and-commands.md b/.claude/skills/trellis-meta/references/platform-files/skills-and-commands.md new file mode 100644 index 0000000..816c666 --- /dev/null +++ b/.claude/skills/trellis-meta/references/platform-files/skills-and-commands.md @@ -0,0 +1,83 @@ +# Skills, Commands, Prompts, And Workflows + +Skills and commands are textual entry points for user interaction with Trellis. Different platforms use different names, but their core purpose is the same: tell the AI how to enter the Trellis flow when the user expresses a certain intent. + +## Conceptual Differences + +| Type | Trigger mode | Best for | +| --- | --- | --- | +| skill | AI auto-match or explicit user mention | Long-term capabilities, workflow rules, modification guides. | +| command | Explicit user invocation | Clear operation entry points such as continue and finish-work. | +| prompt | Explicit user invocation or platform selection | Similar to command, but in a platform prompt format. | +| workflow | Explicit user selection or platform auto-match | Guides the main session when no sub-agent/hook exists. | + +Trellis workflow skills usually share one semantic set: brainstorm, before-dev, check, update-spec, break-loop. Multi-file built-in skills such as `trellis-meta` use layered references. + +## Common Paths + +| Platform | Common entries | +| --- | --- | +| Claude Code | `.claude/skills/`, `.claude/commands/` | +| Cursor | `.cursor/skills/`, `.cursor/commands/` | +| OpenCode | `.opencode/skills/`, `.opencode/commands/` | +| Codex | `.agents/skills/`, `.codex/skills/` | +| Kilo | `.kilocode/skills/`, `.kilocode/workflows/` | +| Kiro | `.kiro/skills/` | +| Gemini CLI | `.agents/skills/`, `.gemini/commands/` | +| Antigravity | `.agent/skills/`, `.agent/workflows/` | +| Windsurf | `.windsurf/skills/`, `.windsurf/workflows/` | +| Qoder | `.qoder/skills/`, `.qoder/commands/` | +| CodeBuddy | `.codebuddy/skills/`, `.codebuddy/commands/` | +| GitHub Copilot | `.github/skills/`, `.github/prompts/` | +| Factory Droid | `.factory/skills/`, `.factory/commands/` | +| Pi Agent | `.pi/skills/` | + +In a user project, use the files actually generated by init as authoritative. + +## Skill Structure + +A common skill is a directory: + +```text +trellis-meta/ +├── SKILL.md +└── references/ +``` + +`SKILL.md` should tell the AI: + +- When to use this skill. +- Which reference to read first for the current task. +- What not to do. + +References hold longer explanations so the entry file does not contain everything. + +## Command/Prompt/Workflow Structure + +Commands, prompts, and workflows are usually single files. Their content should include: + +- When to use it. +- Which `.trellis/` files to read. +- Which scripts to run. +- How to report after completion. + +They should not store task state; task state belongs in `.trellis/tasks/` and `.trellis/.runtime/`. + +## Local Change Scenarios + +| User need | Edit location | +| --- | --- | +| Change AI auto-trigger rules | The corresponding skill's frontmatter description. | +| Change user command behavior | The corresponding command/prompt/workflow file. | +| Add a project-local skill | Platform skill directory, or shared `.agents/skills/`. | +| Let multiple platforms share one capability | Write equivalent skills in each platform skill directory, or use the `.agents/skills/` shared layer on platforms that support it. | +| Change finish/continue entry points | Platform commands/prompts/workflows. | + +## Modification Principles + +1. **Keep entry files short; references carry long content**. This matters especially for multi-file skills like `trellis-meta`. +2. **Make trigger descriptions specific**. A description that is too broad can mis-trigger; one that is too narrow may not trigger. +3. **Keep the same semantics consistent across platforms**. File formats can differ, but behavior descriptions should match. +4. **Put project-specific capabilities in local skills**. Do not put team-private flows into public `trellis-meta`. + +If the user only wants local AI to know one more project rule, usually create a project-local skill or update `.trellis/spec/` instead of changing a Trellis built-in workflow skill. diff --git a/.opencode/commands/trellis/update-spec.md b/.claude/skills/trellis-update-spec/SKILL.md similarity index 86% rename from .opencode/commands/trellis/update-spec.md rename to .claude/skills/trellis-update-spec/SKILL.md index 3f0b2e7..557bc4e 100644 --- a/.opencode/commands/trellis/update-spec.md +++ b/.claude/skills/trellis-update-spec/SKILL.md @@ -1,6 +1,11 @@ +--- +name: trellis-update-spec +description: "Captures executable contracts and coding conventions into .trellis/spec/ documents. Use when learning something valuable from debugging, implementing, or discussion that should be preserved for future sessions." +--- + # Update Code-Spec - Capture Executable Contracts -When you learn something valuable (from debugging, implementing, or discussion), use this command to update the relevant code-spec documents. +When you learn something valuable (from debugging, implementing, or discussion), use this to update the relevant code-spec documents. **Timing**: After completing a task, fixing a bug, or discovering a new pattern @@ -40,13 +45,13 @@ For triggered tasks, include all sections below: | Trigger | Example | Target Spec | |---------|---------|-------------| -| **Implemented a feature** | Added template download with giget | Relevant `backend/` or `frontend/` file | -| **Made a design decision** | Used type field + mapping table for extensibility | Relevant code-spec + "Design Decisions" section | -| **Fixed a bug** | Found a subtle issue with error handling | `backend/error-handling.md` | -| **Discovered a pattern** | Found a better way to structure code | Relevant `backend/` or `frontend/` file | -| **Hit a gotcha** | Learned that X must be done before Y | Relevant code-spec + "Common Mistakes" section | -| **Established a convention** | Team agreed on naming pattern | `quality-guidelines.md` | -| **New thinking trigger** | "Don't forget to check X before doing Y" | `guides/*.md` (as a checklist item, not detailed rules) | +| **Implemented a feature** | Added a new integration or module | Relevant spec file | +| **Made a design decision** | Chose extensibility pattern over simplicity | Relevant spec + "Design Decisions" section | +| **Fixed a bug** | Found a subtle issue with error handling | Relevant spec (e.g., error-handling docs) | +| **Discovered a pattern** | Found a better way to structure code | Relevant spec file | +| **Hit a gotcha** | Learned that X must be done before Y | Relevant spec + "Common Mistakes" section | +| **Established a convention** | Team agreed on naming pattern | Quality guidelines | +| **New thinking trigger** | "Don't forget to check X before doing Y" | `guides/*.md` (as a checklist item) | **Key Insight**: Code-spec updates are NOT just for problems. Every feature implementation contains design decisions and contracts that future AI/developers need to execute safely. @@ -56,10 +61,7 @@ For triggered tasks, include all sections below: ``` .trellis/spec/ -├── backend/ # Backend coding standards -│ ├── index.md # Overview and links -│ └── *.md # Topic-specific guidelines -├── frontend/ # Frontend coding standards +├── <layer>/ # Per-layer coding standards (e.g., backend/, frontend/, api/) │ ├── index.md # Overview and links │ └── *.md # Topic-specific guidelines └── guides/ # Thinking checklists (NOT coding specs!) @@ -71,20 +73,20 @@ For triggered tasks, include all sections below: | Type | Location | Purpose | Content Style | |------|----------|---------|---------------| -| **Code-Spec** | `backend/*.md`, `frontend/*.md` | Tell AI "how to implement safely" | Signatures, contracts, matrices, cases, test points | +| **Code-Spec** | `<layer>/*.md` | Tell AI "how to implement safely" | Signatures, contracts, matrices, cases, test points | | **Guide** | `guides/*.md` | Help AI "what to think about" | Checklists, questions, pointers to specs | **Decision Rule**: Ask yourself: -- "This is **how to write** the code" → Put in `backend/` or `frontend/` +- "This is **how to write** the code" → Put in a spec layer directory - "This is **what to consider** before writing" → Put in `guides/` **Example**: | Learning | Wrong Location | Correct Location | |----------|----------------|------------------| -| "Use `reconfigure()` not `TextIOWrapper` for Windows stdout" | ❌ `guides/cross-platform-thinking-guide.md` | ✅ `backend/script-conventions.md` | -| "Remember to check encoding when writing cross-platform code" | ❌ `backend/script-conventions.md` | ✅ `guides/cross-platform-thinking-guide.md` | +| "Use API X not API Y for this task" | ❌ `guides/` (too specific for a thinking guide) | ✅ Relevant spec file (concrete convention) | +| "Remember to check X when doing Y" | ❌ Spec file (too abstract for a spec) | ✅ `guides/` (thinking checklist) | **Guides should be short checklists that point to specs**, not duplicate the detailed rules. @@ -339,7 +341,7 @@ Development Flow: ``` - `/trellis:break-loop` - Analyzes bugs deeply, often reveals spec updates needed -- `/trellis:update-spec` - Actually makes the updates (this command) +- `/trellis:update-spec` - Actually makes the updates - `/trellis:finish-work` - Reminds you to check if specs need updates --- diff --git a/.opencode/agents/debug.md b/.opencode/agents/debug.md deleted file mode 100644 index 3bf02d5..0000000 --- a/.opencode/agents/debug.md +++ /dev/null @@ -1,129 +0,0 @@ ---- -description: | - Issue fixing expert. Understands issues, fixes against specs, and verifies fixes. Precise fixes only. -mode: subagent -permission: - read: allow - write: allow - edit: allow - bash: allow - glob: allow - grep: allow - mcp__exa__*: allow ---- -# Debug Agent - -You are the Debug Agent in the Trellis workflow. - -## Context Self-Loading - -**If you see "# Debug Agent Task" header with pre-loaded context above, skip this section.** - -Otherwise, load context yourself: - -1. Read `.trellis/.current-task` → get task directory (e.g., `.trellis/tasks/xxx`) -2. Read `{task_dir}/debug.jsonl` (or `spec.jsonl` as fallback) -3. For each entry in JSONL: - - If `path` is a file → Read it - - If `path` is a directory → Read all `.md` files in it -4. Read `{task_dir}/codex-review-output.txt` if exists (Codex review results) - -Then proceed with the workflow below using the loaded context. - ---- - -## Context - -Before debugging, read: -- `.trellis/spec/` - Development guidelines -- Error messages or issue descriptions provided - -## Core Responsibilities - -1. **Understand issues** - Analyze error messages or reported issues -2. **Fix against specs** - Fix issues following dev specs -3. **Verify fixes** - Run typecheck to ensure no new issues -4. **Report results** - Report fix status - ---- - -## Workflow - -### Step 1: Understand Issues - -Parse the issue, categorize by priority: - -- `[P1]` - Must fix (blocking) -- `[P2]` - Should fix (important) -- `[P3]` - Optional fix (nice to have) - -### Step 2: Research if Needed - -If you need additional info: - -```bash -# Check knowledge base -ls .trellis/big-question/ -``` - -### Step 3: Fix One by One - -For each issue: - -1. Locate the exact position -2. Fix following specs -3. Run typecheck to verify - -### Step 4: Verify - -Run project's lint and typecheck commands to verify fixes. - -If fix introduces new issues: - -1. Revert the fix -2. Use a more complete solution -3. Re-verify - ---- - -## Report Format - -```markdown -## Fix Report - -### Issues Fixed - -1. `[P1]` `<file>:<line>` - <what was fixed> -2. `[P2]` `<file>:<line>` - <what was fixed> - -### Issues Not Fixed - -- `<file>:<line>` - <reason why not fixed> - -### Verification - -- TypeCheck: Pass -- Lint: Pass - -### Summary - -Fixed X/Y issues. Z issues require discussion. -``` - ---- - -## Guidelines - -### DO - -- Precise fixes for reported issues -- Follow specs -- Verify each fix - -### DON'T - -- Don't refactor surrounding code -- Don't add new features -- Don't modify unrelated files -- Don't use non-null assertion (`x!` operator) -- Don't execute git commit diff --git a/.opencode/agents/dispatch.md b/.opencode/agents/dispatch.md deleted file mode 100644 index 1ca3332..0000000 --- a/.opencode/agents/dispatch.md +++ /dev/null @@ -1,223 +0,0 @@ ---- -description: | - Multi-Agent Pipeline main dispatcher. Pure dispatcher. Only responsible for calling subagents and scripts in phase order. -mode: primary -permission: - read: allow - write: deny - edit: deny - bash: allow - glob: deny - grep: deny - task: allow - mcp__exa__*: allow ---- -# Dispatch Agent - -You are the Dispatch Agent in the Multi-Agent Pipeline (pure dispatcher). - -## Working Directory Convention - -Current Task is specified by `.trellis/.current-task` file, content is the relative path to task directory. - -Task directory path format: `.trellis/tasks/{MM}-{DD}-{name}/` - -This directory contains all context files for the current task: - -- `task.json` - Task configuration -- `prd.md` - Requirements document -- `info.md` - Technical design (optional) -- `implement.jsonl` - Implement context -- `check.jsonl` - Check context -- `debug.jsonl` - Debug context - -## Core Principles - -1. **You are a pure dispatcher** - Only responsible for calling subagents and scripts in order -2. **You don't read specs/requirements** - Hook will auto-inject all context to subagents -3. **You don't need resume** - Hook injects complete context on each subagent call -4. **You only need simple commands** - Tell subagent "start working" is enough - ---- - -## Startup Flow - -### Step 1: Determine Current Task Directory - -Read `.trellis/.current-task` to get current task directory path: - -```bash -TASK_DIR=$(cat .trellis/.current-task) -# e.g.: .trellis/tasks/02-03-my-feature -``` - -### Step 2: Read Task Configuration - -```bash -cat ${TASK_DIR}/task.json -``` - -Get the `next_action` array, which defines the list of phases to execute. - -### Step 3: Execute in Phase Order - -Execute each step in `phase` order. - -> **Note**: You do NOT need to manually update `current_phase`. The Hook automatically updates it when you call Task with a subagent. - ---- - -## Phase Handling - -> Hook will auto-inject all specs, requirements, and technical design to subagent context. -> Dispatch only needs to issue simple call commands. - -### action: "implement" - -``` -Task( - subagent_type: "implement", - prompt: "Implement the feature described in prd.md in the task directory", - model: "opus", - run_in_background: true -) -``` - -Hook will auto-inject: - -- All spec files from implement.jsonl -- Requirements document (prd.md) -- Technical design (info.md) - -Implement receives complete context and autonomously: read → understand → implement. - -### action: "check" - -``` -Task( - subagent_type: "check", - prompt: "Check code changes, fix issues yourself", - model: "opus", - run_in_background: true -) -``` - -Hook will auto-inject: - -- finish-work.md -- check-cross-layer.md -- check-backend.md -- check-frontend.md -- All spec files from check.jsonl - -### action: "debug" - -``` -Task( - subagent_type: "debug", - prompt: "Fix the issues described in the task context", - model: "opus", - run_in_background: true -) -``` - -Hook will auto-inject: - -- All spec files from debug.jsonl -- Error context if available - -### action: "finish" - -``` -Task( - subagent_type: "check", - prompt: "[finish] Execute final completion check before PR", - model: "opus", - run_in_background: true -) -``` - -**Important**: The `[finish]` marker in prompt triggers different context injection: -- finish-work.md checklist -- update-spec.md (spec update process and templates) -- prd.md for verifying requirements are met - -The finish agent actively updates spec docs when it detects new patterns or contracts in the changes. - -This is different from regular "check" which has full specs for self-fix loop. - -### action: "create-pr" - -This action creates a Pull Request from the feature branch. Run it via Bash: - -```bash -python3 ./.trellis/scripts/multi_agent/create_pr.py -``` - -This will: -1. Stage and commit all changes (excluding workspace) -2. Push to origin -3. Create a Draft PR using `gh pr create` -4. Update task.json with status="review", pr_url, and current_phase - -**Note**: This is the only action that performs git commit, as it's the final step after all implementation and checks are complete. - ---- - -## Calling Subagents - -### Basic Pattern - -``` -task_id = Task( - subagent_type: "implement", // or "check", "debug" - prompt: "Simple task description", - model: "opus", - run_in_background: true -) - -// Poll for completion -for i in 1..N: - result = TaskOutput(task_id, block=true, timeout=300000) - if result.status == "completed": - break -``` - -### Timeout Settings - -| Phase | Max Time | Poll Count | -|-------|----------|------------| -| implement | 30 min | 6 times | -| check | 15 min | 3 times | -| debug | 20 min | 4 times | - ---- - -## Error Handling - -### Timeout - -If a subagent times out, notify the user and ask for guidance: - -``` -"Subagent {phase} timed out after {time}. Options: -1. Retry the same phase -2. Skip to next phase -3. Abort the pipeline" -``` - -### Subagent Failure - -If a subagent reports failure, read the output and decide: - -- If recoverable: call debug agent to fix -- If not recoverable: notify user and ask for guidance - ---- - -## Key Constraints - -1. **Do not read spec/requirement files directly** - Let Hook inject to subagents -2. **Only commit via create-pr action** - Use `multi_agent/create_pr.py` at the end of pipeline -3. **All subagents should use opus model for complex tasks** -4. **Keep dispatch logic simple** - Complex logic belongs in subagents diff --git a/.opencode/agents/research.md b/.opencode/agents/research.md deleted file mode 100644 index 0c1c196..0000000 --- a/.opencode/agents/research.md +++ /dev/null @@ -1,147 +0,0 @@ ---- -description: | - Code and tech search expert. Pure research, no code modifications. Finds files, patterns, and tech solutions. -mode: subagent -permission: - read: allow - write: deny - edit: deny - bash: deny - glob: allow - grep: allow - mcp__exa__*: allow - mcp__chrome-devtools__*: allow ---- -# Research Agent - -You are the Research Agent in the Trellis workflow. - -## Context Self-Loading - -**If you see "# Research Agent Task" header with pre-loaded context above, skip this section.** - -Otherwise, if task-specific research is needed: - -1. Read `.trellis/.current-task` → get task directory (if exists) -2. Read `{task_dir}/research.jsonl` if exists -3. For each entry in JSONL: - - If `path` is a file → Read it - - If `path` is a directory → Read all `.md` files in it - -Project spec locations for reference: -- `.trellis/spec/backend/` - Backend standards -- `.trellis/spec/frontend/` - Frontend standards -- `.trellis/spec/guides/` - Thinking guides -- `.trellis/big-question/` - Known issues and pitfalls - ---- - -## Core Principle - -**You do one thing: find and explain information.** - -You are a documenter, not a reviewer. Your job is to help get the information needed. - ---- - -## Core Responsibilities - -### 1. Internal Search (Project Code) - -| Search Type | Goal | Tools | -|-------------|------|-------| -| **WHERE** | Locate files/components | Glob, Grep | -| **HOW** | Understand code logic | Read, Grep | -| **PATTERN** | Discover existing patterns | Grep, Read | - -### 2. External Search (Tech Solutions) - -Use web search for best practices and code examples. - ---- - -## Strict Boundaries - -### Only Allowed - -- Describe **what exists** -- Describe **where it is** -- Describe **how it works** -- Describe **how components interact** - -### Forbidden (unless explicitly asked) - -- Suggest improvements -- Criticize implementation -- Recommend refactoring -- Modify any files -- Execute git commands - ---- - -## Workflow - -### Step 1: Understand Search Request - -Analyze the query, determine: - -- Search type (internal/external/mixed) -- Search scope (global/specific directory) -- Expected output (file list/code patterns/tech solutions) - -### Step 2: Execute Search - -Execute multiple independent searches in parallel for efficiency. - -### Step 3: Organize Results - -Output structured results in report format. - ---- - -## Report Format - -```markdown -## Search Results - -### Query - -{original query} - -### Files Found - -| File Path | Description | -|-----------|-------------| -| `src/services/xxx.ts` | Main implementation | -| `src/types/xxx.ts` | Type definitions | - -### Code Pattern Analysis - -{Describe discovered patterns, cite specific files and line numbers} - -### Related Spec Documents - -- `.trellis/spec/xxx.md` - {description} - -### Not Found - -{If some content was not found, explain} -``` - ---- - -## Guidelines - -### DO - -- Provide specific file paths and line numbers -- Quote actual code snippets -- Distinguish "definitely found" and "possibly related" -- Explain search scope and limitations - -### DON'T - -- Don't guess uncertain info -- Don't omit important search results -- Don't add improvement suggestions in report (unless explicitly asked) -- Don't modify any files diff --git a/.opencode/agents/trellis-plan.md b/.opencode/agents/trellis-plan.md deleted file mode 100644 index 7a7c19a..0000000 --- a/.opencode/agents/trellis-plan.md +++ /dev/null @@ -1,427 +0,0 @@ ---- -description: | - Multi-Agent Pipeline planner. Analyzes requirements and produces a fully configured task directory ready for dispatch. -mode: primary -permission: - read: allow - write: allow - edit: allow - bash: allow - glob: allow - grep: allow - task: allow ---- -# Plan Agent - -You are the Plan Agent in the Multi-Agent Pipeline. - -**Your job**: Evaluate requirements and, if valid, transform them into a fully configured task directory. - -**You have the power to reject** - If a requirement is unclear, incomplete, unreasonable, or potentially harmful, you MUST refuse to proceed and clean up. - ---- - -## CRITICAL: You MUST Execute Tools - -**DO NOT just output text descriptions of what you would do.** -**You MUST actually execute bash commands and use tools to perform actions.** - -When this prompt says "run this command", you must use the bash tool to execute it. -When this prompt says "write this file", you must use the write tool to create it. - ---- - -## Step 0: Read Environment Variables (REQUIRED FIRST STEP) - -**IMMEDIATELY execute this bash command to read your input:** - -```bash -echo "PLAN_TASK_NAME=$PLAN_TASK_NAME" -echo "PLAN_DEV_TYPE=$PLAN_DEV_TYPE" -echo "PLAN_REQUIREMENT=$PLAN_REQUIREMENT" -echo "PLAN_TASK_DIR=$PLAN_TASK_DIR" -``` - -This gives you the task configuration. Store these values for use in subsequent steps. - ---- - -## Step 1: Evaluate Requirement (CRITICAL) - -Now evaluate the requirement from `$PLAN_REQUIREMENT`: - -### Reject If: - -1. **Unclear or Vague** - - "Make it better" / "Fix the bugs" / "Improve performance" - - No specific outcome defined - - Cannot determine what "done" looks like - -2. **Incomplete Information** - - Missing critical details to implement - - References unknown systems or files - - Depends on decisions not yet made - -3. **Out of Scope for This Project** - - Requirement doesn't match the project's purpose - - Requires changes to external systems - - Not technically feasible with current architecture - -4. **Potentially Harmful** - - Security vulnerabilities (intentional backdoors, data exfiltration) - - Destructive operations without clear justification - - Circumventing access controls - -5. **Too Large / Should Be Split** - - Multiple unrelated features bundled together - - Would require touching too many systems - - Cannot be completed in a reasonable scope - -### If Rejecting: - -**You MUST execute these commands using the bash tool. Do not just describe them.** - -**Step R1: Update task.json status** - Execute this bash command: -```bash -jq '.status = "rejected"' "$PLAN_TASK_DIR/task.json" > "$PLAN_TASK_DIR/task.json.tmp" \ - && mv "$PLAN_TASK_DIR/task.json.tmp" "$PLAN_TASK_DIR/task.json" -``` - -**Step R2: Write REJECTED.md** - Use the write tool to create `$PLAN_TASK_DIR/REJECTED.md` with this content: -```markdown -# Plan Rejected - -## Reason -<category from above> - -## Details -<specific explanation of why this requirement cannot proceed> - -## Suggestions -- <what the user should clarify or change> -- <how to make the requirement actionable> - -## To Retry - -1. Delete this directory: - ```bash - rm -rf <task_dir> - ``` - -2. Run with revised requirement: - ```bash - python3 ./.trellis/scripts/multi_agent/plan.py --name "<name>" --type "<type>" --requirement "<revised requirement>" - ``` -``` - -**Step R3: Print summary** - Execute: -```bash -echo "=== PLAN REJECTED ===" -echo "" -echo "Reason: <category>" -echo "Details: <brief explanation>" -echo "" -echo "See: $PLAN_TASK_DIR/REJECTED.md" -``` - -**Step R4: Stop** - Do not proceed to acceptance workflow. - -**The task directory is kept** with: -- `task.json` (status: "rejected") -- `REJECTED.md` (full explanation) -- `.plan-log` (execution log) - -This allows the user to review why it was rejected. - -### If Accepting: - -Continue to Step 1. The requirement is: -- Clear and specific -- Has a defined outcome -- Is technically feasible -- Is appropriately scoped - ---- - -## Input - -You receive input via environment variables (set by plan.py): - -```bash -PLAN_TASK_NAME # Task name (e.g., "user-auth") -PLAN_DEV_TYPE # Development type: backend | frontend | fullstack -PLAN_REQUIREMENT # Requirement description from user -PLAN_TASK_DIR # Pre-created task directory path -``` - -Read them at startup: - -```bash -echo "Task: $PLAN_TASK_NAME" -echo "Type: $PLAN_DEV_TYPE" -echo "Requirement: $PLAN_REQUIREMENT" -echo "Directory: $PLAN_TASK_DIR" -``` - -## Output (if accepted) - -A complete task directory containing: - -``` -${PLAN_TASK_DIR}/ -├── task.json # Updated with branch, scope, dev_type -├── prd.md # Requirements document -├── implement.jsonl # Implement phase context -├── check.jsonl # Check phase context -└── debug.jsonl # Debug phase context -``` - ---- - -## Workflow (After Acceptance) - -### Step 1: Initialize Context Files - -```bash -python3 ./.trellis/scripts/task.py init-context "$PLAN_TASK_DIR" "$PLAN_DEV_TYPE" -``` - -This creates base jsonl files with standard specs for the dev type. - -### Step 2: Analyze Codebase with Research Agent - -Call research agent to find relevant specs and code patterns: - -``` -Task( - subagent_type: "research", - prompt: "Analyze what specs and code patterns are needed for this task. - -Task: ${PLAN_REQUIREMENT} -Dev Type: ${PLAN_DEV_TYPE} - -Instructions: -1. Search .trellis/spec/ for relevant spec files -2. Search the codebase for related modules and patterns -3. Identify files that should be added to jsonl context - -Output format (use exactly this format): - -## implement.jsonl -- path: <relative file path>, reason: <why needed> -- path: <relative file path>, reason: <why needed> - -## check.jsonl -- path: <relative file path>, reason: <why needed> - -## debug.jsonl -- path: <relative file path>, reason: <why needed> - -## Suggested Scope -<single word for commit scope, e.g., auth, api, ui> - -## Technical Notes -<any important technical considerations for prd.md>", - model: "opus" -) -``` - -### Step 3: Add Context Entries - -Parse research agent output and add entries to jsonl files: - -```bash -# For each entry in implement.jsonl section: -python3 ./.trellis/scripts/task.py add-context "$PLAN_TASK_DIR" implement "<path>" "<reason>" - -# For each entry in check.jsonl section: -python3 ./.trellis/scripts/task.py add-context "$PLAN_TASK_DIR" check "<path>" "<reason>" - -# For each entry in debug.jsonl section: -python3 ./.trellis/scripts/task.py add-context "$PLAN_TASK_DIR" debug "<path>" "<reason>" -``` - -### Step 4: Write prd.md - -Create the requirements document: - -```bash -cat > "$PLAN_TASK_DIR/prd.md" << 'EOF' -# Task: ${PLAN_TASK_NAME} - -## Overview -[Brief description of what this feature does] - -## Requirements -- [Requirement 1] -- [Requirement 2] -- ... - -## Acceptance Criteria -- [ ] [Criterion 1] -- [ ] [Criterion 2] -- ... - -## Technical Notes -[Any technical considerations from research agent] - -## Out of Scope -- [What this feature does NOT include] -EOF -``` - -**Guidelines for prd.md**: -- Be specific and actionable -- Include acceptance criteria that can be verified -- Add technical notes from research agent -- Define what's out of scope to prevent scope creep - -### Step 5: Configure Task Metadata - -```bash -# Set branch name -python3 ./.trellis/scripts/task.py set-branch "$PLAN_TASK_DIR" "feature/${PLAN_TASK_NAME}" - -# Set scope (from research agent suggestion) -python3 ./.trellis/scripts/task.py set-scope "$PLAN_TASK_DIR" "<scope>" - -# Update dev_type in task.json -jq --arg type "$PLAN_DEV_TYPE" '.dev_type = $type' \ - "$PLAN_TASK_DIR/task.json" > "$PLAN_TASK_DIR/task.json.tmp" \ - && mv "$PLAN_TASK_DIR/task.json.tmp" "$PLAN_TASK_DIR/task.json" -``` - -### Step 6: Validate Configuration - -```bash -python3 ./.trellis/scripts/task.py validate "$PLAN_TASK_DIR" -``` - -If validation fails, fix the invalid paths and re-validate. - -### Step 7: Output Summary - -Print a summary for the caller: - -```bash -echo "=== Plan Complete ===" -echo "Task Directory: $PLAN_TASK_DIR" -echo "" -echo "Files created:" -ls -la "$PLAN_TASK_DIR" -echo "" -echo "Context summary:" -python3 ./.trellis/scripts/task.py list-context "$PLAN_TASK_DIR" -echo "" -echo "Ready for: python3 ./.trellis/scripts/multi_agent/start.py $PLAN_TASK_DIR" -``` - ---- - -## Key Principles - -1. **Reject early, reject clearly** - Don't waste time on bad requirements -2. **Research before configure** - Always call research agent to understand the codebase -3. **Validate all paths** - Every file in jsonl must exist -4. **Be specific in prd.md** - Vague requirements lead to wrong implementations -5. **Include acceptance criteria** - Check agent needs to verify something concrete -6. **Set appropriate scope** - This affects commit message format - ---- - -## Error Handling - -### Research Agent Returns No Results - -If research agent finds no relevant specs: -- Use only the base specs from init-context -- Add a note in prd.md that this is a new area without existing patterns - -### Path Not Found - -If add-context fails because path doesn't exist: -- Skip that entry -- Log a warning -- Continue with other entries - -### Validation Fails - -If final validation fails: -- Read the error output -- Remove invalid entries from jsonl files -- Re-validate - ---- - -## Examples - -### Example: Accepted Requirement - -``` -Input: - PLAN_TASK_NAME = "add-rate-limiting" - PLAN_DEV_TYPE = "backend" - PLAN_REQUIREMENT = "Add rate limiting to API endpoints using a sliding window algorithm. Limit to 100 requests per minute per IP. Return 429 status when exceeded." - -Result: ACCEPTED - Clear, specific, has defined behavior - -Output: - .trellis/tasks/02-03-add-rate-limiting/ - ├── task.json # branch: feature/add-rate-limiting, scope: api - ├── prd.md # Detailed requirements with acceptance criteria - ├── implement.jsonl # Backend specs + existing middleware patterns - ├── check.jsonl # Quality guidelines + API testing specs - └── debug.jsonl # Error handling specs -``` - -### Example: Rejected - Vague Requirement - -``` -Input: - PLAN_REQUIREMENT = "Make the API faster" - -Result: REJECTED - -=== PLAN REJECTED === - -Reason: Unclear or Vague - -Details: -"Make the API faster" does not specify: -- Which endpoints need optimization -- Current performance baseline -- Target performance metrics -- Acceptable trade-offs (memory, complexity) - -Suggestions: -- Identify specific slow endpoints with response times -- Define target latency (e.g., "GET /users should respond in <100ms") -- Specify if caching, query optimization, or architecture changes are acceptable -``` - -### Example: Rejected - Too Large - -``` -Input: - PLAN_REQUIREMENT = "Add user authentication, authorization, password reset, 2FA, OAuth integration, and audit logging" - -Result: REJECTED - -=== PLAN REJECTED === - -Reason: Too Large / Should Be Split - -Details: -This requirement bundles 6 distinct features that should be implemented separately: -1. User authentication (login/logout) -2. Authorization (roles/permissions) -3. Password reset flow -4. Two-factor authentication -5. OAuth integration -6. Audit logging - -Suggestions: -- Start with basic authentication first -- Create separate features for each capability -- Consider dependencies (auth before authz, etc.) -``` diff --git a/.opencode/commands/android-test.md b/.opencode/commands/android-test.md deleted file mode 100644 index 5dad31f..0000000 --- a/.opencode/commands/android-test.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -description: Run an Android automation test through Midscene Skills ---- - -You are running an Android mobile UI automation task for this project. - -Interpret the user arguments as the exact natural-language test goal: - -$ARGUMENTS - -Execution requirements: - -1. Verify that adb is available and that at least one Android device or emulator is connected. -2. If no Android target is available, stop and report that the Android automation prerequisite is missing. -3. Use the installed Midscene Android skill workflow to execute the requested UI actions on the Android emulator or device. -4. Prefer acting on the current development build of the app when applicable. -5. Capture visible evidence during the run when useful, especially the final screen state. -6. At the end, report: - - whether the flow succeeded - - the exact failing step if any - - what was observed on screen - - what should be fixed next if this looked like a product bug - -Do not only describe a test plan. Actually perform the automation when prerequisites are available. diff --git a/.opencode/commands/doc-update.md b/.opencode/commands/doc-update.md deleted file mode 100644 index 79cb251..0000000 --- a/.opencode/commands/doc-update.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -description: 审查并更新 docs/protocols,确保与当前代码实现一致 ---- - -你现在要执行一次“协议文档一致性审查与更新”,目标是让 `docs/protocols/` 成为项目协议与数据格式的最新事实来源,并与当前代码实现保持一致。 - -## 执行目标 - -1. 审查 `docs/protocols/` 下所有协议文档是否过期、缺失或与实现不一致。 -2. 在发现差异时,优先更新协议文档(而不是先改代码),明确兼容策略。 -3. 输出结构化审查结果:发现的问题、已更新内容、仍待确认项。 - -## 约束 - -- 审查范围优先限定在:`docs/protocols/**` 与本次协议相关代码(`backend/**`、`apps/**`)。 -- 不做无关重构,不改动与协议无关模块。 -- 禁止“吞错式”描述:若不确定,明确标记为待确认,不要假设正确。 -- 若涉及破坏性变更,必须在文档中写明迁移与回滚策略。 - -## 步骤 - -1. **建立协议清单** - - 列出 `docs/protocols/` 中所有文档。 - - 为每份文档提取:涉及的接口、事件、字段、枚举、状态码、错误结构。 - -2. **建立实现映射** - - 在 `backend/**`、`apps/**` 中定位对应实现与调用点。 - - 对每个协议项建立“文档 -> 实现”映射(文件路径 + 关键符号/接口名)。 - -3. **逐项比对并分级** - - 比对以下维度: - - 请求/响应结构与字段可选性 - - 字段命名、类型、默认值、约束 - - 错误码/错误体格式 - - 版本号、兼容说明、废弃说明 - - 给每个差异打级别: - - CRITICAL:会导致客户端/服务端不兼容 - - HIGH:行为偏差明显,容易引发线上错误 - - MEDIUM:文档缺失或描述不完整 - - LOW:措辞、示例、格式问题 - -4. **更新文档(优先)** - - 先更新 `docs/protocols/**`,使其反映当前真实实现。 - - 每处更新都要补充“兼容策略”: - - `backward-compatible`(向后兼容)或 - - `requires-migration`(需要迁移) - - 如为 `requires-migration`,补充迁移步骤与回滚注意事项。 - -5. **一致性复核** - - 复查所有变更是否与实现一致。 - - 如仓库有协议相关测试/校验脚本,执行最小必要验证。 - -## 输出格式(必须) - -1. **Protocol Audit Scope** - - 本次审查的文档列表 - - 对应实现文件映射 - -2. **Findings** - - 按 CRITICAL/HIGH/MEDIUM/LOW 分组列出差异 - - 每条包含:文档位置、实现位置、差异说明、影响范围 - -3. **Doc Updates Applied** - - 列出已更新的文档与关键修改点 - - 标注每项兼容策略(`backward-compatible` / `requires-migration`) - -4. **Open Questions** - - 仍需产品/后端/前端确认的点 - -5. **Verification** - - 列出执行的验证命令与结果(通过/失败) - -## 完成标准 - -- `docs/protocols/` 覆盖当前实现的真实协议。 -- 所有发现的关键差异(CRITICAL/HIGH)要么已修正文档,要么被明确记录为阻塞项。 -- 输出报告完整,后续协作者可据此继续推进。 diff --git a/.opencode/commands/ios-test.md b/.opencode/commands/ios-test.md deleted file mode 100644 index 8f4a255..0000000 --- a/.opencode/commands/ios-test.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -description: Run an iOS automation test through Midscene Skills ---- - -You are running an iOS mobile UI automation task for this project. - -Interpret the user arguments as the exact natural-language test goal: - -$ARGUMENTS - -Execution requirements: - -1. Verify that WebDriverAgent is reachable at http://localhost:8100/status before doing any iOS action. -2. If WebDriverAgent is not ready, stop and report that the iOS automation prerequisite is missing. -3. Use the installed Midscene iOS skill workflow to execute the requested UI actions on the iOS simulator or device. -4. Prefer acting on the current development build of the app when applicable. -5. Capture visible evidence during the run when useful, especially the final screen state. -6. At the end, report: - - whether the flow succeeded - - the exact failing step if any - - what was observed on screen - - what should be fixed next if this looked like a product bug - -Do not only describe a test plan. Actually perform the automation when prerequisites are available. diff --git a/.opencode/commands/trellis/before-backend-dev.md b/.opencode/commands/trellis/before-backend-dev.md deleted file mode 100644 index 7dfcd36..0000000 --- a/.opencode/commands/trellis/before-backend-dev.md +++ /dev/null @@ -1,13 +0,0 @@ -Read the backend development guidelines before starting your development task. - -Execute these steps: -1. Read `.trellis/spec/backend/index.md` to understand available guidelines -2. Based on your task, read the relevant guideline files: - - Database work → `.trellis/spec/backend/database-guidelines.md` - - Error handling → `.trellis/spec/backend/error-handling.md` - - Logging → `.trellis/spec/backend/logging-guidelines.md` - - Type questions → `.trellis/spec/backend/type-safety.md` -3. Understand the coding standards and patterns you need to follow -4. Then proceed with your development plan - -This step is **mandatory** before writing any backend code. diff --git a/.opencode/commands/trellis/before-frontend-dev.md b/.opencode/commands/trellis/before-frontend-dev.md deleted file mode 100644 index 9687edc..0000000 --- a/.opencode/commands/trellis/before-frontend-dev.md +++ /dev/null @@ -1,13 +0,0 @@ -Read the frontend development guidelines before starting your development task. - -Execute these steps: -1. Read `.trellis/spec/frontend/index.md` to understand available guidelines -2. Based on your task, read the relevant guideline files: - - Component work → `.trellis/spec/frontend/component-guidelines.md` - - Hook work → `.trellis/spec/frontend/hook-guidelines.md` - - State management → `.trellis/spec/frontend/state-management.md` - - Type questions → `.trellis/spec/frontend/type-safety.md` -3. Understand the coding standards and patterns you need to follow -4. Then proceed with your development plan - -This step is **mandatory** before writing any frontend code. diff --git a/.opencode/commands/trellis/check-backend.md b/.opencode/commands/trellis/check-backend.md deleted file mode 100644 index 886f5c9..0000000 --- a/.opencode/commands/trellis/check-backend.md +++ /dev/null @@ -1,13 +0,0 @@ -Check if the code you just wrote follows the backend development guidelines. - -Execute these steps: -1. Run `git status` to see modified files -2. Read `.trellis/spec/backend/index.md` to understand which guidelines apply -3. Based on what you changed, read the relevant guideline files: - - Database changes → `.trellis/spec/backend/database-guidelines.md` - - Error handling → `.trellis/spec/backend/error-handling.md` - - Logging changes → `.trellis/spec/backend/logging-guidelines.md` - - Type changes → `.trellis/spec/backend/type-safety.md` - - Any changes → `.trellis/spec/backend/quality-guidelines.md` -4. Review your code against the guidelines -5. Report any violations and fix them if found diff --git a/.opencode/commands/trellis/check-cross-layer.md b/.opencode/commands/trellis/check-cross-layer.md deleted file mode 100644 index 591d39b..0000000 --- a/.opencode/commands/trellis/check-cross-layer.md +++ /dev/null @@ -1,153 +0,0 @@ -# Cross-Layer Check - -Check if your changes considered all dimensions. Most bugs come from "didn't think of it", not lack of technical skill. - -> **Note**: This is a **post-implementation** safety net. Ideally, read the [Pre-Implementation Checklist](.trellis/spec/guides/pre-implementation-checklist.md) **before** writing code. - ---- - -## Related Documents - -| Document | Purpose | Timing | -|----------|---------|--------| -| [Pre-Implementation Checklist](.trellis/spec/guides/pre-implementation-checklist.md) | Questions before coding | **Before** writing code | -| [Code Reuse Thinking Guide](.trellis/spec/guides/code-reuse-thinking-guide.md) | Pattern recognition | During implementation | -| **`/trellis:check-cross-layer`** (this) | Verification check | **After** implementation | - ---- - -## Execution Steps - -### 1. Identify Change Scope - -```bash -git status -git diff --name-only -``` - -### 2. Select Applicable Check Dimensions - -Based on your change type, execute relevant checks below: - ---- - -## Dimension A: Cross-Layer Data Flow (Required when 3+ layers) - -**Trigger**: Changes involve 3 or more layers - -| Layer | Common Locations | -|-------|------------------| -| API/Routes | `routes/`, `api/`, `handlers/`, `controllers/` | -| Service/Business Logic | `services/`, `lib/`, `core/`, `domain/` | -| Database/Storage | `db/`, `models/`, `repositories/`, `schema/` | -| UI/Presentation | `components/`, `views/`, `templates/`, `pages/` | -| Utility | `utils/`, `helpers/`, `common/` | - -**Checklist**: -- [ ] Read flow: Database -> Service -> API -> UI -- [ ] Write flow: UI -> API -> Service -> Database -- [ ] Types/schemas correctly passed between layers? -- [ ] Errors properly propagated to caller? -- [ ] Loading/pending states handled at each layer? - -**Detailed Guide**: `.trellis/spec/guides/cross-layer-thinking-guide.md` - ---- - -## Dimension B: Code Reuse (Required when modifying constants/config) - -**Trigger**: -- Modifying UI constants (label, icon, color) -- Modifying any hardcoded value -- Seeing similar code in multiple places -- Creating a new utility/helper function -- Just finished batch modifications across files - -**Checklist**: -- [ ] Search first: How many places define this value? - ```bash - # Search in source files (adjust extensions for your project) - grep -r "value-to-change" src/ - ``` -- [ ] If 2+ places define same value -> Should extract to shared constant -- [ ] After modification, all usage sites updated? -- [ ] If creating utility: Does similar utility already exist? - -**Detailed Guide**: `.trellis/spec/guides/code-reuse-thinking-guide.md` - ---- - -## Dimension B2: New Utility Functions - -**Trigger**: About to create a new utility/helper function - -**Checklist**: -- [ ] Search for existing similar utilities first - ```bash - grep -r "functionNamePattern" src/ - ``` -- [ ] If similar exists, can you extend it instead? -- [ ] If creating new, is it in the right location (shared vs domain-specific)? - ---- - -## Dimension B3: After Batch Modifications - -**Trigger**: Just modified similar patterns in multiple files - -**Checklist**: -- [ ] Did you check ALL files with similar patterns? - ```bash - grep -r "patternYouChanged" src/ - ``` -- [ ] Any files missed that should also be updated? -- [ ] Should this pattern be abstracted to prevent future duplication? - ---- - -## Dimension C: Import/Dependency Paths (Required when creating new files) - -**Trigger**: Creating new source files - -**Checklist**: -- [ ] Using correct import paths (relative vs absolute)? -- [ ] No circular dependencies? -- [ ] Consistent with project's module organization? - ---- - -## Dimension D: Same-Layer Consistency - -**Trigger**: -- Modifying display logic or formatting -- Same domain concept used in multiple places - -**Checklist**: -- [ ] Search for other places using same concept - ```bash - grep -r "ConceptName" src/ - ``` -- [ ] Are these usages consistent? -- [ ] Should they share configuration/constants? - ---- - -## Common Issues Quick Reference - -| Issue | Root Cause | Prevention | -|-------|------------|------------| -| Changed one place, missed others | Didn't search impact scope | `grep` before changing | -| Data lost at some layer | Didn't check data flow | Trace data source to destination | -| Type/schema mismatch | Cross-layer types inconsistent | Use shared type definitions | -| UI/output inconsistent | Same concept in multiple places | Extract shared constants | -| Similar utility exists | Didn't search first | Search before creating | -| Batch fix incomplete | Didn't verify all occurrences | grep after fixing | - ---- - -## Output - -Report: -1. Which dimensions your changes involve -2. Check results for each dimension -3. Issues found and fix suggestions diff --git a/.opencode/commands/trellis/check-frontend.md b/.opencode/commands/trellis/check-frontend.md deleted file mode 100644 index 3771ae3..0000000 --- a/.opencode/commands/trellis/check-frontend.md +++ /dev/null @@ -1,13 +0,0 @@ -Check if the code you just wrote follows the frontend development guidelines. - -Execute these steps: -1. Run `git status` to see modified files -2. Read `.trellis/spec/frontend/index.md` to understand which guidelines apply -3. Based on what you changed, read the relevant guideline files: - - Component changes → `.trellis/spec/frontend/component-guidelines.md` - - Hook changes → `.trellis/spec/frontend/hook-guidelines.md` - - State changes → `.trellis/spec/frontend/state-management.md` - - Type changes → `.trellis/spec/frontend/type-safety.md` - - Any changes → `.trellis/spec/frontend/quality-guidelines.md` -4. Review your code against the guidelines -5. Report any violations and fix them if found diff --git a/.opencode/commands/trellis/create-command.md b/.opencode/commands/trellis/create-command.md deleted file mode 100644 index bc12b6f..0000000 --- a/.opencode/commands/trellis/create-command.md +++ /dev/null @@ -1,154 +0,0 @@ -# Create New Slash Command - -Create a new slash command in both `.cursor/commands/` (with `trellis-` prefix) and `.opencode/commands/trellis/` directories based on user requirements. - -## Usage - -``` -/trellis:create-command <command-name> <description> -``` - -**Example**: -``` -/trellis:create-command review-pr Check PR code changes against project guidelines -``` - -## Execution Steps - -### 1. Parse Input - -Extract from user input: -- **Command name**: Use kebab-case (e.g., `review-pr`) -- **Description**: What the command should accomplish - -### 2. Analyze Requirements - -Determine command type based on description: -- **Initialization**: Read docs, establish context -- **Pre-development**: Read guidelines, check dependencies -- **Code check**: Validate code quality and guideline compliance -- **Recording**: Record progress, questions, structure changes -- **Generation**: Generate docs, code templates - -### 3. Generate Command Content - -Based on command type, generate appropriate content: - -**Simple command** (1-3 lines): -```markdown -Concise instruction describing what to do -``` - -**Complex command** (with steps): -```markdown -# Command Title - -Command description - -## Steps - -### 1. First Step -Specific action - -### 2. Second Step -Specific action - -## Output Format (if needed) - -Template -``` - -### 4. Create Files - -Create in both directories: -- `.cursor/commands/trellis-<command-name>.md` -- `.opencode/commands/trellis/<command-name>.md` - -### 5. Confirm Creation - -Output result: -``` -[OK] Created Slash Command: /<command-name> - -File paths: -- .cursor/commands/trellis-<command-name>.md -- .opencode/commands/trellis/<command-name>.md - -Usage: -/trellis:<command-name> - -Description: -<description> -``` - -## Command Content Guidelines - -### [OK] Good command content - -1. **Clear and concise**: Immediately understandable -2. **Executable**: AI can follow steps directly -3. **Well-scoped**: Clear boundaries of what to do and not do -4. **Has output**: Specifies expected output format (if needed) - -### [X] Avoid - -1. **Too vague**: e.g., "optimize code" -2. **Too complex**: Single command should not exceed 100 lines -3. **Duplicate functionality**: Check if similar command exists first - -## Naming Conventions - -| Command Type | Prefix | Example | -|--------------|--------|---------| -| Session Start | `start` | `start` | -| Pre-development | `before-` | `before-frontend-dev` | -| Check | `check-` | `check-frontend` | -| Record | `record-` | `record-session` | -| Generate | `generate-` | `generate-api-doc` | -| Update | `update-` | `update-changelog` | -| Other | Verb-first | `review-code`, `sync-data` | - -## Example - -### Input -``` -/trellis:create-command review-pr Check PR code changes against project guidelines -``` - -### Generated Command Content -```markdown -# PR Code Review - -Check current PR code changes against project guidelines. - -## Steps - -### 1. Get Changed Files -```bash -git diff main...HEAD --name-only -``` - -### 2. Categorized Review - -**Frontend files** (`apps/web/`): -- Reference `.trellis/spec/frontend/index.md` - -**Backend files** (`packages/api/`): -- Reference `.trellis/spec/backend/index.md` - -### 3. Output Review Report - -Format: - -## PR Review Report - -### Changed Files -- [file list] - -### Check Results -- [OK] Passed items -- [X] Issues found - -### Suggestions -- [improvement suggestions] -``` diff --git a/.opencode/commands/trellis/finish-work.md b/.opencode/commands/trellis/finish-work.md deleted file mode 100644 index b82b153..0000000 --- a/.opencode/commands/trellis/finish-work.md +++ /dev/null @@ -1,144 +0,0 @@ -# Finish Work - Pre-Commit Checklist - -Before submitting or committing, use this checklist to ensure work completeness. - -**Timing**: After code is written and tested, before commit - ---- - -## Checklist - -### 1. Code Quality - -```bash -# Must pass -pnpm lint -pnpm type-check -pnpm test -``` - -- [ ] `pnpm lint` passes with 0 errors? -- [ ] `pnpm type-check` passes with no type errors? -- [ ] Tests pass? -- [ ] No `console.log` statements (use logger)? -- [ ] No non-null assertions (the `x!` operator)? -- [ ] No `any` types? - -### 2. Code-Spec Sync - -**Code-Spec Docs**: -- [ ] Does `.trellis/spec/backend/` need updates? - - New patterns, new modules, new conventions -- [ ] Does `.trellis/spec/frontend/` need updates? - - New components, new hooks, new patterns -- [ ] Does `.trellis/spec/guides/` need updates? - - New cross-layer flows, lessons from bugs - -**Key Question**: -> "If I fixed a bug or discovered something non-obvious, should I document it so future me (or others) won't hit the same issue?" - -If YES -> Update the relevant code-spec doc. - -### 2.5. Code-Spec Hard Block (Infra/Cross-Layer) - -If this change touches infra or cross-layer contracts, this is a blocking checklist: - -- [ ] Spec content is executable (real signatures/contracts), not principle-only text -- [ ] Includes file path + command/API name + payload field names -- [ ] Includes validation and error matrix -- [ ] Includes Good/Base/Bad cases -- [ ] Includes required tests and assertion points - -**Block Rule**: -In pipeline mode, the finish agent will automatically detect and execute spec updates when gaps are found. -If running this checklist manually, ensure spec sync is complete before committing — run `/trellis:update-spec` if needed. - -### 3. API Changes - -If you modified API endpoints: - -- [ ] Input schema updated? -- [ ] Output schema updated? -- [ ] API documentation updated? -- [ ] Client code updated to match? - -### 4. Database Changes - -If you modified database schema: - -- [ ] Migration file created? -- [ ] Schema file updated? -- [ ] Related queries updated? -- [ ] Seed data updated (if applicable)? - -### 5. Cross-Layer Verification - -If the change spans multiple layers: - -- [ ] Data flows correctly through all layers? -- [ ] Error handling works at each boundary? -- [ ] Types are consistent across layers? -- [ ] Loading states handled? - -### 6. Manual Testing - -- [ ] Feature works in browser/app? -- [ ] Edge cases tested? -- [ ] Error states tested? -- [ ] Works after page refresh? - ---- - -## Quick Check Flow - -```bash -# 1. Code checks -pnpm lint && pnpm type-check - -# 2. View changes -git status -git diff --name-only - -# 3. Based on changed files, check relevant items above -``` - ---- - -## Common Oversights - -| Oversight | Consequence | Check | -|-----------|-------------|-------| -| Code-spec docs not updated | Others don't know the change | Check .trellis/spec/ | -| Spec text is abstract only | Easy regressions in infra/cross-layer changes | Require signature/contract/matrix/cases/tests | -| Migration not created | Schema out of sync | Check db/migrations/ | -| Types not synced | Runtime errors | Check shared types | -| Tests not updated | False confidence | Run full test suite | -| Console.log left in | Noisy production logs | Search for console.log | - ---- - -## Relationship to Other Commands - -``` -Development Flow: - Write code -> Test -> /trellis:finish-work -> git commit -> /trellis:record-session - | | - Ensure completeness Record progress - -Debug Flow: - Hit bug -> Fix -> /trellis:break-loop -> Knowledge capture - | - Deep analysis -``` - -- `/trellis:finish-work` - Check work completeness (this command) -- `/trellis:record-session` - Record session and commits -- `/trellis:break-loop` - Deep analysis after debugging - ---- - -## Core Principle - -> **Delivery includes not just code, but also documentation, verification, and knowledge capture.** - -Complete work = Code + Docs + Tests + Verification diff --git a/.opencode/commands/trellis/integrate-skill.md b/.opencode/commands/trellis/integrate-skill.md deleted file mode 100644 index cacafd5..0000000 --- a/.opencode/commands/trellis/integrate-skill.md +++ /dev/null @@ -1,219 +0,0 @@ -# Integrate Claude Skill into Project Guidelines - -Adapt and integrate a Claude global skill into your project's development guidelines (not directly into project code). - -## Usage - -``` -/trellis:integrate-skill <skill-name> -``` - -**Examples**: -``` -/trellis:integrate-skill frontend-design -/trellis:integrate-skill mcp-builder -``` - -## Core Principle - -> [!] **Important**: The goal of skill integration is to update **development guidelines**, not to generate project code directly. -> -> - Guidelines content -> Write to `.trellis/spec/{target}/doc.md` -> - Code examples -> Place in `.trellis/spec/{target}/examples/skills/<skill-name>/` -> - Example files -> Use `.template` suffix (e.g., `component.tsx.template`) to avoid IDE errors -> -> Where `{target}` is `frontend` or `backend`, determined by skill type. - -## Execution Steps - -### 1. Read Skill Content - -```bash -openskills read <skill-name> -``` - -If the skill doesn't exist, prompt user to check available skills: -```bash -# Available skills are listed in AGENTS.md under <available_skills> -``` - -### 2. Determine Integration Target - -Based on skill type, determine which guidelines to update: - -| Skill Category | Integration Target | -|----------------|-------------------| -| UI/Frontend (`frontend-design`, `web-artifacts-builder`) | `.trellis/spec/frontend/` | -| Backend/API (`mcp-builder`) | `.trellis/spec/backend/` | -| Documentation (`doc-coauthoring`, `docx`, `pdf`) | `.trellis/` or create dedicated guidelines | -| Testing (`webapp-testing`) | `.trellis/spec/frontend/` (E2E) | - -### 3. Analyze Skill Content - -Extract from the skill: -- **Core concepts**: How the skill works and key concepts -- **Best practices**: Recommended approaches -- **Code patterns**: Reusable code templates -- **Caveats**: Common issues and solutions - -### 4. Execute Integration - -#### 4.1 Update Guidelines Document - -Add a new section to the corresponding `doc.md`: - -```markdown -@@@section:skill-<skill-name> -## # <Skill Name> Integration Guide - -### Overview -[Core functionality and use cases of the skill] - -### Project Adaptation -[How to use this skill in the current project] - -### Usage Steps -1. [Step 1] -2. [Step 2] - -### Caveats -- [Project-specific constraints] -- [Differences from default behavior] - -### Reference Examples -See `examples/skills/<skill-name>/` - -@@@/section:skill-<skill-name> -``` - -#### 4.2 Create Examples Directory (if code examples exist) - -```bash -# Directory structure ({target} = frontend or backend) -.trellis/spec/{target}/ -|-- doc.md # Add skill-related section -|-- index.md # Update index -+-- examples/ - +-- skills/ - +-- <skill-name>/ - |-- README.md # Example documentation - |-- example-1.ts.template # Code example (use .template suffix) - +-- example-2.tsx.template -``` - -**File naming conventions**: -- Code files: `<name>.<ext>.template` (e.g., `component.tsx.template`) -- Config files: `<name>.config.template` (e.g., `tailwind.config.template`) -- Documentation: `README.md` (normal suffix) - -#### 4.3 Update Index File - -Add to the Quick Navigation table in `index.md`: - -```markdown -| <Skill-related task> | <Section name> | `skill-<skill-name>` | -``` - -### 5. Generate Integration Report - ---- - -## Skill Integration Report: `<skill-name>` - -### # Overview -- **Skill description**: [Functionality description] -- **Integration target**: `.trellis/spec/{target}/` - -### # Tech Stack Compatibility - -| Skill Requirement | Project Status | Compatibility | -|-------------------|----------------|---------------| -| [Tech 1] | [Project tech] | [OK]/[!]/[X] | - -### # Integration Locations - -| Type | Path | -|------|------| -| Guidelines doc | `.trellis/spec/{target}/doc.md` (section: `skill-<name>`) | -| Code examples | `.trellis/spec/{target}/examples/skills/<name>/` | -| Index update | `.trellis/spec/{target}/index.md` | - -> `{target}` = `frontend` or `backend` - -### # Dependencies (if needed) - -```bash -# Install required dependencies (adjust for your package manager) -npm install <package> -# or -pnpm add <package> -# or -yarn add <package> -``` - -### [OK] Completed Changes - -- [ ] Added `@@@section:skill-<name>` section to `doc.md` -- [ ] Added index entry to `index.md` -- [ ] Created example files in `examples/skills/<name>/` -- [ ] Example files use `.template` suffix - -### # Related Guidelines - -- [Existing related section IDs] - ---- - -## 6. Optional: Create Usage Command - -If this skill is frequently used, create a shortcut command: - -```bash -/trellis:create-command use-<skill-name> Use <skill-name> skill following project guidelines -``` - -## Common Skill Integration Reference - -| Skill | Integration Target | Examples Directory | -|-------|-------------------|-------------------| -| `frontend-design` | `frontend` | `examples/skills/frontend-design/` | -| `mcp-builder` | `backend` | `examples/skills/mcp-builder/` | -| `webapp-testing` | `frontend` | `examples/skills/webapp-testing/` | -| `doc-coauthoring` | `.trellis/` | N/A (documentation workflow only) | - -## Example: Integrating `mcp-builder` Skill - -### Directory Structure - -``` -.trellis/spec/backend/ -|-- doc.md # Add MCP section -|-- index.md # Add index entry -+-- examples/ - +-- skills/ - +-- mcp-builder/ - |-- README.md - |-- server.ts.template - |-- tools.ts.template - +-- types.ts.template -``` - -### New Section in doc.md - -```markdown -@@@section:skill-mcp-builder -## # MCP Server Development Guide - -### Overview -Create LLM-callable tool services using MCP (Model Context Protocol). - -### Project Adaptation -- Place services in a dedicated directory -- Follow existing TypeScript and type definition conventions -- Use project's logging system - -### Reference Examples -See `examples/skills/mcp-builder/` - -@@@/section:skill-mcp-builder -``` diff --git a/.opencode/commands/trellis/migrate-specs.md b/.opencode/commands/trellis/migrate-specs.md deleted file mode 100644 index e69de29..0000000 diff --git a/.opencode/commands/trellis/onboard.md b/.opencode/commands/trellis/onboard.md deleted file mode 100644 index 732f80d..0000000 --- a/.opencode/commands/trellis/onboard.md +++ /dev/null @@ -1,358 +0,0 @@ -You are a senior developer onboarding a new team member to this project's AI-assisted workflow system. - -YOUR ROLE: Be a mentor and teacher. Don't just list steps - EXPLAIN the underlying principles, why each command exists, what problem it solves at a fundamental level. - -## CRITICAL INSTRUCTION - YOU MUST COMPLETE ALL SECTIONS - -This onboarding has THREE equally important parts: - -**PART 1: Core Concepts** (Sections: CORE PHILOSOPHY, SYSTEM STRUCTURE, COMMAND DEEP DIVE) -- Explain WHY this workflow exists -- Explain WHAT each command does and WHY - -**PART 2: Real-World Examples** (Section: REAL-WORLD WORKFLOW EXAMPLES) -- Walk through ALL 5 examples in detail -- For EACH step in EACH example, explain: - - PRINCIPLE: Why this step exists - - WHAT HAPPENS: What the command actually does - - IF SKIPPED: What goes wrong without it - -**PART 3: Customize Your Development Guidelines** (Section: CUSTOMIZE YOUR DEVELOPMENT GUIDELINES) -- Check if project guidelines are still empty templates -- If empty, guide the developer to fill them with project-specific content -- Explain the customization workflow - -DO NOT skip any part. All three parts are essential: -- Part 1 teaches the concepts -- Part 2 shows how concepts work in practice -- Part 3 ensures the project has proper guidelines for AI to follow - -After completing ALL THREE parts, ask the developer about their first task. - ---- - -## CORE PHILOSOPHY: Why This Workflow Exists - -AI-assisted development has three fundamental challenges: - -### Challenge 1: AI Has No Memory - -Every AI session starts with a blank slate. Unlike human engineers who accumulate project knowledge over weeks/months, AI forgets everything when a session ends. - -**The Problem**: Without memory, AI asks the same questions repeatedly, makes the same mistakes, and can't build on previous work. - -**The Solution**: The `.trellis/workspace/` system captures what happened in each session - what was done, what was learned, what problems were solved. The `/trellis:start` command reads this history at session start, giving AI "artificial memory." - -### Challenge 2: AI Has Generic Knowledge, Not Project-Specific Knowledge - -AI models are trained on millions of codebases - they know general patterns for React, TypeScript, databases, etc. But they don't know YOUR project's conventions. - -**The Problem**: AI writes code that "works" but doesn't match your project's style. It uses patterns that conflict with existing code. It makes decisions that violate unwritten team rules. - -**The Solution**: The `.trellis/spec/` directory contains project-specific guidelines. The `/before-*-dev` commands inject this specialized knowledge into AI context before coding starts. - -### Challenge 3: AI Context Window Is Limited - -Even after injecting guidelines, AI has limited context window. As conversation grows, earlier context (including guidelines) gets pushed out or becomes less influential. - -**The Problem**: AI starts following guidelines, but as the session progresses and context fills up, it "forgets" the rules and reverts to generic patterns. - -**The Solution**: The `/check-*` commands re-verify code against guidelines AFTER writing, catching drift that occurred during development. The `/trellis:finish-work` command does a final holistic review. - ---- - -## SYSTEM STRUCTURE - -``` -.trellis/ -|-- .developer # Your identity (gitignored) -|-- workflow.md # Complete workflow documentation -|-- workspace/ # "AI Memory" - session history -| |-- index.md # All developers' progress -| +-- {developer}/ # Per-developer directory -| |-- index.md # Personal progress index -| +-- journal-N.md # Session records (max 2000 lines) -|-- tasks/ # Task tracking (unified) -| +-- {MM}-{DD}-{slug}/ # Task directory -| |-- task.json # Task metadata -| +-- prd.md # Requirements doc -|-- spec/ # "AI Training Data" - project knowledge -| |-- frontend/ # Frontend conventions -| |-- backend/ # Backend conventions -| +-- guides/ # Thinking patterns -+-- scripts/ # Automation tools -``` - -### Understanding spec/ subdirectories - -**frontend/** - Single-layer frontend knowledge: -- Component patterns (how to write components in THIS project) -- State management rules (Redux? Zustand? Context?) -- Styling conventions (CSS modules? Tailwind? Styled-components?) -- Hook patterns (custom hooks, data fetching) - -**backend/** - Single-layer backend knowledge: -- API design patterns (REST? GraphQL? tRPC?) -- Database conventions (query patterns, migrations) -- Error handling standards -- Logging and monitoring rules - -**guides/** - Cross-layer thinking guides: -- Code reuse thinking guide -- Cross-layer thinking guide -- Pre-implementation checklists - ---- - -## COMMAND DEEP DIVE - -### /trellis:start - Restore AI Memory - -**WHY IT EXISTS**: -When a human engineer joins a project, they spend days/weeks learning: What is this project? What's been built? What's in progress? What's the current state? - -AI needs the same onboarding - but compressed into seconds at session start. - -**WHAT IT ACTUALLY DOES**: -1. Reads developer identity (who am I in this project?) -2. Checks git status (what branch? uncommitted changes?) -3. Reads recent session history from `workspace/` (what happened before?) -4. Identifies active features (what's in progress?) -5. Understands current project state before making any changes - -**WHY THIS MATTERS**: -- Without /trellis:start: AI is blind. It might work on wrong branch, conflict with others' work, or redo already-completed work. -- With /trellis:start: AI knows project context, can continue where previous session left off, avoids conflicts. - ---- - -### /trellis:before-frontend-dev and /trellis:before-backend-dev - Inject Specialized Knowledge - -**WHY IT EXISTS**: -AI models have "pre-trained knowledge" - general patterns from millions of codebases. But YOUR project has specific conventions that differ from generic patterns. - -**WHAT IT ACTUALLY DOES**: -1. Reads `.trellis/spec/frontend/` or `.trellis/spec/backend/` -2. Loads project-specific patterns into AI's working context: - - Component naming conventions - - State management patterns - - Database query patterns - - Error handling standards - -**WHY THIS MATTERS**: -- Without before-*-dev: AI writes generic code that doesn't match project style. -- With before-*-dev: AI writes code that looks like the rest of the codebase. - ---- - -### /trellis:check-frontend and /trellis:check-backend - Combat Context Drift - -**WHY IT EXISTS**: -AI context window has limited capacity. As conversation progresses, guidelines injected at session start become less influential. This causes "context drift." - -**WHAT IT ACTUALLY DOES**: -1. Re-reads the guidelines that were injected earlier -2. Compares written code against those guidelines -3. Runs type checker and linter -4. Identifies violations and suggests fixes - -**WHY THIS MATTERS**: -- Without check-*: Context drift goes unnoticed, code quality degrades. -- With check-*: Drift is caught and corrected before commit. - ---- - -### /trellis:check-cross-layer - Multi-Dimension Verification - -**WHY IT EXISTS**: -Most bugs don't come from lack of technical skill - they come from "didn't think of it": -- Changed a constant in one place, missed 5 other places -- Modified database schema, forgot to update the API layer -- Created a utility function, but similar one already exists - -**WHAT IT ACTUALLY DOES**: -1. Identifies which dimensions your change involves -2. For each dimension, runs targeted checks: - - Cross-layer data flow - - Code reuse analysis - - Import path validation - - Consistency checks - ---- - -### /trellis:finish-work - Holistic Pre-Commit Review - -**WHY IT EXISTS**: -The `/check-*` commands focus on code quality within a single layer. But real changes often have cross-cutting concerns. - -**WHAT IT ACTUALLY DOES**: -1. Reviews all changes holistically -2. Checks cross-layer consistency -3. Identifies broader impacts -4. Checks if new patterns should be documented - ---- - -### /trellis:record-session - Persist Memory for Future - -**WHY IT EXISTS**: -All the context AI built during this session will be lost when session ends. The next session's `/trellis:start` needs this information. - -**WHAT IT ACTUALLY DOES**: -1. Records session summary to `workspace/{developer}/journal-N.md` -2. Captures what was done, learned, and what's remaining -3. Updates index files for quick lookup - ---- - -## REAL-WORLD WORKFLOW EXAMPLES - -### Example 1: Bug Fix Session - -**[1/8] /trellis:start** - AI needs project context before touching code -**[2/8] python3 ./.trellis/scripts/task.py create "Fix bug" --slug fix-bug** - Track work for future reference -**[3/8] /trellis:before-frontend-dev** - Inject project-specific frontend knowledge -**[4/8] Investigate and fix the bug** - Actual development work -**[5/8] /trellis:check-frontend** - Re-verify code against guidelines -**[6/8] /trellis:finish-work** - Holistic cross-layer review -**[7/8] Human tests and commits** - Human validates before code enters repo -**[8/8] /trellis:record-session** - Persist memory for future sessions - -### Example 2: Planning Session (No Code) - -**[1/4] /trellis:start** - Context needed even for non-coding work -**[2/4] python3 ./.trellis/scripts/task.py create "Planning task" --slug planning-task** - Planning is valuable work -**[3/4] Review docs, create subtask list** - Actual planning work -**[4/4] /trellis:record-session (with --summary)** - Planning decisions must be recorded - -### Example 3: Code Review Fixes - -**[1/6] /trellis:start** - Resume context from previous session -**[2/6] /trellis:before-backend-dev** - Re-inject guidelines before fixes -**[3/6] Fix each CR issue** - Address feedback with guidelines in context -**[4/6] /trellis:check-backend** - Verify fixes didn't introduce new issues -**[5/6] /trellis:finish-work** - Document lessons from CR -**[6/6] Human commits, then /trellis:record-session** - Preserve CR lessons - -### Example 4: Large Refactoring - -**[1/5] /trellis:start** - Clear baseline before major changes -**[2/5] Plan phases** - Break into verifiable chunks -**[3/5] Execute phase by phase with /check-* after each** - Incremental verification -**[4/5] /trellis:finish-work** - Check if new patterns should be documented -**[5/5] Record with multiple commit hashes** - Link all commits to one feature - -### Example 5: Debug Session - -**[1/6] /trellis:start** - See if this bug was investigated before -**[2/6] /trellis:before-backend-dev** - Guidelines might document known gotchas -**[3/6] Investigation** - Actual debugging work -**[4/6] /trellis:check-backend** - Verify debug changes don't break other things -**[5/6] /trellis:finish-work** - Debug findings might need documentation -**[6/6] Human commits, then /trellis:record-session** - Debug knowledge is valuable - ---- - -## KEY RULES TO EMPHASIZE - -1. **AI NEVER commits** - Human tests and approves. AI prepares, human validates. -2. **Guidelines before code** - /before-*-dev commands inject project knowledge. -3. **Check after code** - /check-* commands catch context drift. -4. **Record everything** - /trellis:record-session persists memory. - ---- - -# PART 3: Customize Your Development Guidelines - -After explaining Part 1 and Part 2, check if the project's development guidelines need customization. - -## Step 1: Check Current Guidelines Status - -Check if `.trellis/spec/` contains empty templates or customized guidelines: - -```bash -# Check if files are still empty templates (look for placeholder text) -grep -l "To be filled by the team" .trellis/spec/backend/*.md 2>/dev/null | wc -l -grep -l "To be filled by the team" .trellis/spec/frontend/*.md 2>/dev/null | wc -l -``` - -## Step 2: Determine Situation - -**Situation A: First-time setup (empty templates)** - -If guidelines are empty templates (contain "To be filled by the team"), this is the first time using Trellis in this project. - -Explain to the developer: - -"I see that the development guidelines in `.trellis/spec/` are still empty templates. This is normal for a new Trellis setup! - -The templates contain placeholder text that needs to be replaced with YOUR project's actual conventions. Without this, `/before-*-dev` commands won't provide useful guidance. - -**Your first task should be to fill in these guidelines:** - -1. Look at your existing codebase -2. Identify the patterns and conventions already in use -3. Document them in the guideline files - -For example, for `.trellis/spec/backend/database-guidelines.md`: -- What ORM/query library does your project use? -- How are migrations managed? -- What naming conventions for tables/columns? - -Would you like me to help you analyze your codebase and fill in these guidelines?" - -**Situation B: Guidelines already customized** - -If guidelines have real content (no "To be filled" placeholders), this is an existing setup. - -Explain to the developer: - -"Great! Your team has already customized the development guidelines. You can start using `/before-*-dev` commands right away. - -I recommend reading through `.trellis/spec/` to familiarize yourself with the team's coding standards." - -## Step 3: Help Fill Guidelines (If Empty) - -If the developer wants help filling guidelines, create a feature to track this: - -```bash -python3 ./.trellis/scripts/task.py create "Fill spec guidelines" --slug fill-spec-guidelines -``` - -Then systematically analyze the codebase and fill each guideline file: - -1. **Analyze the codebase** - Look at existing code patterns -2. **Document conventions** - Write what you observe, not ideals -3. **Include examples** - Reference actual files in the project -4. **List forbidden patterns** - Document anti-patterns the team avoids - -Work through one file at a time: -- `backend/directory-structure.md` -- `backend/database-guidelines.md` -- `backend/error-handling.md` -- `backend/quality-guidelines.md` -- `backend/logging-guidelines.md` -- `frontend/directory-structure.md` -- `frontend/component-guidelines.md` -- `frontend/hook-guidelines.md` -- `frontend/state-management.md` -- `frontend/quality-guidelines.md` -- `frontend/type-safety.md` - ---- - -## Completing the Onboard Session - -After covering all three parts, summarize: - -"You're now onboarded to the Trellis workflow system! Here's what we covered: -- Part 1: Core concepts (why this workflow exists) -- Part 2: Real-world examples (how to apply the workflow) -- Part 3: Guidelines status (empty templates need filling / already customized) - -**Next steps** (tell user): -1. Run `/trellis:record-session` to record this onboard session -2. [If guidelines empty] Start filling in `.trellis/spec/` guidelines -3. [If guidelines ready] Start your first development task - -What would you like to do first?" diff --git a/.opencode/commands/trellis/parallel.md b/.opencode/commands/trellis/parallel.md deleted file mode 100644 index 172f689..0000000 --- a/.opencode/commands/trellis/parallel.md +++ /dev/null @@ -1,194 +0,0 @@ -# Multi-Agent Pipeline Orchestrator - -You are the Multi-Agent Pipeline Orchestrator Agent, running in the main repository, responsible for collaborating with users to manage parallel development tasks. - -## Role Definition - -- **You are in the main repository**, not in a worktree -- **You don't write code directly** - code work is done by agents in worktrees -- **You are responsible for planning and dispatching**: discuss requirements, create plans, configure context, start worktree agents -- **Delegate complex analysis to research agent**: finding specs, analyzing code structure - ---- - -## Operation Types - -Operations in this document are categorized as: - -| Marker | Meaning | Executor | -|--------|---------|----------| -| `[AI]` | Bash scripts or Task calls executed by AI | You (AI) | -| `[USER]` | Slash commands executed by user | User | - ---- - -## Startup Flow - -### Step 1: Understand Trellis Workflow `[AI]` - -First, read the workflow guide to understand the development process: - -```bash -cat .trellis/workflow.md # Development process, conventions, and quick start guide -``` - -### Step 2: Get Current Status `[AI]` - -```bash -python3 ./.trellis/scripts/get_context.py -``` - -### Step 3: Read Project Guidelines `[AI]` - -```bash -cat .trellis/spec/frontend/index.md # Frontend guidelines index -cat .trellis/spec/backend/index.md # Backend guidelines index -cat .trellis/spec/guides/index.md # Thinking guides -``` - -### Step 4: Ask User for Requirements - -Ask the user: - -1. What feature to develop? -2. Which modules are involved? -3. Development type? (backend / frontend / fullstack) - ---- - -## Planning: Choose Your Approach - -Based on requirement complexity, choose one of these approaches: - -### Option A: Plan Agent (Recommended for complex features) `[AI]` - -Use when: -- Requirements need analysis and validation -- Multiple modules or cross-layer changes -- Unclear scope that needs research - -```bash -python3 ./.trellis/scripts/multi_agent/plan.py \ - --name "<feature-name>" \ - --type "<backend|frontend|fullstack>" \ - --requirement "<user requirement description>" \ - --platform opencode -``` - -Plan Agent will: -1. Evaluate requirement validity (may reject if unclear/too large) -2. Call research agent to analyze codebase -3. Create and configure task directory -4. Write prd.md with acceptance criteria -5. Output ready-to-use task directory - -After plan.py completes, start the worktree agent: - -```bash -python3 ./.trellis/scripts/multi_agent/start.py "$TASK_DIR" --platform opencode -``` - -### Option B: Manual Configuration (For simple/clear features) `[AI]` - -Use when: -- Requirements are already clear and specific -- You know exactly which files are involved -- Simple, well-scoped changes - -#### Step 1: Create Task Directory - -```bash -# title is task description, --slug for task directory name -TASK_DIR=$(python3 ./.trellis/scripts/task.py create "<title>" --slug <task-name>) -``` - -#### Step 2: Configure Task - -```bash -# Initialize jsonl context files -python3 ./.trellis/scripts/task.py init-context "$TASK_DIR" <dev_type> - -# Set branch and scope -python3 ./.trellis/scripts/task.py set-branch "$TASK_DIR" feature/<name> -python3 ./.trellis/scripts/task.py set-scope "$TASK_DIR" <scope> -``` - -#### Step 3: Add Context (optional: use research agent) - -```bash -python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" implement "<path>" "<reason>" -python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" check "<path>" "<reason>" -``` - -#### Step 4: Create prd.md - -```bash -cat > "$TASK_DIR/prd.md" << 'EOF' -# Feature: <name> - -## Requirements -- ... - -## Acceptance Criteria -- ... -EOF -``` - -#### Step 5: Validate and Start - -```bash -python3 ./.trellis/scripts/task.py validate "$TASK_DIR" -python3 ./.trellis/scripts/multi_agent/start.py "$TASK_DIR" --platform opencode -``` - ---- - -## After Starting: Report Status - -Tell the user the agent has started and provide monitoring commands. - ---- - -## User Available Commands `[USER]` - -The following slash commands are for users (not AI): - -| Command | Description | -|---------|-------------| -| `/trellis:parallel` | Start Multi-Agent Pipeline (this command) | -| `/trellis:start` | Start normal development mode (single process) | -| `/trellis:record-session` | Record session progress | -| `/trellis:finish-work` | Pre-completion checklist | - ---- - -## Monitoring Commands (for user reference) - -Tell the user they can use these commands to monitor: - -```bash -python3 ./.trellis/scripts/multi_agent/status.py # Overview -python3 ./.trellis/scripts/multi_agent/status.py --log <name> # View log -python3 ./.trellis/scripts/multi_agent/status.py --watch <name> # Real-time monitoring -python3 ./.trellis/scripts/multi_agent/cleanup.py <branch> # Cleanup worktree -``` - ---- - -## Pipeline Phases - -The dispatch agent in worktree will automatically execute: - -1. implement → Implement feature -2. check → Check code quality -3. finish → Final verification -4. create-pr → Create PR - ---- - -## Core Rules - -- **Don't write code directly** - delegate to agents in worktree -- **Don't execute git commit** - agent does it via create-pr action -- **Delegate complex analysis to research** - finding specs, analyzing code structure -- **Subagents use globally configured model** - inherits from user's OpenCode config diff --git a/.opencode/commands/trellis/record-session.md b/.opencode/commands/trellis/record-session.md deleted file mode 100644 index 4a7e6ff..0000000 --- a/.opencode/commands/trellis/record-session.md +++ /dev/null @@ -1,61 +0,0 @@ -[!] **Prerequisite**: This command should only be used AFTER the human has tested and committed the code. - -**Do NOT run `git commit` directly** — the scripts below handle their own commits for `.trellis/` metadata. You only need to read git history (`git log`, `git status`, `git diff`) and run the Python scripts. - ---- - -## Record Work Progress - -### Step 1: Get Context & Check Tasks - -```bash -python3 ./.trellis/scripts/get_context.py --mode record -``` - -[!] Archive tasks whose work is **actually done** — judge by work status, not the `status` field in task.json: -- Code committed? → Archive it (don't wait for PR) -- All acceptance criteria met? → Archive it -- Don't skip archiving just because `status` still says `planning` or `in_progress` - -```bash -python3 ./.trellis/scripts/task.py archive <task-name> -``` - -### Step 2: One-Click Add Session - -```bash -# Method 1: Simple parameters -python3 ./.trellis/scripts/add_session.py \ - --title "Session Title" \ - --commit "hash1,hash2" \ - --summary "Brief summary of what was done" - -# Method 2: Pass detailed content via stdin -cat << 'EOF' | python3 ./.trellis/scripts/add_session.py --title "Title" --commit "hash" -| Feature | Description | -|---------|-------------| -| New API | Added user authentication endpoint | -| Frontend | Updated login form | - -**Updated Files**: -- `packages/api/modules/auth/router.ts` -- `apps/web/modules/auth/components/login-form.tsx` -EOF -``` - -**Auto-completes**: -- [OK] Appends session to journal-N.md -- [OK] Auto-detects line count, creates new file if >2000 lines -- [OK] Updates index.md (Total Sessions +1, Last Active, line stats, history) -- [OK] Auto-commits .trellis/workspace and .trellis/tasks changes - ---- - -## Script Command Reference - -| Command | Purpose | -|---------|---------| -| `python3 ./.trellis/scripts/get_context.py --mode record` | Get context for record-session | -| `python3 ./.trellis/scripts/add_session.py --title "..." --commit "..."` | **One-click add session (recommended)** | -| `python3 ./.trellis/scripts/task.py archive <name>` | Archive completed task (auto-commits) | -| `python3 ./.trellis/scripts/task.py list` | List active tasks | diff --git a/.opencode/commands/trellis/start.md b/.opencode/commands/trellis/start.md deleted file mode 100644 index 4040de8..0000000 --- a/.opencode/commands/trellis/start.md +++ /dev/null @@ -1,346 +0,0 @@ -# Start Session - -Initialize your AI development session and begin working on tasks. - ---- - -## Operation Types - -| Marker | Meaning | Executor | -|--------|---------|----------| -| `[AI]` | Bash scripts or Task calls executed by AI | You (AI) | -| `[USER]` | Slash commands executed by user | User | - ---- - -## Initialization `[AI]` - -### Step 1: Understand Development Workflow - -First, read the workflow guide to understand the development process: - -```bash -cat .trellis/workflow.md -``` - -**Follow the instructions in workflow.md** - it contains: -- Core principles (Read Before Write, Follow Standards, etc.) -- File system structure -- Development process -- Best practices - -### Step 2: Get Current Context - -```bash -python3 ./.trellis/scripts/get_context.py -``` - -This shows: developer identity, git status, current task (if any), active tasks. - -### Step 3: Read Guidelines Index - -```bash -cat .trellis/spec/frontend/index.md # Frontend guidelines -cat .trellis/spec/backend/index.md # Backend guidelines -cat .trellis/spec/guides/index.md # Thinking guides -``` - -> **Important**: The index files are navigation — they list the actual guideline files (e.g., `error-handling.md`, `conventions.md`, `mock-strategies.md`). -> At this step, just read the indexes to understand what's available. -> When you start actual development, you MUST go back and read the specific guideline files relevant to your task, as listed in the index's Pre-Development Checklist. - -### Step 4: Report and Ask - -Report what you learned and ask: "What would you like to work on?" - ---- - -## Task Classification - -When user describes a task, classify it: - -| Type | Criteria | Workflow | -|------|----------|----------| -| **Question** | User asks about code, architecture, or how something works | Answer directly | -| **Trivial Fix** | Typo fix, comment update, single-line change, < 5 minutes | Direct Edit | -| **Simple Task** | Clear goal, 1-2 files, well-defined scope | Quick confirm → Task Workflow | -| **Complex Task** | Vague goal, multiple files, architectural decisions | **Brainstorm → Task Workflow** | - -### Decision Rule - -> **If in doubt, use Brainstorm + Task Workflow.** -> -> Task Workflow ensures code-spec context is injected to agents, resulting in higher quality code. -> The overhead is minimal, but the benefit is significant. - ---- - -## Question / Trivial Fix - -For questions or trivial fixes, work directly: - -1. Answer question or make the fix -2. If code was changed, remind user to run `/trellis:finish-work` - ---- - -## Complex Task - Brainstorm First - -For complex or vague tasks, **automatically start the brainstorm process** — do NOT skip directly to implementation. - -See `/trellis:brainstorm` for the full process. Summary: - -1. **Acknowledge and classify** - State your understanding -2. **Create task directory** - Track evolving requirements in `prd.md` -3. **Ask questions one at a time** - Update PRD after each answer -4. **Propose approaches** - For architectural decisions -5. **Confirm final requirements** - Get explicit approval -6. **Proceed to Task Workflow** - With clear requirements in PRD - -> **Subtask Decomposition**: If brainstorm reveals multiple independent work items, -> consider creating subtasks using `--parent` flag or `add-subtask` command. -> See `/trellis:brainstorm` Step 8 for details. - ---- - -## Task Workflow (Development Tasks) - -**Why this workflow?** -- Research Agent analyzes what code-spec files are needed -- Code-spec files are configured in jsonl files -- Implement Agent receives code-spec context via Hook injection -- Check Agent verifies against code-spec requirements -- Result: Code that follows project conventions automatically - -### Overview: Two Entry Points - -``` -From Brainstorm (Complex Task): - PRD confirmed → Research → Configure Context → Activate → Implement → Check → Complete - -From Simple Task: - Confirm → Create Task → Write PRD → Research → Configure Context → Activate → Implement → Check → Complete -``` - -**Key principle: Research happens AFTER requirements are clear (PRD exists).** - ---- - -### Phase 1: Establish Requirements - -#### Path A: From Brainstorm (skip to Phase 2) - -PRD and task directory already exist from brainstorm. Skip directly to Phase 2. - -#### Path B: From Simple Task - -**Step 1: Confirm Understanding** `[AI]` - -Quick confirm: -- What is the goal? -- What type of development? (frontend / backend / fullstack) -- Any specific requirements or constraints? - -**Step 2: Create Task Directory** `[AI]` - -```bash -TASK_DIR=$(python3 ./.trellis/scripts/task.py create "<title>" --slug <name>) -``` - -**Step 3: Write PRD** `[AI]` - -Create `prd.md` in the task directory with: - -```markdown -# <Task Title> - -## Goal -<What we're trying to achieve> - -## Requirements -- <Requirement 1> -- <Requirement 2> - -## Acceptance Criteria -- [ ] <Criterion 1> -- [ ] <Criterion 2> - -## Technical Notes -<Any technical decisions or constraints> -``` - ---- - -### Phase 2: Prepare for Implementation (shared) - -> Both paths converge here. PRD and task directory must exist before proceeding. - -**Step 4: Code-Spec Depth Check** `[AI]` - -If the task touches infra or cross-layer contracts, do not start implementation until code-spec depth is defined. - -Trigger this requirement when the change includes any of: -- New or changed command/API signatures -- Database schema or migration changes -- Infra integrations (storage, queue, cache, secrets, env contracts) -- Cross-layer payload transformations - -Must-have before proceeding: -- [ ] Target code-spec files to update are identified -- [ ] Concrete contract is defined (signature, fields, env keys) -- [ ] Validation and error matrix is defined -- [ ] At least one Good/Base/Bad case is defined - -**Step 5: Research the Codebase** `[AI]` - -Based on the confirmed PRD, call Research Agent to find relevant specs and patterns: - -``` -Task( - subagent_type: "research", - prompt: "Analyze the codebase for this task: - - Task: <goal from PRD> - Type: <frontend/backend/fullstack> - - Please find: - 1. Relevant code-spec files in .trellis/spec/ - 2. Existing code patterns to follow (find 2-3 examples) - 3. Files that will likely need modification - - Output: - ## Relevant Code-Specs - - <path>: <why it's relevant> - - ## Code Patterns Found - - <pattern>: <example file path> - - ## Files to Modify - - <path>: <what change>", - model: "opus" -) -``` - -**Step 6: Configure Context** `[AI]` - -Initialize default context: - -```bash -python3 ./.trellis/scripts/task.py init-context "$TASK_DIR" <type> -# type: backend | frontend | fullstack -``` - -Add code-spec files found by Research Agent: - -```bash -# For each relevant code-spec and code pattern: -python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" implement "<path>" "<reason>" -python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" check "<path>" "<reason>" -``` - -**Step 7: Activate Task** `[AI]` - -```bash -python3 ./.trellis/scripts/task.py start "$TASK_DIR" -``` - -This sets `.current-task` so hooks can inject context. - ---- - -### Phase 3: Execute (shared) - -**Step 8: Implement** `[AI]` - -Call Implement Agent (code-spec context is auto-injected by hook): - -``` -Task( - subagent_type: "implement", - prompt: "Implement the task described in prd.md. - - Follow all code-spec files that have been injected into your context. - Run lint and typecheck before finishing.", - model: "opus" -) -``` - -**Step 9: Check Quality** `[AI]` - -Call Check Agent (code-spec context is auto-injected by hook): - -``` -Task( - subagent_type: "check", - prompt: "Review all code changes against the code-spec requirements. - - Fix any issues you find directly. - Ensure lint and typecheck pass.", - model: "opus" -) -``` - -**Step 10: Complete** `[AI]` - -1. Verify lint and typecheck pass -2. Report what was implemented -3. Remind user to: - - Test the changes - - Commit when ready - - Run `/trellis:record-session` to record this session - ---- - -## Continuing Existing Task - -If `get_context.py` shows a current task: - -1. Read the task's `prd.md` to understand the goal -2. Check `task.json` for current status and phase -3. Ask user: "Continue working on <task-name>?" - -If yes, resume from the appropriate step (usually Step 7 or 8). - ---- - -## Commands Reference - -### User Commands `[USER]` - -| Command | When to Use | -|---------|-------------| -| `/trellis:start` | Begin a session (this command) | -| `/trellis:brainstorm` | Clarify vague requirements (called from start) | -| `/trellis:parallel` | Complex tasks needing isolated worktree | -| `/trellis:finish-work` | Before committing changes | -| `/trellis:record-session` | After completing a task | - -### AI Scripts `[AI]` - -| Script | Purpose | -|--------|---------| -| `python3 ./.trellis/scripts/get_context.py` | Get session context | -| `python3 ./.trellis/scripts/task.py create` | Create task directory | -| `python3 ./.trellis/scripts/task.py init-context` | Initialize jsonl files | -| `python3 ./.trellis/scripts/task.py add-context` | Add code-spec/context file to jsonl | -| `python3 ./.trellis/scripts/task.py start` | Set current task | -| `python3 ./.trellis/scripts/task.py finish` | Clear current task | -| `python3 ./.trellis/scripts/task.py archive` | Archive completed task | - -### Sub Agents `[AI]` - -| Agent | Purpose | Hook Injection | -|-------|---------|----------------| -| research | Analyze codebase | No (reads directly) | -| implement | Write code | Yes (implement.jsonl) | -| check | Review & fix | Yes (check.jsonl) | -| debug | Fix specific issues | Yes (debug.jsonl) | - ---- - -## Key Principle - -> **Code-spec context is injected, not remembered.** -> -> The Task Workflow ensures agents receive relevant code-spec context automatically. -> This is more reliable than hoping the AI "remembers" conventions. diff --git a/.opencode/lib/trellis-context.js b/.opencode/lib/trellis-context.js deleted file mode 100644 index 972802d..0000000 --- a/.opencode/lib/trellis-context.js +++ /dev/null @@ -1,436 +0,0 @@ -/** - * Trellis Context Manager - * - * Unified context management for OpenCode plugins. - * Handles detection of oh-my-opencode, .claude/hooks/, and other edge cases. - * - * Usage: - * import { TrellisContext } from "./trellis-context.js" - * const ctx = new TrellisContext(directory) - * if (ctx.shouldSkipHook("session-start")) return - */ - -import { existsSync, readFileSync, appendFileSync, readdirSync } from "fs" -import { join } from "path" -import { homedir, platform } from "os" -import { execSync } from "child_process" - -// Python command: Windows uses 'python', macOS/Linux use 'python3' -const PYTHON_CMD = platform() === "win32" ? "python" : "python3" - -// Debug logging -const DEBUG_LOG = "/tmp/trellis-plugin-debug.log" - -function debugLog(prefix, ...args) { - const timestamp = new Date().toISOString() - const msg = `[${timestamp}] [${prefix}] ${args.map(a => typeof a === "object" ? JSON.stringify(a) : a).join(" ")}\n` - try { - appendFileSync(DEBUG_LOG, msg) - } catch { - // ignore - } -} - -/** - * Trellis Context Manager - * - * Centralized logic for: - * - Detecting oh-my-opencode installation - * - Checking .claude/hooks/ presence - * - Determining which plugin should handle each hook - */ -export class TrellisContext { - constructor(directory) { - this.directory = directory - this._omoInstalled = null - this._omoHooksEnabled = null - this._claudeHooksPresent = {} - - debugLog("context", "TrellisContext initialized", { directory }) - } - - // ============================================================ - // oh-my-opencode Detection - // ============================================================ - - /** - * Check if oh-my-opencode is installed - * - * Detection order: - * 1. Check if oh-my-opencode.json exists (most reliable) - * 2. Fallback: check opencode.json plugin list - */ - isOmoInstalled() { - if (this._omoInstalled !== null) { - return this._omoInstalled - } - - try { - const configDir = join(homedir(), ".config", "opencode") - - // Method 1: Check oh-my-opencode.json existence (omo-specific config) - const omoConfigPath = join(configDir, "oh-my-opencode.json") - if (existsSync(omoConfigPath)) { - this._omoInstalled = true - debugLog("context", "omo installed: oh-my-opencode.json exists") - return true - } - - // Method 2: Fallback to plugin list check - const configPath = join(configDir, "opencode.json") - if (!existsSync(configPath)) { - this._omoInstalled = false - debugLog("context", "omo not installed: no config files") - return false - } - - const content = readFileSync(configPath, "utf-8") - const config = JSON.parse(content) - const plugins = config.plugin || [] - - this._omoInstalled = plugins.some(p => - typeof p === "string" && p.toLowerCase().includes("oh-my-opencode") - ) - - debugLog("context", "omo installed (plugin list):", this._omoInstalled) - return this._omoInstalled - } catch (e) { - debugLog("context", "omo detection error:", e.message) - this._omoInstalled = false - return false - } - } - - /** - * Check if omo's claude_code.hooks is enabled - * Reads oh-my-opencode.json or defaults to true - */ - isOmoHooksEnabled() { - if (this._omoHooksEnabled !== null) { - return this._omoHooksEnabled - } - - if (!this.isOmoInstalled()) { - this._omoHooksEnabled = false - return false - } - - try { - // Check global config - const globalConfig = join(homedir(), ".config", "opencode", "oh-my-opencode.json") - if (existsSync(globalConfig)) { - const content = readFileSync(globalConfig, "utf-8") - const config = JSON.parse(content) - if (config.claude_code?.hooks === false) { - this._omoHooksEnabled = false - debugLog("context", "omo hooks disabled in global config") - return false - } - } - - // Check project config - const projectConfig = join(this.directory, "oh-my-opencode.json") - if (existsSync(projectConfig)) { - const content = readFileSync(projectConfig, "utf-8") - const config = JSON.parse(content) - if (config.claude_code?.hooks === false) { - this._omoHooksEnabled = false - debugLog("context", "omo hooks disabled in project config") - return false - } - } - - // Default: enabled - this._omoHooksEnabled = true - debugLog("context", "omo hooks enabled (default)") - return true - } catch (e) { - debugLog("context", "omo hooks detection error:", e.message) - this._omoHooksEnabled = true // Default to enabled - return true - } - } - - // ============================================================ - // .claude/hooks/ Detection - // ============================================================ - - /** - * Check if a specific .claude/hooks/ file exists - */ - hasClaudeHook(hookName) { - if (hookName in this._claudeHooksPresent) { - return this._claudeHooksPresent[hookName] - } - - const hookPath = join(this.directory, ".claude", "hooks", `${hookName}.py`) - const exists = existsSync(hookPath) - - this._claudeHooksPresent[hookName] = exists - debugLog("context", `claude hook ${hookName}:`, exists) - return exists - } - - // ============================================================ - // Trellis Project Detection - // ============================================================ - - /** - * Check if this is a Trellis-managed project - */ - isTrellisProject() { - return existsSync(join(this.directory, ".trellis")) - } - - /** - * Get current task directory from .trellis/.current-task - */ - getCurrentTask() { - try { - const currentTaskPath = join(this.directory, ".trellis", ".current-task") - if (!existsSync(currentTaskPath)) { - return null - } - return readFileSync(currentTaskPath, "utf-8").trim() - } catch { - return null - } - } - - // ============================================================ - // Hook Decision Logic - // ============================================================ - - /** - * Determine if our plugin should skip this hook - * (because omo will handle it via .claude/hooks/) - * - * @param {string} hookName - Hook name without extension (e.g., "session-start") - * @returns {boolean} - true if we should skip, false if we should handle - */ - shouldSkipHook(hookName) { - // Not a Trellis project? Skip. - if (!this.isTrellisProject()) { - debugLog("context", `shouldSkipHook(${hookName}): skip - not Trellis project`) - return true - } - - // omo not installed? We handle it. - if (!this.isOmoInstalled()) { - debugLog("context", `shouldSkipHook(${hookName}): handle - omo not installed`) - return false - } - - // omo installed but hooks disabled? We handle it. - if (!this.isOmoHooksEnabled()) { - debugLog("context", `shouldSkipHook(${hookName}): handle - omo hooks disabled`) - return false - } - - // omo installed + hooks enabled + .claude/hooks/ exists? Skip (omo handles). - if (this.hasClaudeHook(hookName)) { - debugLog("context", `shouldSkipHook(${hookName}): skip - omo will handle via .claude/hooks/`) - return true - } - - // omo installed but no .claude/hooks/ file? We handle it. - debugLog("context", `shouldSkipHook(${hookName}): handle - no .claude/hooks/ file`) - return false - } - - // ============================================================ - // File Reading Utilities - // ============================================================ - - /** - * Read a file, return null on error - */ - readFile(filePath) { - try { - if (existsSync(filePath)) { - return readFileSync(filePath, "utf-8") - } - } catch { - // Ignore read errors - } - return null - } - - /** - * Read a file relative to project directory - */ - readProjectFile(relativePath) { - return this.readFile(join(this.directory, relativePath)) - } - - /** - * Run a Python script and return output - */ - runScript(scriptPath, cwd = null) { - try { - const result = execSync(`${PYTHON_CMD} "${scriptPath}"`, { - cwd: cwd || this.directory, - timeout: 10000, - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"] - }) - return result || "" - } catch { - return "" - } - } - - // ============================================================ - // JSONL Reading - // ============================================================ - - /** - * Read all .md files in a directory - * @param {string} dirPath - Directory path relative to project root - * @param {number} maxFiles - Max files to read (prevent huge directories) - * @returns {Array<{path: string, content: string}>} - */ - readDirectoryMdFiles(dirPath, maxFiles = 20) { - const results = [] - const fullPath = join(this.directory, dirPath) - - if (!existsSync(fullPath)) { - return results - } - - try { - const files = readdirSync(fullPath) - .filter(f => f.endsWith(".md")) - .sort() - .slice(0, maxFiles) - - for (const filename of files) { - const filePath = join(dirPath, filename) - const content = this.readProjectFile(filePath) - if (content) { - results.push({ path: filePath, content }) - } - } - } catch { - // Ignore directory read errors - } - - return results - } - - /** - * Read a JSONL file and load referenced files/directories - * Supports: - * {"file": "path/to/file.md", "reason": "..."} - * {"file": "path/to/dir/", "type": "directory", "reason": "..."} - */ - readJsonlWithFiles(jsonlPath) { - const results = [] - const content = this.readFile(jsonlPath) - if (!content) return results - - for (const line of content.split("\n")) { - if (!line.trim()) continue - try { - const item = JSON.parse(line) - const file = item.file || item.path - const entryType = item.type || "file" - - if (!file) continue - - if (entryType === "directory") { - // Read all .md files in directory - const dirEntries = this.readDirectoryMdFiles(file) - results.push(...dirEntries) - } else { - // Read single file - const fullPath = join(this.directory, file) - const fileContent = this.readFile(fullPath) - if (fileContent) { - results.push({ path: file, content: fileContent }) - } - } - } catch { - // Ignore parse errors for individual lines - } - } - return results - } - - /** - * Build context string from file entries - */ - buildContextFromEntries(entries) { - return entries.map(e => `=== ${e.path} ===\n${e.content}`).join("\n\n") - } -} - -// ============================================================ -// Context Collector (for synthetic message injection) -// ============================================================ - -/** - * Simple context collector for cross-hook communication - * Similar to oh-my-opencode's contextCollector - */ -class ContextCollector { - constructor() { - this.pending = new Map() - this.processed = new Set() - } - - /** - * Store context for a session - */ - store(sessionID, content) { - this.pending.set(sessionID, { - content, - timestamp: Date.now() - }) - debugLog("collector", "stored context for session:", sessionID, "length:", content.length) - } - - /** - * Check if session has pending context - */ - hasPending(sessionID) { - return this.pending.has(sessionID) - } - - /** - * Get and consume pending context - */ - consume(sessionID) { - const pending = this.pending.get(sessionID) - this.pending.delete(sessionID) - return pending - } - - /** - * Mark session as processed (for first-message-only injection) - */ - markProcessed(sessionID) { - this.processed.add(sessionID) - } - - /** - * Check if session was already processed - */ - isProcessed(sessionID) { - return this.processed.has(sessionID) - } - - /** - * Clear session state - */ - clear(sessionID) { - this.pending.delete(sessionID) - this.processed.delete(sessionID) - } -} - -// Singleton instance -export const contextCollector = new ContextCollector() - -// Export debug log for plugins -export { debugLog } diff --git a/.opencode/opencode.json.example b/.opencode/opencode.json.example deleted file mode 100644 index 63f11c1..0000000 --- a/.opencode/opencode.json.example +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "bash": { - "*": "allow", - "git checkout --*": "ask", - "git checkout -- *": "ask", - "git restore *": "ask", - "git reset --hard*": "ask", - "git reset HEAD*": "ask", - "git revert*": "ask", - "git clean*": "ask", - "git stash drop*": "ask" - } - }, - "mcp": { - "supabase": { - "type": "remote", - "enabled": true, - "url": "" - } - } -} diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json deleted file mode 100644 index 28ed456..0000000 --- a/.opencode/package-lock.json +++ /dev/null @@ -1,376 +0,0 @@ -{ - "name": ".opencode", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "@opencode-ai/plugin": "1.14.22" - } - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", - "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@opencode-ai/plugin": { - "version": "1.14.22", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.22.tgz", - "integrity": "sha512-lJlukegf5ECEHm9Y0NxCjXNfUArpPSUHP6hc+M4VCJ3NFk8uzzVsIXAzPS9Hvf2ltzjEYD/ulCOTi6pleeZ6yw==", - "license": "MIT", - "dependencies": { - "@opencode-ai/sdk": "1.14.22", - "effect": "4.0.0-beta.48", - "zod": "4.1.8" - }, - "peerDependencies": { - "@opentui/core": ">=0.1.99", - "@opentui/solid": ">=0.1.99" - }, - "peerDependenciesMeta": { - "@opentui/core": { - "optional": true - }, - "@opentui/solid": { - "optional": true - } - } - }, - "node_modules/@opencode-ai/sdk": { - "version": "1.14.22", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.22.tgz", - "integrity": "sha512-1PjkrZRAwm9ocfTwOleP/e31HYtLVODb2E1hYTRHMmvF2rmAdCm7lztguYVkAPn/B6koGpFvhslTQH7j+38Fjw==", - "license": "MIT", - "dependencies": { - "cross-spawn": "7.0.6" - } - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/effect": { - "version": "4.0.0-beta.48", - "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz", - "integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "fast-check": "^4.6.0", - "find-my-way-ts": "^0.1.6", - "ini": "^6.0.0", - "kubernetes-types": "^1.30.0", - "msgpackr": "^1.11.9", - "multipasta": "^0.2.7", - "toml": "^4.1.1", - "uuid": "^13.0.0", - "yaml": "^2.8.3" - } - }, - "node_modules/fast-check": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", - "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT", - "dependencies": { - "pure-rand": "^8.0.0" - }, - "engines": { - "node": ">=12.17.0" - } - }, - "node_modules/find-my-way-ts": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", - "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", - "license": "MIT" - }, - "node_modules/ini": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", - "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/kubernetes-types": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", - "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", - "license": "Apache-2.0" - }, - "node_modules/msgpackr": { - "version": "1.11.10", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz", - "integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==", - "license": "MIT", - "optionalDependencies": { - "msgpackr-extract": "^3.0.2" - } - }, - "node_modules/msgpackr-extract": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", - "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build-optional-packages": "5.2.2" - }, - "bin": { - "download-msgpackr-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" - } - }, - "node_modules/multipasta": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", - "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", - "license": "MIT" - }, - "node_modules/node-gyp-build-optional-packages": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", - "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.1" - }, - "bin": { - "node-gyp-build-optional-packages": "bin.js", - "node-gyp-build-optional-packages-optional": "optional.js", - "node-gyp-build-optional-packages-test": "build-test.js" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/pure-rand": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", - "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/toml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", - "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/zod": { - "version": "4.1.8", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/.opencode/plugin/inject-subagent-context.js b/.opencode/plugin/inject-subagent-context.js deleted file mode 100644 index 3ccbed7..0000000 --- a/.opencode/plugin/inject-subagent-context.js +++ /dev/null @@ -1,538 +0,0 @@ -/** - * Trellis Context Injection Plugin - * - * Injects context when Task tool is called with supported subagent types. - * Uses OpenCode's tool.execute.before hook. - * - * Compatibility: - * - If oh-my-opencode handles via .claude/hooks/, this plugin skips - * - Otherwise, this plugin handles injection - */ - -import { existsSync, writeFileSync } from "fs" -import { join } from "path" -import { TrellisContext, debugLog } from "../lib/trellis-context.js" - -// Supported subagent types -const AGENTS_ALL = ["implement", "check", "debug", "research"] -const AGENTS_REQUIRE_TASK = ["implement", "check", "debug"] -// Agents that don't update phase (can be called at any time) -const AGENTS_NO_PHASE_UPDATE = ["debug", "research"] - -/** - * Update current_phase in task.json based on subagent_type - */ -function updateCurrentPhase(ctx, taskDir, subagentType) { - if (AGENTS_NO_PHASE_UPDATE.includes(subagentType)) { - return - } - - const taskJsonPath = join(ctx.directory, taskDir, "task.json") - const content = ctx.readFile(taskJsonPath) - if (!content) return - - try { - const taskData = JSON.parse(content) - const currentPhase = taskData.current_phase || 0 - const nextActions = taskData.next_action || [] - - // Map action names to subagent types - const actionToAgent = { - "implement": "implement", - "check": "check", - "finish": "check" // finish uses check agent - } - - // Find the next phase that matches this subagent_type - let newPhase = null - for (const action of nextActions) { - const phaseNum = action.phase || 0 - const actionName = action.action || "" - const expectedAgent = actionToAgent[actionName] - - // Only consider phases after current_phase - if (phaseNum > currentPhase && expectedAgent === subagentType) { - newPhase = phaseNum - break - } - } - - if (newPhase !== null) { - taskData.current_phase = newPhase - writeFileSync(taskJsonPath, JSON.stringify(taskData, null, 2)) - debugLog("inject", "Updated current_phase to:", newPhase) - } - } catch (e) { - debugLog("inject", "Error updating phase:", e.message) - } -} - -/** - * Get context for implement agent - */ -function getImplementContext(ctx, taskDir) { - const parts = [] - - // 1. Read implement.jsonl (or fallback to spec.jsonl) - let jsonlPath = join(ctx.directory, taskDir, "implement.jsonl") - let entries = ctx.readJsonlWithFiles(jsonlPath) - - if (entries.length === 0) { - // Fallback to spec.jsonl - jsonlPath = join(ctx.directory, taskDir, "spec.jsonl") - entries = ctx.readJsonlWithFiles(jsonlPath) - } - - if (entries.length > 0) { - parts.push(ctx.buildContextFromEntries(entries)) - } - - // 2. Requirements document - const prd = ctx.readProjectFile(join(taskDir, "prd.md")) - if (prd) { - parts.push(`=== ${taskDir}/prd.md (Requirements) ===\n${prd}`) - } - - // 3. Technical design - const info = ctx.readProjectFile(join(taskDir, "info.md")) - if (info) { - parts.push(`=== ${taskDir}/info.md (Technical Design) ===\n${info}`) - } - - return parts.join("\n\n") -} - -/** - * Get context for check agent - */ -function getCheckContext(ctx, taskDir) { - const parts = [] - - // 1. Read check.jsonl - const jsonlPath = join(ctx.directory, taskDir, "check.jsonl") - const entries = ctx.readJsonlWithFiles(jsonlPath) - - if (entries.length > 0) { - parts.push(ctx.buildContextFromEntries(entries)) - } else { - // Fallback: hardcoded check files + spec.jsonl - const checkFiles = [ - [".opencode/commands/trellis/finish-work.md", "Finish work checklist"], - [".opencode/commands/trellis/check-cross-layer.md", "Cross-layer check spec"], - [".opencode/commands/trellis/check-backend.md", "Backend check spec"], - [".opencode/commands/trellis/check-frontend.md", "Frontend check spec"], - ] - for (const [f, description] of checkFiles) { - const content = ctx.readProjectFile(f) - if (content) { - parts.push(`=== ${f} (${description}) ===\n${content}`) - } - } - - // Add spec.jsonl - const specJsonlPath = join(ctx.directory, taskDir, "spec.jsonl") - const specEntries = ctx.readJsonlWithFiles(specJsonlPath) - for (const entry of specEntries) { - parts.push(`=== ${entry.path} (Dev spec) ===\n${entry.content}`) - } - } - - // 2. Requirements document - const prd = ctx.readProjectFile(join(taskDir, "prd.md")) - if (prd) { - parts.push(`=== ${taskDir}/prd.md (Requirements - for understanding intent) ===\n${prd}`) - } - - return parts.join("\n\n") -} - -/** - * Get context for finish phase (final check before PR) - */ -function getFinishContext(ctx, taskDir) { - const parts = [] - - // 1. Try finish.jsonl first - const jsonlPath = join(ctx.directory, taskDir, "finish.jsonl") - const entries = ctx.readJsonlWithFiles(jsonlPath) - - if (entries.length > 0) { - parts.push(ctx.buildContextFromEntries(entries)) - } else { - // Fallback: only finish-work.md (lightweight) - const finishWork = ctx.readProjectFile(".opencode/commands/trellis/finish-work.md") - if (finishWork) { - parts.push(`=== .opencode/commands/trellis/finish-work.md (Finish checklist) ===\n${finishWork}`) - } - } - - // 2. Spec update process (for active spec sync) - const updateSpec = ctx.readProjectFile(".opencode/commands/trellis/update-spec.md") - if (updateSpec) { - parts.push(`=== .opencode/commands/trellis/update-spec.md (Spec update process) ===\n${updateSpec}`) - } - - // 3. Requirements document (for verifying requirements are met) - const prd = ctx.readProjectFile(join(taskDir, "prd.md")) - if (prd) { - parts.push(`=== ${taskDir}/prd.md (Requirements - verify all met) ===\n${prd}`) - } - - return parts.join("\n\n") -} - -/** - * Get context for debug agent - */ -function getDebugContext(ctx, taskDir) { - const parts = [] - - // 1. Read debug.jsonl (or fallback to spec.jsonl + check files) - const jsonlPath = join(ctx.directory, taskDir, "debug.jsonl") - const entries = ctx.readJsonlWithFiles(jsonlPath) - - if (entries.length > 0) { - parts.push(ctx.buildContextFromEntries(entries)) - } else { - // Fallback: use spec.jsonl + hardcoded check files - const specJsonlPath = join(ctx.directory, taskDir, "spec.jsonl") - const specEntries = ctx.readJsonlWithFiles(specJsonlPath) - for (const entry of specEntries) { - parts.push(`=== ${entry.path} (Dev spec) ===\n${entry.content}`) - } - - const checkFiles = [ - [".opencode/commands/trellis/check-backend.md", "Backend check spec"], - [".opencode/commands/trellis/check-frontend.md", "Frontend check spec"], - [".opencode/commands/trellis/check-cross-layer.md", "Cross-layer check spec"], - ] - for (const [f, description] of checkFiles) { - const content = ctx.readProjectFile(f) - if (content) { - parts.push(`=== ${f} (${description}) ===\n${content}`) - } - } - } - - // 2. Codex review output (if exists) - const codex = ctx.readProjectFile(join(taskDir, "codex-review-output.txt")) - if (codex) { - parts.push(`=== ${taskDir}/codex-review-output.txt (Codex Review Results) ===\n${codex}`) - } - - return parts.join("\n\n") -} - -/** - * Get context for research agent - */ -function getResearchContext(ctx, taskDir) { - const parts = [] - - parts.push(`## Project Spec Directory Structure - -\`\`\` -.trellis/spec/ -├── shared/ # Cross-project common specs -├── frontend/ # Frontend standards -├── backend/ # Backend standards -└── guides/ # Thinking guides - -.trellis/big-question/ # Known issues and pitfalls -\`\`\` - -## Search Tips - -- Spec files: \`.trellis/spec/**/*.md\` -- Known issues: \`.trellis/big-question/\` -- Code search: Use Glob and Grep tools -- Tech solutions: Use mcp__exa__web_search_exa or mcp__exa__get_code_context_exa`) - - if (taskDir) { - const jsonlPath = join(ctx.directory, taskDir, "research.jsonl") - const entries = ctx.readJsonlWithFiles(jsonlPath) - if (entries.length > 0) { - parts.push("\n## Additional Search Context\n") - parts.push(ctx.buildContextFromEntries(entries)) - } - } - - return parts.join("\n\n") -} - -/** - * Build enhanced prompt with context - */ -function buildPrompt(agentType, originalPrompt, context, isFinish = false) { - const templates = { - implement: `# Implement Agent Task - -You are the Implement Agent in the Multi-Agent Pipeline. - -## Your Context - -${context} - ---- - -## Your Task - -${originalPrompt} - ---- - -## Workflow - -1. **Understand specs** - All dev specs are injected above -2. **Understand requirements** - Read requirements and technical design -3. **Implement feature** - Follow specs and design -4. **Self-check** - Ensure code quality - -## Important Constraints - -- Do NOT execute git commit -- Follow all dev specs injected above -- Report list of modified/created files when done`, - - check: isFinish ? `# Finish Agent Task - -You are performing the final check before creating a PR. - -## Your Context - -${context} - ---- - -## Your Task - -${originalPrompt} - ---- - -## Workflow - -1. **Review changes** - Run \`git diff --name-only\` to see all changed files -2. **Verify requirements** - Check each requirement in prd.md is implemented -3. **Spec sync** - Analyze whether changes introduce new patterns, contracts, or conventions - - If new pattern/convention found: read target spec file → update it → update index.md if needed - - If infra/cross-layer change: follow the 7-section mandatory template from update-spec.md - - If pure code fix with no new patterns: skip this step -4. **Run final checks** - Execute lint and typecheck -5. **Confirm ready** - Ensure code is ready for PR - -## Important Constraints - -- You MAY update spec files when gaps are detected (use update-spec.md as guide) -- MUST read the target spec file BEFORE editing (avoid duplicating existing content) -- Do NOT update specs for trivial changes (typos, formatting, obvious fixes) -- If critical CODE issues found, report them clearly (fix specs, not code) -- Verify all acceptance criteria in prd.md are met` : - `# Check Agent Task - -You are the Check Agent in the Multi-Agent Pipeline. - -## Your Context - -${context} - ---- - -## Your Task - -${originalPrompt} - ---- - -## Workflow - -1. **Get changes** - Run \`git diff --name-only\` and \`git diff\` -2. **Check against specs** - Check item by item -3. **Self-fix** - Fix issues directly, don't just report -4. **Run verification** - Run lint and typecheck - -## Important Constraints - -- Fix issues yourself, don't just report -- Must execute complete checklist`, - - debug: `# Debug Agent Task - -You are the Debug Agent in the Multi-Agent Pipeline. - -## Your Context - -${context} - ---- - -## Your Task - -${originalPrompt} - ---- - -## Workflow - -1. **Understand issues** - Analyze issues pointed out -2. **Locate code** - Find positions that need fixing -3. **Fix against specs** - Fix following dev specs -4. **Verify fixes** - Run typecheck - -## Important Constraints - -- Do NOT execute git commit -- Run typecheck after each fix`, - - research: `# Research Agent Task - -You are the Research Agent in the Multi-Agent Pipeline. - -## Core Principle - -**You do one thing: find and explain information.** - -## Project Info - -${context} - ---- - -## Your Task - -${originalPrompt} - ---- - -## Workflow - -1. **Understand query** - Determine search type and scope -2. **Plan search** - List search steps -3. **Execute search** - Run multiple searches in parallel -4. **Organize results** - Output structured report - -## Strict Boundaries - -**Only allowed**: Describe what exists, where it is, how it works - -**Forbidden**: Suggest improvements, criticize implementation, modify files` - } - - return templates[agentType] || originalPrompt -} - -export default async ({ directory }) => { - const ctx = new TrellisContext(directory) - debugLog("inject", "Plugin loaded, directory:", directory) - - return { - // ========================================================================== - // ⚠️ KNOWN LIMITATION: OpenCode project-level plugins cannot intercept subagents - // - // This hook will NOT be triggered because: - // 1. Project-level plugins (.opencode/plugin/) don't support tool.execute.before - // 2. Only global plugins (npm packages) have full hook permissions - // 3. This is a known OpenCode architecture limitation (see Issue #5894) - // - // SOLUTION: Trellis + OpenCode users must install oh-my-opencode (omo) - // - omo is a global plugin with full hook permissions - // - omo reads .claude/settings.json and executes Python hooks - // - .claude/hooks/inject-subagent-context.py handles the actual injection - // - // References: - // - https://github.com/sst/opencode/issues/5894 (plugin hooks don't intercept subagent) - // - https://github.com/sst/opencode/issues/2588 (subagent inherit context) - // ========================================================================== - "tool.execute.before": async (input, output) => { - try { - debugLog("inject", "tool.execute.before called, tool:", input?.tool) - - // Only handle Task tool - const toolName = input?.tool?.toLowerCase() - if (toolName !== "task") { - return - } - - const args = output?.args || {} - const subagentType = args.subagent_type - const originalPrompt = args.prompt || "" - - debugLog("inject", "Task tool called, subagent_type:", subagentType) - - // Only handle supported agent types - if (!AGENTS_ALL.includes(subagentType)) { - debugLog("inject", "Skipping - unsupported subagent_type") - return - } - - // Check if we should skip (omo will handle) - if (ctx.shouldSkipHook("inject-subagent-context")) { - debugLog("inject", "Skipping - omo will handle via .claude/hooks/") - return - } - - // Read current task - const taskDir = ctx.getCurrentTask() - - // Agents requiring task directory - if (AGENTS_REQUIRE_TASK.includes(subagentType)) { - if (!taskDir) { - debugLog("inject", "Skipping - no current task") - return - } - const taskDirFull = join(directory, taskDir) - if (!existsSync(taskDirFull)) { - debugLog("inject", "Skipping - task directory not found") - return - } - - // Update current_phase in task.json - updateCurrentPhase(ctx, taskDir, subagentType) - } - - // Check for [finish] marker - const isFinish = originalPrompt.toLowerCase().includes("[finish]") - - // Get context based on agent type - let context = "" - switch (subagentType) { - case "implement": - context = getImplementContext(ctx, taskDir) - break - case "check": - // Use finish context for [finish] phase (lighter, focused on final verification) - // Use check context for regular check (full specs for self-fix loop) - context = isFinish - ? getFinishContext(ctx, taskDir) - : getCheckContext(ctx, taskDir) - break - case "debug": - context = getDebugContext(ctx, taskDir) - break - case "research": - context = getResearchContext(ctx, taskDir) - break - } - - if (!context) { - debugLog("inject", "No context to inject") - return - } - - // Build enhanced prompt - const newPrompt = buildPrompt(subagentType, originalPrompt, context, isFinish) - - // Update the tool input - output.args = { - ...args, - prompt: newPrompt - } - - debugLog("inject", "Injected context for", subagentType, "prompt length:", newPrompt.length) - - } catch (error) { - debugLog("inject", "Error in tool.execute.before:", error.message, error.stack) - } - } - } -} diff --git a/.opencode/plugin/session-start.js b/.opencode/plugin/session-start.js deleted file mode 100644 index d455d66..0000000 --- a/.opencode/plugin/session-start.js +++ /dev/null @@ -1,325 +0,0 @@ -/* global process */ -/** - * Trellis Session Start Plugin - * - * Injects context when user sends the first message in a session. - * Uses OpenCode's chat.message + experimental.chat.messages.transform hooks. - * - * Compatibility: - * - If oh-my-opencode handles via .claude/hooks/, this plugin skips - * - Otherwise, this plugin handles injection - */ - -import { existsSync, readFileSync, readdirSync, statSync } from "fs" -import { join } from "path" -import { TrellisContext, contextCollector, debugLog } from "../lib/trellis-context.js" - - -/** - * Check current task status and return structured status string. - * JavaScript equivalent of _get_task_status in Claude's session-start.py. - */ -function getTaskStatus(directory) { - const trellisDir = join(directory, ".trellis") - const currentTaskFile = join(trellisDir, ".current-task") - - if (!existsSync(currentTaskFile)) { - return "Status: NO ACTIVE TASK\nNext: Describe what you want to work on" - } - - let taskRef - try { - taskRef = readFileSync(currentTaskFile, "utf-8").trim() - } catch { - return "Status: NO ACTIVE TASK\nNext: Describe what you want to work on" - } - - if (!taskRef) { - return "Status: NO ACTIVE TASK\nNext: Describe what you want to work on" - } - - // Resolve task directory - let taskDir - if (taskRef.startsWith("/")) { - taskDir = taskRef - } else if (taskRef.startsWith(".trellis/")) { - taskDir = join(directory, taskRef) - } else { - taskDir = join(trellisDir, "tasks", taskRef) - } - - if (!existsSync(taskDir)) { - return `Status: STALE POINTER\nTask: ${taskRef}\nNext: Task directory not found. Run: python3 ./.trellis/scripts/task.py finish` - } - - // Read task.json - let taskData = {} - const taskJsonPath = join(taskDir, "task.json") - if (existsSync(taskJsonPath)) { - try { - taskData = JSON.parse(readFileSync(taskJsonPath, "utf-8")) - } catch { - // Ignore parse errors - } - } - - const taskTitle = taskData.title || taskRef - const taskStatus = taskData.status || "unknown" - - if (taskStatus === "completed") { - const dirName = taskDir.split("/").pop() - return `Status: COMPLETED\nTask: ${taskTitle}\nNext: Archive with \`python3 ./.trellis/scripts/task.py archive ${dirName}\` or start a new task` - } - - // Check if context is configured (jsonl files exist and non-empty) - let hasContext = false - for (const jsonlName of ["implement.jsonl", "check.jsonl", "spec.jsonl"]) { - const jsonlPath = join(taskDir, jsonlName) - if (existsSync(jsonlPath)) { - try { - const st = statSync(jsonlPath) - if (st.size > 0) { - hasContext = true - break - } - } catch { - // Ignore stat errors - } - } - } - - const hasPrd = existsSync(join(taskDir, "prd.md")) - - if (!hasPrd) { - return `Status: NOT READY\nTask: ${taskTitle}\nMissing: prd.md not created\nNext: Write PRD, then research → init-context → start` - } - - if (!hasContext) { - return `Status: NOT READY\nTask: ${taskTitle}\nMissing: Context not configured (no jsonl files)\nNext: Complete Phase 2 (research → init-context → start) before implementing` - } - - return `Status: READY\nTask: ${taskTitle}\nNext: Continue with implement or check` -} - -/** - * Build session context for injection - */ -function buildSessionContext(ctx) { - const directory = ctx.directory - const trellisDir = join(directory, ".trellis") - const claudeDir = join(directory, ".claude") - const opencodeDir = join(directory, ".opencode") - - const parts = [] - - // 1. Header - parts.push(`<trellis-context> -You are starting a new session in a Trellis-managed project. -Read and follow all instructions below carefully. -</trellis-context>`) - - // 2. Current Context (dynamic) - const contextScript = join(trellisDir, "scripts", "get_context.py") - if (existsSync(contextScript)) { - const output = ctx.runScript(contextScript) - if (output) { - parts.push("<current-state>") - parts.push(output) - parts.push("</current-state>") - } - } - - // 3. Workflow Guide - const workflow = ctx.readProjectFile(".trellis/workflow.md") - if (workflow) { - parts.push("<workflow>") - parts.push(workflow) - parts.push("</workflow>") - } - - // 4. Guidelines Index (dynamic discovery, matching Claude's session-start.py) - parts.push("<guidelines>") - parts.push("**Note**: The guidelines below are index files — they list available guideline documents and their locations.") - parts.push("During actual development, you MUST read the specific guideline files listed in each index's Pre-Development Checklist.\n") - - const specDir = join(directory, ".trellis", "spec") - if (existsSync(specDir)) { - try { - const subs = readdirSync(specDir).filter(name => { - if (name.startsWith(".")) return false - try { - return statSync(join(specDir, name)).isDirectory() - } catch { - return false - } - }).sort() - - for (const sub of subs) { - const indexFile = join(specDir, sub, "index.md") - if (existsSync(indexFile)) { - // Flat spec dir: spec/<layer>/index.md - const content = ctx.readFile(indexFile) - if (content) { - parts.push(`## ${sub}\n${content}\n`) - } - } else { - // Nested package dirs (monorepo): spec/<pkg>/<layer>/index.md - try { - const nested = readdirSync(join(specDir, sub)).filter(name => { - try { - return statSync(join(specDir, sub, name)).isDirectory() - } catch { - return false - } - }).sort() - - for (const layer of nested) { - const nestedIndex = join(specDir, sub, layer, "index.md") - if (existsSync(nestedIndex)) { - const content = ctx.readFile(nestedIndex) - if (content) { - parts.push(`## ${sub}/${layer}\n${content}\n`) - } - } - } - } catch { - // Ignore directory read errors - } - } - } - } catch { - // Ignore spec directory read errors - } - } - - parts.push("</guidelines>") - - // 5. Session Instructions - try both .claude and .opencode - let startMd = ctx.readFile(join(claudeDir, "commands", "trellis", "start.md")) - if (!startMd) { - startMd = ctx.readFile(join(opencodeDir, "commands", "trellis", "start.md")) - } - if (startMd) { - parts.push("<instructions>") - parts.push(startMd) - parts.push("</instructions>") - } - - // 6. Task status (R2: check task state for session resume) - const taskStatus = getTaskStatus(directory) - parts.push(`<task-status>\n${taskStatus}\n</task-status>`) - - // 7. Final directive (R3: active, not passive) - parts.push(`<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>`) - - return parts.join("\n\n") -} - -export default async ({ directory }) => { - const ctx = new TrellisContext(directory) - debugLog("session", "Plugin loaded, directory:", directory) - - return { - // chat.message - triggered when user sends a message - "chat.message": async (input) => { - try { - const sessionID = input.sessionID - const agent = input.agent || "unknown" - debugLog("session", "chat.message called, sessionID:", sessionID, "agent:", agent) - - // Skip in non-interactive mode - if (process.env.OPENCODE_NON_INTERACTIVE === "1") { - debugLog("session", "Skipping - non-interactive mode") - return - } - - // Check if we should skip (omo will handle) - if (ctx.shouldSkipHook("session-start")) { - debugLog("session", "Skipping - omo will handle via .claude/hooks/") - return - } - - // Only inject on first message - if (contextCollector.isProcessed(sessionID)) { - debugLog("session", "Skipping - session already processed") - return - } - - // Mark session as processed - contextCollector.markProcessed(sessionID) - - // Build and store context - const context = buildSessionContext(ctx) - debugLog("session", "Built context, length:", context.length) - - contextCollector.store(sessionID, context) - debugLog("session", "Context stored for session:", sessionID) - - } catch (error) { - debugLog("session", "Error in chat.message:", error.message, error.stack) - } - }, - - // experimental.chat.messages.transform - modify messages before sending to AI - "experimental.chat.messages.transform": async (input, output) => { - try { - const { messages } = output - debugLog("session", "messages.transform called, messageCount:", messages?.length) - - if (!messages || messages.length === 0) { - return - } - - // Find last user message - let lastUserMessageIndex = -1 - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].info?.role === "user") { - lastUserMessageIndex = i - break - } - } - - if (lastUserMessageIndex === -1) { - debugLog("session", "No user message found") - return - } - - const lastUserMessage = messages[lastUserMessageIndex] - const sessionID = lastUserMessage.info?.sessionID - - debugLog("session", "Found user message, sessionID:", sessionID) - - if (!sessionID || !contextCollector.hasPending(sessionID)) { - debugLog("session", "No pending context for session") - return - } - - // Get and consume pending context - const pending = contextCollector.consume(sessionID) - - // Find first text part - const textPartIndex = lastUserMessage.parts?.findIndex( - p => p.type === "text" && p.text !== undefined - ) - - if (textPartIndex === -1) { - debugLog("session", "No text part found in user message") - return - } - - // Prepend context to the text part (same approach as omo) - const originalText = lastUserMessage.parts[textPartIndex].text || "" - lastUserMessage.parts[textPartIndex].text = `${pending.content}\n\n---\n\n${originalText}` - - debugLog("session", "Injected context by prepending to text, length:", pending.content.length) - - } catch (error) { - debugLog("session", "Error in messages.transform:", error.message, error.stack) - } - } - } -} diff --git a/.trellis/.gitignore b/.trellis/.gitignore index 46135ba..5a991ea 100644 --- a/.trellis/.gitignore +++ b/.trellis/.gitignore @@ -4,6 +4,9 @@ # Current task pointer (each dev works on different task) .current-task +# Session/window scoped runtime state +.runtime/ + # Ralph Loop state file .ralph-state.json diff --git a/.trellis/.template-hashes.json b/.trellis/.template-hashes.json index 58baeea..0c883c4 100644 --- a/.trellis/.template-hashes.json +++ b/.trellis/.template-hashes.json @@ -1,823 +1,65 @@ { - ".trellis/config.yaml": "fe1fba0961e589c6f49190f5e19d4edb0d5bf894dba8468f06882c6e1c5e2aa1", - ".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c", - ".trellis/scripts/add_session.py": "7c869be8146e6f675bd95e424909ff301ea0a8f8fd82a4f056f6d320e755a406", - ".trellis/scripts/common/__init__.py": "301724230abcce6e9fc99054c12d21c30eea7bc3b330ae6350aa3b6158461273", - ".trellis/scripts/common/cli_adapter.py": "66ef4f75470807b531490a6b6928604eb59781148fe3c5412f39e132ffab0850", - ".trellis/scripts/common/config.py": "909257b442d7d1e7a2596996622c4f2f010d8c1343e1efd088ef8615d99554c7", - ".trellis/scripts/common/developer.py": "69f6145c4c48953677de3ba06f487ba2a1675f4d66153346ab40594bb06a01c9", - ".trellis/scripts/common/git_context.py": "f154d358c858f7bcfc21a03c9b909af3a8dfa20be37b2c5012d84b8e0588b493", - ".trellis/scripts/common/paths.py": "058f333fb80c71c90ddc131742e8e64949c2f1ed07c1254d8f7232506d891ffc", - ".trellis/scripts/common/phase.py": "f9bdd553c7a278b97736b04c066ed06d8baa2ef179ed8219befcf6c27afcc9cd", - ".trellis/scripts/common/registry.py": "6c65db45a487ef839b0a4b5b20abe201547269c20c7257254293a89dc01b56dc", - ".trellis/scripts/common/task_queue.py": "6de22c7731465ee52d2b5cd4853b191d3cf869bf259fbc93079b426ba1c3756c", - ".trellis/scripts/common/task_utils.py": "e19c290d90f9a779db161aeb9fefda27852847fbc67d358d471530b8ede64131", - ".trellis/scripts/common/worktree.py": "434880e02dfa2e92f0c717ed2a28e4cdee681ea10c329a2438d533bdbc612408", - ".trellis/scripts/create_bootstrap.py": "aa5dd1f39a77b2f4bb827fd14ce7a83fb51870e77f556fe508afce3f8eac0b4e", - ".trellis/scripts/get_context.py": "ca5bf9e90bdb1d75d3de182b95f820f9d108ab28793d29097b24fd71315adcf5", - ".trellis/scripts/get_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f", - ".trellis/scripts/init_developer.py": "f9e6c0d882406e81c8cd6b1c5abb204b0befc0069ff89cf650cd536a80f8c60e", - ".trellis/scripts/multi_agent/__init__.py": "af6fceb4d9a64da04be03ba0f5a6daf71066503eca832b8b58d8a7d4b2844fa4", - ".trellis/scripts/multi_agent/cleanup.py": "db50c4fbb32261905a8278c2760b33029f187963cd4e448938e57f3db3facd6c", - ".trellis/scripts/multi_agent/create_pr.py": "6a2423aba5720a2150c32349faa957cdc59c6bb96511e56c79ca08d92d69c666", - ".trellis/scripts/multi_agent/plan.py": "242b870b7667f730c910d629f16d44d5d3fd0a58f6451d9003c175fb2e77cee5", - ".trellis/scripts/multi_agent/start.py": "32ed1a13405b7c71881b2507a79e1a3733bc3fcedbc92fcee0d733ce00d759d0", - ".trellis/scripts/multi_agent/status.py": "5fc46b6d605c69b6044967a6b33ffb0c9d6f99dd919374572ac614222864a811", - ".trellis/scripts/task.py": "ecf52885a698dc93af67fd693825a2f71163ab86b5c2abe76d8aa2e2caa44372", - ".trellis/workflow.md": "9b6d6e8027bd2cf32d9efd7ef77d6524c59fcaa4ad6052f72d028a07a5fd69a7", - ".trellis/worktree.yaml": "c57de79e40d5f748f099625ed4a17d5f0afbf25cac598aced0b3c964e7b7c226", - ".opencode/agents/check.md": "39763ef458f95a2b38c0fbc9cd79df8c66909086e0ace4e4e80c536f58d09aed", - ".opencode/agents/debug.md": "0bac1d723fb3634ea95c471a22245eff2b4c9d6bd98bc66cafacf6a0092609bb", - ".opencode/agents/dispatch.md": "23d7834c540907c98f7988661849db5d949ee394470952215c373aef926fec81", - ".opencode/agents/implement.md": "540ce5cd7b2c2281ce520ed487bf0bbc4773169646f8224d7c363af293def396", - ".opencode/agents/research.md": "094829f1572e65c0d954c648c9638440e3279b02f2538f2abe0d9706b90e6fa2", - ".opencode/agents/trellis-plan.md": "36de06c7eddbff290acb3c200f30af96291048e492ce2f2d8b7038662eeb572b", - ".opencode/bun.lock": "31e0d053588da5aaeb7c3fce5de22d5878df9af7f3ffc48775992698f8614fe9", - ".opencode/commands/android-test.md": "59a50131df27fd26c287970f88443f0fa4d20a0ebb52f8c879fcc4d5aeb2b891", - ".opencode/commands/doc-update.md": "e8d9b6c122dc45d52c59a0a8c4d7363b58eb883e54118f951cab0fbc38de4d84", - ".opencode/commands/ios-test.md": "2dac0c13d8e0f026816f7ad235030f4f83b474b89c796413e0a20d00ebc15bdd", - ".opencode/commands/trellis/before-backend-dev.md": "7e35444de2a5779ef39944f17f566ea21d2ed7f4994246f4cfe6ebf9a11dd3e3", - ".opencode/commands/trellis/before-frontend-dev.md": "a6225f9d123dbd4a7aec822652030cae50be3f5b308297015e04d42b23a27b2a", - ".opencode/commands/trellis/brainstorm.md": "7c7731eda092275a5d87f2569a69584f3c39b544a126a76e727a1e9d250c4a65", - ".opencode/commands/trellis/break-loop.md": "ba4dd4022dde1e4bbcfc1cc99e6a118e51b9db95bd962d88f1c29d0c9c433112", - ".opencode/commands/trellis/check-backend.md": "4e81a28d681ea770f780df55a212fd504ce21ee49b44ba16023b74b5c243cef3", - ".opencode/commands/trellis/check-cross-layer.md": "b9ab24515ead84330d6634f6ad912ca3547db3a36139d62c5688161824097d60", - ".opencode/commands/trellis/check-frontend.md": "5e8e3b682032ba0dd6bb843dd4826fff0159f78a7084964ccb119c6cf98b3d91", - ".opencode/commands/trellis/create-command.md": "230640908f2863f0cf2d7dc0cd2b61782b77d75fc02636d6d46b22d00ccb3465", - ".opencode/commands/trellis/finish-work.md": "dd147ab880063f4359322618f39ac0e84e1494aa9f67883dcd82e947f6b5d8bb", - ".opencode/commands/trellis/integrate-skill.md": "3940442485341832257c595ddfb45582e2d60e5a4716f2bd15b7bce0498b130a", - ".opencode/commands/trellis/migrate-specs.md": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - ".opencode/commands/trellis/onboard.md": "a5dbd5db094b13fd006ec856efa53a688e209bcdc3ed1680b63b15f1e3293ab4", - ".opencode/commands/trellis/parallel.md": "82e7a5214b48ffdea9063109f89a8428d7c077e0beb4cc86d4836394e47a1e21", - ".opencode/commands/trellis/record-session.md": "0c4f61283c2f262c1f9c900d9207309107497d4ac848cca86eb62bc5b7189fe7", - ".opencode/commands/trellis/start.md": "5e6141d6f7bc06fbd7de453d64e204416f1007ca0a55a8a8ca9aad100f3b3572", - ".opencode/commands/trellis/update-spec.md": "ff4d5a0405a763e61936f5b9df175fd25ea20ec5c20fa999855020ab78a919b6", - ".opencode/lib/trellis-context.js": "8974c446808852152d1f9cd1165d5dad3c215e9c9206339d83fedca464f886a8", - ".opencode/node_modules/@opencode-ai/plugin/dist/example.d.ts": "ba1871db5442e49c90630686e08323d6d8303b1749d0752646e69701572560e0", - ".opencode/node_modules/@opencode-ai/plugin/dist/example.js": "df6bc51eb345aa69a3bdfe389a5f7b1368fc5973e45f9166763b8ed3f6606f03", - ".opencode/node_modules/@opencode-ai/plugin/dist/index.d.ts": "072ed6da227611707565cfb175149035aee26b1e5b9ef9bf9f1691090a9a9fcc", - ".opencode/node_modules/@opencode-ai/plugin/dist/index.js": "a3d26196e07062e858fc0ee44cfbddeb6143be740f9c93df4286b162a8b3e298", - ".opencode/node_modules/@opencode-ai/plugin/dist/shell.d.ts": "340d8057526217987a3a797d93a03b6e6c9a9e1cd8e7c46a676ea008a710afe1", - ".opencode/node_modules/@opencode-ai/plugin/dist/shell.js": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", - ".opencode/node_modules/@opencode-ai/plugin/dist/tool.d.ts": "10c4747fb7470f341a977f88fa3c66bf05593cd835a25185f996244c5b638073", - ".opencode/node_modules/@opencode-ai/plugin/dist/tool.js": "a01230d42fa1055ddb4b378e0128f156e2db07f6f6eadbf2da174eb634380b16", - ".opencode/node_modules/@opencode-ai/plugin/dist/tui.d.ts": "fa0ce667c42213f02b5fb7beba5a76c2800225d847268b547f6e81f7e66d2593", - ".opencode/node_modules/@opencode-ai/plugin/dist/tui.js": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", - ".opencode/node_modules/@opencode-ai/plugin/package.json": "9a3801d112f0b81abebf95bf371b607c4dec57a8595a603a7ffe33a2c350cabc", - ".opencode/node_modules/@opencode-ai/sdk/dist/client.d.ts": "84edb938f2b673df750cf83e9516218404e9df7e32e66f2f063c813d7f4d0e62", - ".opencode/node_modules/@opencode-ai/sdk/dist/client.js": "3a6229169864892dfbd1919c99e9e377f4a1458e708237ab789476843f415bd2", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client/client.gen.d.ts": "358599da1d31542e1d3ebf35a4a98665395ea8eaf0063826b8838974f7e826e5", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client/client.gen.js": "a4303bf5538ac7652dd7ab06f709024356a674318e452f71bf391ffe6b30a727", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client/index.d.ts": "06240cf9eca32a7ea5436ee67c8420bc1ddacfaf0d28cd17d09dd56a65c8fb9a", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client/index.js": "190026b1b139eaf999907d9be7af64a4485dcf07e15f392fbb46c391f625aa58", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client/types.gen.d.ts": "11f6e61b31e0c89eae79ce790895bf85b918572c32c2de45998f7cbacbb3b963", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client/types.gen.js": "17ebe2baaa8d2f0c94afaaaae74a46e43e2b163a0f4ffd69adb39ad57c318e78", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client/utils.gen.d.ts": "77fed0312d2ec2b08afb890b7760b5f2328f6e09065ea05b5fc35d5294fbb434", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client/utils.gen.js": "723a458bd538ada79b036e49a2c28d29e674647b51e28b61ba3fd8074ae41d84", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client.gen.d.ts": "30243dd321042c89cab45896e85d4d7c0fd8e0ba184e9b2163201945eaed71be", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client.gen.js": "aba08480d2ca9591f619b13fd0cb2bee3a4a9648004141229b7a2f67391bcde1", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/auth.gen.d.ts": "96135fbf1dab9f857c9df88a191fdc435517f7a5b021d09cad0c290a1dcec436", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/auth.gen.js": "5daa30e9a92a9fb4c8df37212c6bf421e6102dcb6070b32a7cdb978ecc27c230", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/bodySerializer.gen.d.ts": "44fd7a3b2fac384971e638548c227835a2f8c3c2232edff7e0f951a0e136e562", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/bodySerializer.gen.js": "133af6e45e6541018e1cdff6a59d610421c97e3215ffcb1447b682a74a278d6b", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/params.gen.d.ts": "d23fac93be3afb1c83269864f13b5e5fc246b05cb21b7e019e38403e4ef1aba5", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/params.gen.js": "6176cffd7187e9192957bb3f5df926d84f3b952079b2c33ce00f2ee37d7b401e", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/pathSerializer.gen.d.ts": "c36ce4ffa39eae9d8d5f3262bbff170f55b375156bc4eeadadfeede2820aa4c2", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/pathSerializer.gen.js": "f92d4e9209d6d6bcfc3b8bf0152f85f9004aa9a3163d1113d132651c2c022581", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/queryKeySerializer.gen.d.ts": "60d1baa9e7b198ea9221564bc5000c97fd9fb9a9d74974cb08f0176192612b2b", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/queryKeySerializer.gen.js": "cc69f01bfc81230f20a2355c6785d8f83e37d79119d55d5c1f44f099ff4740ff", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/serverSentEvents.gen.d.ts": "c41ccf7ec4fecea9d6e5b72ae7f20c91bbe85a8a82caada5ae8a3bc5a56c8926", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/serverSentEvents.gen.js": "ee09b085675f234fd6c35aa125c237202a8b728f885b24ab65e7d05ffbb2dd43", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/types.gen.d.ts": "80498d31235c22ee711a9fb6f0ca2bcb8fbf5c9a787f3d3a76962b440df70013", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/types.gen.js": "17ebe2baaa8d2f0c94afaaaae74a46e43e2b163a0f4ffd69adb39ad57c318e78", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/utils.gen.d.ts": "46f46fcafdf1b4e003679a35ee1a2d9c3a7e31b780fb437903fb1a6273483d52", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/utils.gen.js": "90a6a76e6d717839cb6fbaec696fe17a1f407ba696513366315fb30f5793b871", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/sdk.gen.d.ts": "5f71dae22207eb5119b2e005e046c0404a29454292e07c68668e39c9931414cd", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/sdk.gen.js": "2c40e32e5a6c06a4a0c02a78c010906ea2f9fefbea104abadf29e4dccc05d51f", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/types.gen.d.ts": "abd950a6eb114f55ae2210538340b4167a5d6e2f270864433417a9417c9c59b2", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/types.gen.js": "17ebe2baaa8d2f0c94afaaaae74a46e43e2b163a0f4ffd69adb39ad57c318e78", - ".opencode/node_modules/@opencode-ai/sdk/dist/index.d.ts": "2a7a35e3f1ead49569fe0423ae65055184f9384b97999cd90bb99878f727e585", - ".opencode/node_modules/@opencode-ai/sdk/dist/index.js": "c89e7660b6c14b282f497cf590f1693a49a421bba437a2d3ef2c2f6b51326958", - ".opencode/node_modules/@opencode-ai/sdk/dist/server.d.ts": "579f61b51c0e5dc0119ab49fc536130749280c7a23e489e753fcce08c229ce2c", - ".opencode/node_modules/@opencode-ai/sdk/dist/server.js": "79afd73e4c83f595b3ab91193cf6a9359e95506e4825b8f433d47926afdaab14", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/client.d.ts": "19d2c965297a2f7eedec208f00b8f988e83cff5e87fd58b9b2bcba9f4e481814", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/client.js": "43bf4990b39b59fcbc8fb90c5ab50ee070b0f72c8a143d0410edb5683fa6e6c0", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client/client.gen.d.ts": "358599da1d31542e1d3ebf35a4a98665395ea8eaf0063826b8838974f7e826e5", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client/client.gen.js": "90b45d194961c72dd2775b39af02c69fd49587a8a4f4d56d3407b12b8073447d", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client/index.d.ts": "55122db1dc0aa0340759af31319c40309930899ee2d67c82cd0d3cfc68b3c29c", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client/index.js": "cbbc149808c7dd0c00ceac3df03a948f8f701ec049a763ce14567abe306a7a54", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client/types.gen.d.ts": "481c7349554c63b0930b792ad2f0174f7d0666b3ec11c203f72d3b5be22f1d3b", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client/types.gen.js": "17ebe2baaa8d2f0c94afaaaae74a46e43e2b163a0f4ffd69adb39ad57c318e78", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client/utils.gen.d.ts": "53be801bc15e23f6ebb90c6675ad1803a6488f19b0b36c11f309e9899e74e75f", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client/utils.gen.js": "c593c1f1fe32737c849a284677d9a1e0643b125bcb5727b1afc7638d7f01eee8", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client.gen.d.ts": "c1af887105801d15dd6eeb88cb42e680d33e4a1d7cb6372011b3224bd81d7c4d", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client.gen.js": "a5f22d5f97899072c3a6afe04312f0b4838a811458427627f60473c63c0ead5d", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/auth.gen.d.ts": "96135fbf1dab9f857c9df88a191fdc435517f7a5b021d09cad0c290a1dcec436", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/auth.gen.js": "5daa30e9a92a9fb4c8df37212c6bf421e6102dcb6070b32a7cdb978ecc27c230", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/bodySerializer.gen.d.ts": "010b37dfe9c2cbc48d874bcab4074917ea836e630dc577a9e2d03e97da2236e5", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/bodySerializer.gen.js": "133af6e45e6541018e1cdff6a59d610421c97e3215ffcb1447b682a74a278d6b", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/params.gen.d.ts": "210945863764365e1e6b4e9c7e6fdd76e3b4aac91f38b7a5a0830ac112d57ac3", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/params.gen.js": "b3c95661ddaec7c7e54fb17bca6a51961ea5b383daa218fe6c862f05fcf594d2", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/pathSerializer.gen.d.ts": "c36ce4ffa39eae9d8d5f3262bbff170f55b375156bc4eeadadfeede2820aa4c2", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/pathSerializer.gen.js": "f92d4e9209d6d6bcfc3b8bf0152f85f9004aa9a3163d1113d132651c2c022581", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/queryKeySerializer.gen.d.ts": "60d1baa9e7b198ea9221564bc5000c97fd9fb9a9d74974cb08f0176192612b2b", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/queryKeySerializer.gen.js": "cc69f01bfc81230f20a2355c6785d8f83e37d79119d55d5c1f44f099ff4740ff", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/serverSentEvents.gen.d.ts": "844af05b12599165533995c74c309effc0106729f86e8596c2c9ec4868b80d94", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/serverSentEvents.gen.js": "218952938cd653a781ef512a60de49e4b558e5fb25558b27d96ada68b5eda4bc", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/types.gen.d.ts": "997352ab110eb2f15bd22e39882b9bfcf884b84b4d57cc0d24dd9037b36681c5", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/types.gen.js": "17ebe2baaa8d2f0c94afaaaae74a46e43e2b163a0f4ffd69adb39ad57c318e78", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/utils.gen.d.ts": "6be7640d8a8bcbeb9054bf83ab782d68e4cd761f3738bf776615d5ae9b729cbd", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/utils.gen.js": "110a18cdb229ab980a39aa222eca37e5a3b75773199555b7f8d2d110f588b4de", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/sdk.gen.d.ts": "500931c9b40d897c07c39fa2a7aace12e46a8757339598a214520843e55d3cf3", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/sdk.gen.js": "ea83d35534f04b309100f9d1d1fb4d0f5f931c3499c48cacf6c15c6b87059fa0", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/types.gen.d.ts": "66beddf56fbb2238485b53b6b4343acb5b79694d8bf4edaf8291259d64e822ef", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/types.gen.js": "17ebe2baaa8d2f0c94afaaaae74a46e43e2b163a0f4ffd69adb39ad57c318e78", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/index.d.ts": "2a7a35e3f1ead49569fe0423ae65055184f9384b97999cd90bb99878f727e585", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/index.js": "c89e7660b6c14b282f497cf590f1693a49a421bba437a2d3ef2c2f6b51326958", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/server.d.ts": "579f61b51c0e5dc0119ab49fc536130749280c7a23e489e753fcce08c229ce2c", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/server.js": "79afd73e4c83f595b3ab91193cf6a9359e95506e4825b8f433d47926afdaab14", - ".opencode/node_modules/@opencode-ai/sdk/package.json": "6d9580a225220796ff6c19feb1ff2b53b2d8e7db22e83e9f997bda241e5a3850", - ".opencode/node_modules/zod/LICENSE": "3f1189b28e3866e0d979968d466b78f813f76827cfdca1fbb124cc0a5c8841f8", - ".opencode/node_modules/zod/README.md": "67485f7fe9fda912f02235894aa782f4354ca2f568b4735db005d0ee52390628", - ".opencode/node_modules/zod/index.cjs": "2a3455cecff4f7021c92d0a3e2e5fc170c0448dc5cc261160127e08f4888a9c2", - ".opencode/node_modules/zod/index.d.cts": "29f823cbe0166e10e7176a94afe609a24b9e5af3858628c541ff8ce1727023cd", - ".opencode/node_modules/zod/index.d.ts": "c733a1897d6b4b30dad6998597f6896b265b094a65534359ada34b08ecf8932c", - ".opencode/node_modules/zod/index.js": "c733a1897d6b4b30dad6998597f6896b265b094a65534359ada34b08ecf8932c", - ".opencode/node_modules/zod/locales/index.cjs": "77c515c23956a04462d5c8fb207321bbbaf6e231b86faa4ecd98dfaec82a40e7", - ".opencode/node_modules/zod/locales/index.d.cts": "79152153afe093c320d882e1d66640fdf7a853fcab8e897ab7f10cbac937cb2e", - ".opencode/node_modules/zod/locales/index.d.ts": "0b98ccbe349eb9774d8c96fa381335cef127140996f91a4eea47b7742b55f51d", - ".opencode/node_modules/zod/locales/index.js": "0b98ccbe349eb9774d8c96fa381335cef127140996f91a4eea47b7742b55f51d", - ".opencode/node_modules/zod/locales/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", - ".opencode/node_modules/zod/mini/index.cjs": "c3ef916ed5e1bb397f04352f010f97961e98f610c13ecc05c25a384ef0e5a5a2", - ".opencode/node_modules/zod/mini/index.d.cts": "d34c4532b0004150342d04ab1a6f61d19751c4fc7c465c72ec582b180b0904c0", - ".opencode/node_modules/zod/mini/index.d.ts": "cf94ca2939d655e17fdfd1d0a3aceb4cbeb040d8946e9c9c1eaa5cc734dc69c8", - ".opencode/node_modules/zod/mini/index.js": "cf94ca2939d655e17fdfd1d0a3aceb4cbeb040d8946e9c9c1eaa5cc734dc69c8", - ".opencode/node_modules/zod/mini/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", - ".opencode/node_modules/zod/package.json": "67f2058ef56c9209df51e6fe0dd1395eb25af454974de2978ced56008e6fad5e", - ".opencode/node_modules/zod/src/index.ts": "c733a1897d6b4b30dad6998597f6896b265b094a65534359ada34b08ecf8932c", - ".opencode/node_modules/zod/src/locales/index.ts": "0b98ccbe349eb9774d8c96fa381335cef127140996f91a4eea47b7742b55f51d", - ".opencode/node_modules/zod/src/mini/index.ts": "cf94ca2939d655e17fdfd1d0a3aceb4cbeb040d8946e9c9c1eaa5cc734dc69c8", - ".opencode/node_modules/zod/src/v3/ZodError.ts": "e4386fe8f2a49d774c7e1aff4c015c125ac4a0dcf70d5aa6883f167278ca141f", - ".opencode/node_modules/zod/src/v3/benchmarks/datetime.ts": "0d1a81e5608b286c1843aaecb20bd7d1f62b6674a610a51dc7d1043bed05963d", - ".opencode/node_modules/zod/src/v3/benchmarks/discriminatedUnion.ts": "33e4f792dbfdc735a72f92fcc117d3dbaea8e7ff3937d523bef3df44814805d3", - ".opencode/node_modules/zod/src/v3/benchmarks/index.ts": "27f4322ad98e28575d5849a7612c6acea55cd5be8a1571d1885a656e8a9d5620", - ".opencode/node_modules/zod/src/v3/benchmarks/ipv4.ts": "3ef475926d7e95a5880cd9d7bd780f98642189010046bb9748e27e50360da333", - ".opencode/node_modules/zod/src/v3/benchmarks/object.ts": "ea0f6d4b3e2c6f47bf1739a53473997da4aaacc1e5f59e57f00cfb6b7f59a4e5", - ".opencode/node_modules/zod/src/v3/benchmarks/primitives.ts": "706d51c7048aff5108dd71b33b5c66d354a6856be0a48b23bc32e55d284c31d0", - ".opencode/node_modules/zod/src/v3/benchmarks/realworld.ts": "9bcc944a5e875ca191ca10542c113dde3c754e45a196400726f251b045e14215", - ".opencode/node_modules/zod/src/v3/benchmarks/string.ts": "ed17ef186249dc4f1da01a2d5e0bd3c87da6d0d2dcad89e8afa05e0920d98536", - ".opencode/node_modules/zod/src/v3/benchmarks/union.ts": "9eec8e3e58cb4d3dd76be11874187f71a99c27e06c639de0cc9d8d928687fe0a", - ".opencode/node_modules/zod/src/v3/errors.ts": "cb8debd524102d5a38dbaadd72b220f68349dbac51ac9663569c1807f64b3772", - ".opencode/node_modules/zod/src/v3/external.ts": "5d26d2e47e2352def36f89a3e8bf8581da22b7f857e07ef3114cd52cf4813445", - ".opencode/node_modules/zod/src/v3/helpers/enumUtil.ts": "dffcb43b363e6804128c415c128c32dbbda302049cfce7662b2f0e17eeae044e", - ".opencode/node_modules/zod/src/v3/helpers/errorUtil.ts": "808aa0875577c556006ca193a5d4bd35a2ade57e50e09199fd9cbf6e780f0a31", - ".opencode/node_modules/zod/src/v3/helpers/parseUtil.ts": "cb94690c02dce392b98ca364de0d4b24f42db715fbb1759a08bcfb7fa6674645", - ".opencode/node_modules/zod/src/v3/helpers/partialUtil.ts": "68848db44869461bc20373abdcd002e32cb3759e2d5801ba3d9894d2c23db0b1", - ".opencode/node_modules/zod/src/v3/helpers/typeAliases.ts": "d3cfde44f8089768ebb08098c96d01ca260b88bccf238d55eee93f1c620ff5a5", - ".opencode/node_modules/zod/src/v3/helpers/util.ts": "df1b9ea5a29a591f273555e240142431617e686aa6a97f172193770c26841a52", - ".opencode/node_modules/zod/src/v3/index.ts": "3db2efd285e7328d8014b54a7fce3f4861ebcdc655df40517092ed0050983617", - ".opencode/node_modules/zod/src/v3/locales/en.ts": "f729055c5f4acb839084e5c66c80bdecca02a78df6b048224e56e634322cb223", - ".opencode/node_modules/zod/src/v3/standard-schema.ts": "e14e38f67bfc8d76e208366f639b73b3292f2bd46c97a44f43535c2389927ece", - ".opencode/node_modules/zod/src/v3/tests/Mocker.ts": "b7732bb15ed6ab38f61dd4a15f088af109b0a99eccb50cdd9f542a17bb935f5e", - ".opencode/node_modules/zod/src/v3/tests/all-errors.test.ts": "d629da33805d24b17d1f6383009acfbf3b769bd4f2d8191a9720d68e4e64ab7d", - ".opencode/node_modules/zod/src/v3/tests/anyunknown.test.ts": "fac8ebb9d3007f7ef1ea320eed85b22b3248fde6bb1fe645a1a513b69442c014", - ".opencode/node_modules/zod/src/v3/tests/array.test.ts": "e7d3813182c59e511d350bebbd2a25b349868cc73d057b23b1d85a4d927b3711", - ".opencode/node_modules/zod/src/v3/tests/async-parsing.test.ts": "45f829362cc917d9505e244ea0d9a06d71a1953747dc31e57780388e82ad5afa", - ".opencode/node_modules/zod/src/v3/tests/async-refinements.test.ts": "cfd6752223678338c949f1716deeff8c18807f31343d6a4544f494d10e5acf60", - ".opencode/node_modules/zod/src/v3/tests/base.test.ts": "989d8ff33e43dbeb3d08d8f659a3269d22fb573a86d48c72da58ab84e484e532", - ".opencode/node_modules/zod/src/v3/tests/bigint.test.ts": "799b38ffff2d2a977493846e127d6f76e5cdb346d8886a2d2c76616253170f45", - ".opencode/node_modules/zod/src/v3/tests/branded.test.ts": "d0d7213caa6b727fe72a4bd99631984c4038ecbe8f67001840b447ca4b2085b0", - ".opencode/node_modules/zod/src/v3/tests/catch.test.ts": "e4b1727836b5cdca66337af180a756fab2b712f61d49d17326a90c0a54080be5", - ".opencode/node_modules/zod/src/v3/tests/coerce.test.ts": "30dec33039db38b3c2baecbee09c74afc1d9adc68a6229a35628a1bac4539db9", - ".opencode/node_modules/zod/src/v3/tests/complex.test.ts": "51d676be11ddb5a0b987471c6e49b819b4f6b2aba77fa23565deb4591439cf40", - ".opencode/node_modules/zod/src/v3/tests/custom.test.ts": "6837c23478b39dc6304a98bbbba4a1df9b973d40f6243f0939bfbc339d96b01d", - ".opencode/node_modules/zod/src/v3/tests/date.test.ts": "7d352eab070ae8f4fcbc61674f6b238159699ec4b96754ec1aac0cac74091c34", - ".opencode/node_modules/zod/src/v3/tests/deepmasking.test.ts": "8b6cef1cdb8eb422ebb50b24e4f88742f13eaf0abf83b08a3980f2406a4b6ac6", - ".opencode/node_modules/zod/src/v3/tests/default.test.ts": "92067a119b984ab2750a1eba8e1a5573276aeaed95056e9e283f4a3bb54e5d5e", - ".opencode/node_modules/zod/src/v3/tests/description.test.ts": "f1b75327fd817bb63c3b2e3be0ff9aff7511e2a391713756d8cb26cbfd3d5aa5", - ".opencode/node_modules/zod/src/v3/tests/discriminated-unions.test.ts": "5bd00d9f7eeac9dd60faa03af58b5a915040f54b93ff77c4eab0d4a18f8b9e9c", - ".opencode/node_modules/zod/src/v3/tests/enum.test.ts": "3fcf9f740cd53f52431abda4c61a3732dde44c3a9f1033159e9c0c92fcebd09a", - ".opencode/node_modules/zod/src/v3/tests/error.test.ts": "1c77f7fefb917210ac238159c8116bd159158c260008bb6fd2f8a91ba9e55538", - ".opencode/node_modules/zod/src/v3/tests/firstparty.test.ts": "9906d7da8e62f7b9b7e5cd9553bea0872ad8a0b259214034108cec8a624cfc1d", - ".opencode/node_modules/zod/src/v3/tests/firstpartyschematypes.test.ts": "9be5a0881151df777293172088b2039ed7e1e6f4a98a7557cdd0211f108c9b96", - ".opencode/node_modules/zod/src/v3/tests/function.test.ts": "93b2a5008501e4d883c838e567a6d3da18f88f31fb71a7ba5615664572f89783", - ".opencode/node_modules/zod/src/v3/tests/generics.test.ts": "d1a4c54239acff333f76231084c48133257fefe14f504d0cab055979b5a9e723", - ".opencode/node_modules/zod/src/v3/tests/instanceof.test.ts": "b11897351fcf312dee317408b7aa27929ba37538c468588429f4f4ae3c5fa4ce", - ".opencode/node_modules/zod/src/v3/tests/intersection.test.ts": "c560d1fbca45e049f07eb2a382f7ee4c678164825cf5725b014312eac9412c8c", - ".opencode/node_modules/zod/src/v3/tests/language-server.source.ts": "aeb84d6fb14e6511a3509a0545770d869986f397ee02da640730d4ff97c6be4a", - ".opencode/node_modules/zod/src/v3/tests/language-server.test.ts": "c75a03c145d1b0f28c62cba881aae1db600912a1df3be99d1996a18da776c498", - ".opencode/node_modules/zod/src/v3/tests/literal.test.ts": "a22faed6c08e04c75d6bb0180d5e1fafc4645b15445a479f338016ee4855c362", - ".opencode/node_modules/zod/src/v3/tests/map.test.ts": "bb52432835e9fa70a94f444fc9e7c2d8e6ac5eb905cc3b8ae54846e52fa57360", - ".opencode/node_modules/zod/src/v3/tests/masking.test.ts": "557f4bc4d8f4ab29afd87f07cf70fa4de60d765fc86fbc5eb4b788f34dd825a2", - ".opencode/node_modules/zod/src/v3/tests/mocker.test.ts": "46f64d2995e29c5838189ed10cd1b185b44c33dbc21418364a80ac19f285362b", - ".opencode/node_modules/zod/src/v3/tests/nan.test.ts": "2ef6416fc58f94964cb889d28f8b7019a90365d24229d8146c845b8c7b0b8610", - ".opencode/node_modules/zod/src/v3/tests/nativeEnum.test.ts": "6f64363193a447f71a8aa5440461bdb5451c6c4433fb4fca785ef4798d5b52fb", - ".opencode/node_modules/zod/src/v3/tests/nullable.test.ts": "c51336535632e1404455bca333ccafc143191600047251a3e3e13f551693c516", - ".opencode/node_modules/zod/src/v3/tests/number.test.ts": "d56a65ec9bc03482c2b23eecb97c5213b564b5aaafeaf205d2a1f54a2c91e1d8", - ".opencode/node_modules/zod/src/v3/tests/object-augmentation.test.ts": "69777e89506b2d8157c98e176dde94a7c664db7228485c52a18154ac4b794382", - ".opencode/node_modules/zod/src/v3/tests/object-in-es5-env.test.ts": "687d2c52ddaaca24ab3d3e301cba042a7560888570af23b50a0597d3012d500c", - ".opencode/node_modules/zod/src/v3/tests/object.test.ts": "489ba37da9b2117c730aaa847099c7899ba0a376cf612495f41c42c29808fb45", - ".opencode/node_modules/zod/src/v3/tests/optional.test.ts": "0edb7a6478e931bf256f3d71d1367d2b8199b4082101dba97cc306e4dc3c92c9", - ".opencode/node_modules/zod/src/v3/tests/parseUtil.test.ts": "822a6d836b05ab38eadf26592420eba6be694dc7c1d654388efbb601af00c288", - ".opencode/node_modules/zod/src/v3/tests/parser.test.ts": "7c8b720c921618f6c89eb08eae80ba45602fa04dafe9c7b9b861512954d70f9b", - ".opencode/node_modules/zod/src/v3/tests/partials.test.ts": "2fe9fe6cdfa085eed053894acdd3ef32c5425f7da0aa81ea1043c737fcd7cf41", - ".opencode/node_modules/zod/src/v3/tests/pickomit.test.ts": "cd05293952b5fb0ca26eeb9ba6cd69a26f58fa5c62eb35f9ce1eb1ac9e00ccd6", - ".opencode/node_modules/zod/src/v3/tests/pipeline.test.ts": "b285aac721b744da8c5a0a093c9ed57244643e1e333df576922e67bfaa9e16ab", - ".opencode/node_modules/zod/src/v3/tests/preprocess.test.ts": "7433e95a0504f7a250b7beaac30a5e8f500c315d99b3badb978a0b28ed7a61dd", - ".opencode/node_modules/zod/src/v3/tests/primitive.test.ts": "d0214e93de931a8e6b89b3cb89df1cbf7299d6be00abcdde71ffdcb811953dea", - ".opencode/node_modules/zod/src/v3/tests/promise.test.ts": "c03c19525c56cea7808d982bd23ba89b5a48db13934e8d4824e13ee0a249d19b", - ".opencode/node_modules/zod/src/v3/tests/readonly.test.ts": "dc0ffe451674c8938b67bdcb63deff7958dd1f0e5ec69a485bba470c978817c1", - ".opencode/node_modules/zod/src/v3/tests/record.test.ts": "ebefc7ad59b2246787408efe7aaf2f7beec0b5c90c70e79990dcbf241c2d6c95", - ".opencode/node_modules/zod/src/v3/tests/recursive.test.ts": "9ccc037ef698ceeb4e6986e02aaee71ff318c4dc0f875b9253bff6ba14db9137", - ".opencode/node_modules/zod/src/v3/tests/refine.test.ts": "2f3a329952b6f0109028a142225d719b05307a18f7395297711f725b599933f8", - ".opencode/node_modules/zod/src/v3/tests/safeparse.test.ts": "5aa7597109dea6647d0ab022a4aab694c44acfeae53386ea6366b806fbd3e872", - ".opencode/node_modules/zod/src/v3/tests/set.test.ts": "eeadd9dabd30b857c5de8526503cb1e5c8b6aa45a99c4708958ca1f822bb3da2", - ".opencode/node_modules/zod/src/v3/tests/standard-schema.test.ts": "f74076e43f44c887b3bf7d26d5ec686b8070359e63fe373e6c45b6c7708c025c", - ".opencode/node_modules/zod/src/v3/tests/string.test.ts": "19ac9a6b0a7c610038d93dd9f97d453ad3a54bd5e1b1dab99e25d544872d93cd", - ".opencode/node_modules/zod/src/v3/tests/transformer.test.ts": "e3da81596e0077326f442153a741c9ba25669b8b5eb025a8ea7fc1eb0106faf7", - ".opencode/node_modules/zod/src/v3/tests/tuple.test.ts": "e8a48c8791387c94076c7eee8c9b7e18712a45356c7167f7c43bedd54544614f", - ".opencode/node_modules/zod/src/v3/tests/unions.test.ts": "0c32f20d663b30438bb8ff834b7f066722160720a25a90e6513a66b9baa4604d", - ".opencode/node_modules/zod/src/v3/tests/validations.test.ts": "10ae103a7c7d9e5ce8bdacf9ef30bc44a52cdadf64fe365f492d27dcb432b482", - ".opencode/node_modules/zod/src/v3/tests/void.test.ts": "6bdad3b7ba624eb3bd03c0bfea611de46c10aba62d03df9bd2ba49bafedc15cb", - ".opencode/node_modules/zod/src/v3/types.ts": "1cfe15702dbccfc592bb46679bb6979c6b1b44b9790114a01447436849f0900d", - ".opencode/node_modules/zod/src/v4/classic/checks.ts": "925c0df406a04a1d0441c0e51f5c08dd1f7534a1919ebd525a7dc271aafb20a6", - ".opencode/node_modules/zod/src/v4/classic/coerce.ts": "9cf6429965d7d55dc4e50999af50cbad5b57b5f3372bba13038133aba62ba600", - ".opencode/node_modules/zod/src/v4/classic/compat.ts": "1333987ab926fb40f4708758fc674c50be1f05540b62fd1de5cb8ccda574e532", - ".opencode/node_modules/zod/src/v4/classic/errors.ts": "840c80756be1f61952e0a65234029e31fb3aae32e3eca4a68ea969f809a81159", - ".opencode/node_modules/zod/src/v4/classic/external.ts": "58af150e9fb50909d26d4f795743ab20f8807ea5098ee59145d52ccc3b4aa3bf", - ".opencode/node_modules/zod/src/v4/classic/index.ts": "8f199f0404574864bd10cac8311858f39d50d418609a0ef089dfd444d4977840", - ".opencode/node_modules/zod/src/v4/classic/iso.ts": "e3215f0650c1256d63299dec334fbbcb0b92517286618f1c24223b5294fd5fb4", - ".opencode/node_modules/zod/src/v4/classic/parse.ts": "07b4d6d890ca610989b15429a0c551d6ecc0dcae8e2d6f96933b678e389d1422", - ".opencode/node_modules/zod/src/v4/classic/schemas.ts": "a2b12003d2afaf67832200bd3adbbfa567fcbd7eec0ed7356b487a1b2cae6f62", - ".opencode/node_modules/zod/src/v4/classic/tests/anyunknown.test.ts": "c5fbbcc05862414d2bf8d8c33e90b15f07d2542e56408e668bc4ab8dcdae5758", - ".opencode/node_modules/zod/src/v4/classic/tests/array.test.ts": "a73d624a81a7308e901b2584c491eb40dcb05f04c2acd96fb90f96bff5547058", - ".opencode/node_modules/zod/src/v4/classic/tests/assignability.test.ts": "40dfb44f27f5ca10cd854d050e203a228ef616424eda3ade56e0a1e70bb34c92", - ".opencode/node_modules/zod/src/v4/classic/tests/async-parsing.test.ts": "5854e0b2712dea24b27f8d0b5dad384e06eaa785539e1388df941fb4fe6ff80b", - ".opencode/node_modules/zod/src/v4/classic/tests/async-refinements.test.ts": "ac573cf82efdba4cf92c86ac959f63177c4622338609848869f778c0d125cdb2", - ".opencode/node_modules/zod/src/v4/classic/tests/base.test.ts": "0401d2c75ed792b13bf811fc77801c485b89671460a4d52439cf1908fe94f2b7", - ".opencode/node_modules/zod/src/v4/classic/tests/bigint.test.ts": "85cbf87b9df6b154ad7d3f5df030b38fb2c2761dcbc2d4a2ddc7ba06de95433d", - ".opencode/node_modules/zod/src/v4/classic/tests/brand.test.ts": "bceffeda14e55a4c6f6d4f2e30fe9a1d8d1199d50bae767c0ac0c4d4aeb41414", - ".opencode/node_modules/zod/src/v4/classic/tests/catch.test.ts": "de6705d2346629d186396674c7e773b8c8193208337972b2d6cb99be064fbab1", - ".opencode/node_modules/zod/src/v4/classic/tests/coalesce.test.ts": "e03d0cc88ac34605b99061da2e0ce22a7e7fbca0467819096994bcf63bb5b8f9", - ".opencode/node_modules/zod/src/v4/classic/tests/codec-examples.test.ts": "3498ad27ece4f8d97a9588728a909d6fee046b7ef8d6d9f442ce702193f0d789", - ".opencode/node_modules/zod/src/v4/classic/tests/codec.test.ts": "7e21189d8f75aa63d8a164945b9ed7fa06d621a8b5c13c323b32039eb30b46d2", - ".opencode/node_modules/zod/src/v4/classic/tests/coerce.test.ts": "b1c494141f3120e5f4253c2bbe1131468d32281e8a9513f27fb40fee3142b55a", - ".opencode/node_modules/zod/src/v4/classic/tests/continuability.test.ts": "634af853f6509497f8d441045039fd1b6f43fcece4be813ec56c3f25134b53d0", - ".opencode/node_modules/zod/src/v4/classic/tests/custom.test.ts": "130ec42a9e192f5b4f43a890303921cb0ec7f1e878a4f0d6a1aacfe863ff1b91", - ".opencode/node_modules/zod/src/v4/classic/tests/date.test.ts": "e578d7bea1019b22cbc675788e5cc7123f9c3fc2ed90e8a8cb222ddb0cdb8b76", - ".opencode/node_modules/zod/src/v4/classic/tests/datetime.test.ts": "b511934d118fa8dc605900ab770a4f71fededd603f57a83540993e31b40c4fdc", - ".opencode/node_modules/zod/src/v4/classic/tests/default.test.ts": "e6cdf299ffff3de4ddc745c018065484ff01575033c130fdc671f6e84879c94e", - ".opencode/node_modules/zod/src/v4/classic/tests/description.test.ts": "c7f59501ffad7792ae76c178e6098de70c3da062280dc788f3354187335cfd8f", - ".opencode/node_modules/zod/src/v4/classic/tests/discriminated-unions.test.ts": "8098b485b5f6279a609d21aaf51fb2f56177c84577bd903b028f0e5e5a6c9dea", - ".opencode/node_modules/zod/src/v4/classic/tests/enum.test.ts": "2a76a8b5c6116fb06a249284afaf3aba4512a790264f86871857a0b5f6af7650", - ".opencode/node_modules/zod/src/v4/classic/tests/error-utils.test.ts": "61f208ad1009e950137ad8da3ebc8ab78a44d2a23fafea788c0bd4247bf7de86", - ".opencode/node_modules/zod/src/v4/classic/tests/error.test.ts": "f38b436b7cba83cb35993e05fc7feeb7e8e1dc6695dd069936e232412f6fce5a", - ".opencode/node_modules/zod/src/v4/classic/tests/file.test.ts": "3c6d15d9c9230a1047e2036aa7c56ee9bed22d8b91dccab6c7afeec42fe978e9", - ".opencode/node_modules/zod/src/v4/classic/tests/firstparty.test.ts": "60e754acdfeb6bac8dc94fe59f15df0b0a94e9fbead52d230f27ea687aa85cc9", - ".opencode/node_modules/zod/src/v4/classic/tests/function.test.ts": "d052609b0510d42fc956e427a797fbd42cedfc7f2b9f401a200f5c9dc46428bf", - ".opencode/node_modules/zod/src/v4/classic/tests/generics.test.ts": "e4dc0d13796ff0d0ef8b5286dd168afec62ddeef66fae429cc9860b2bbd8f80b", - ".opencode/node_modules/zod/src/v4/classic/tests/hash.test.ts": "14633fa651f6bbdd23ef23a8ce4c4520c3981f7462980f8b8cef8ee5fdc85ef5", - ".opencode/node_modules/zod/src/v4/classic/tests/index.test.ts": "ba5dfbd8b70a5fd894b239ffe1e9beecb7d5603641ff7b375dce062b3f626229", - ".opencode/node_modules/zod/src/v4/classic/tests/instanceof.test.ts": "483a97ca348233bcf363859b2354dd6f1974d131091cc6b1953f8ae974b27503", - ".opencode/node_modules/zod/src/v4/classic/tests/intersection.test.ts": "f443b615937d929a381a7a8b1b0ec17f63f1514e8a5d8059d63e4ad4d52c1334", - ".opencode/node_modules/zod/src/v4/classic/tests/json.test.ts": "ee8aeddf8696bb9dc97d360217b4140ed9434ec6c6e8598ccda18094c6981c2d", - ".opencode/node_modules/zod/src/v4/classic/tests/lazy.test.ts": "4c3c1ac8620955f7f19713771ef48d8401fa0deecd0e1dfda205fecf89f8c8cc", - ".opencode/node_modules/zod/src/v4/classic/tests/literal.test.ts": "a861896be65d22ddcc0cb10bfcb3bc8bd8ed0397406617d0cb1f76f69768def6", - ".opencode/node_modules/zod/src/v4/classic/tests/map.test.ts": "2847a83a1ab7c56e6bc75f16589ebc732eac91a4b9a4ed95b347669a47b116af", - ".opencode/node_modules/zod/src/v4/classic/tests/nan.test.ts": "2ff494cc0c8c785f29b0b4c2eff2e93b3f829b518266d78a05ee40fea9ffc11a", - ".opencode/node_modules/zod/src/v4/classic/tests/nested-refine.test.ts": "8e3f3492563a93adaded8b352e03a087f1c5c2f6a5fbc853ca4e2706ea006799", - ".opencode/node_modules/zod/src/v4/classic/tests/nonoptional.test.ts": "f14e374f6e33d7ada15398cde5248cb66f3c946c9734382f467c3c7ea4f5fc44", - ".opencode/node_modules/zod/src/v4/classic/tests/nullable.test.ts": "a749dc3a8ca78ac05c94abd82958c692b492e4ed6d93c6c05ad1b9a769067850", - ".opencode/node_modules/zod/src/v4/classic/tests/number.test.ts": "08680e3f8f46c1438b6305fa3fdde01ab779c8082b53187e1578bfd717ea3460", - ".opencode/node_modules/zod/src/v4/classic/tests/object.test.ts": "fe0edca4f092f0b478f4e9ea307d3f2ab6c996465d4395627320043c99694fe6", - ".opencode/node_modules/zod/src/v4/classic/tests/optional.test.ts": "61addfd84555a58342a1073ac090ca8f0b8d8fbdfa8a0c7bc9b7f852fc23ddb7", - ".opencode/node_modules/zod/src/v4/classic/tests/partial.test.ts": "1ddc98d1b019d447895db51ec71c3caa8afbafa1bf252c7982225e2aa2bb6fde", - ".opencode/node_modules/zod/src/v4/classic/tests/pickomit.test.ts": "fd55dd310c6197a27fdd486dfbb94649079b422a2951188c4b4ea784a693a1db", - ".opencode/node_modules/zod/src/v4/classic/tests/pipe.test.ts": "9383a52f0f85e97ff34e7bd485bedf2c1a5ef3ffd37ace47001de7b885b7adfd", - ".opencode/node_modules/zod/src/v4/classic/tests/prefault.test.ts": "1943868cdc707eef785d48dd9d73ff10f764beed2b706ac73a0aed85a428b740", - ".opencode/node_modules/zod/src/v4/classic/tests/preprocess.test.ts": "04f3f845eda2e0f36a35c5caa871e7e1169b733bbe20ec04a09b2b374a254357", - ".opencode/node_modules/zod/src/v4/classic/tests/primitive.test.ts": "b5a2f74d03039eff1d20229ab35594d1e9a808dc50b943e802724525338f9f1f", - ".opencode/node_modules/zod/src/v4/classic/tests/promise.test.ts": "b7d58a54d480429314f23cd52c2a78baefaa5c1e87f4bfd0b13a9e30d586c754", - ".opencode/node_modules/zod/src/v4/classic/tests/prototypes.test.ts": "c6e493d089995c8aecc728b99bd2dbcb6f56137be4537c3e2fd657838c582ebc", - ".opencode/node_modules/zod/src/v4/classic/tests/readonly.test.ts": "c2ddd379f4e85caa2856ba0b784c44be87c079cef18ba4108ba698fa9fecb611", - ".opencode/node_modules/zod/src/v4/classic/tests/record.test.ts": "25b6ce62b9a6736d821ea62e07106aaa73a42de7f8824ffc97463e5095a110be", - ".opencode/node_modules/zod/src/v4/classic/tests/recursive-types.test.ts": "d2d4570462ad84cf6b3fb17d99ad4d4c242b4e5d078c1775b530b8f721ba9407", - ".opencode/node_modules/zod/src/v4/classic/tests/refine.test.ts": "df89ea9a88dd60b38001b3a31b643f99d207f39a6d225e6bc107d6c44d4682d0", - ".opencode/node_modules/zod/src/v4/classic/tests/registries.test.ts": "4325f184075634098ec061b2b6070bc7b27c55301191a444d41662e029ce6e33", - ".opencode/node_modules/zod/src/v4/classic/tests/set.test.ts": "4e3d1d08df1bcd2e659a2f6de0f12f816e756ac9ec98b253cdb931ffe8ecc951", - ".opencode/node_modules/zod/src/v4/classic/tests/standard-schema.test.ts": "af5fef06f1d9f695d39842ae98c29555f05c432d2fe5abd361a20c748de39c7c", - ".opencode/node_modules/zod/src/v4/classic/tests/string-formats.test.ts": "d6b61815603bbfc8a7acaeac23d88773195c45991c7b85dffc85e23f24b37640", - ".opencode/node_modules/zod/src/v4/classic/tests/string.test.ts": "bf41f94cf272b01900fd4cc0086aa6bc276c181ab430c45010c74ce0a7c665e8", - ".opencode/node_modules/zod/src/v4/classic/tests/stringbool.test.ts": "6a5f06aa81c442476c2c656607ec580707afbb08df67eb813ce7dd0f20c42fdd", - ".opencode/node_modules/zod/src/v4/classic/tests/template-literal.test.ts": "9662e3535763a01abef78e5775aa82559b4286b0cdf1aa71ecc3e3123358def5", - ".opencode/node_modules/zod/src/v4/classic/tests/to-json-schema.test.ts": "f5c70e5db4f70be9b2fb7a24f54a329b20123fa61d54cdf120e1dc8d0d7af879", - ".opencode/node_modules/zod/src/v4/classic/tests/transform.test.ts": "250d1faea61566e13c1067b3c2fe900af97219c19cfc4a530d48a3047d40f048", - ".opencode/node_modules/zod/src/v4/classic/tests/tuple.test.ts": "96d3aa6cfec7dfd9febdee31b1aca837c0a79c378678b9e833fa5479cff9fbfc", - ".opencode/node_modules/zod/src/v4/classic/tests/union.test.ts": "f286b4b36f5097f2b127e632400b2627b714e4b4a82872adbe0e47198866dc39", - ".opencode/node_modules/zod/src/v4/classic/tests/validations.test.ts": "b2dd61b3b20b082cee04ef5d988cb4852909f9f0e71bbaf2a91f1a681bc674be", - ".opencode/node_modules/zod/src/v4/classic/tests/void.test.ts": "a1e5ff5f759a09e192764d4f153443bc5735cf8b6db6d810e21f0dddb414e805", - ".opencode/node_modules/zod/src/v4/core/api.ts": "6fb05b277c361f635d0aa0c368b12ac8873d497a62d31c9b83b8e64c38a2b3e3", - ".opencode/node_modules/zod/src/v4/core/checks.ts": "8159fb8da563bdaff4691d7e335c4512644b8fe83eb5d52c1b120357eeb18202", - ".opencode/node_modules/zod/src/v4/core/config.ts": "3c3ec0c03ee7387685f7a5e0155d9d55f251ffe4cfb5f4447bcbd380aed3d847", - ".opencode/node_modules/zod/src/v4/core/core.ts": "ab5c675bf8aae560c8f98f20060bae665969ebac1206470b787015897913795b", - ".opencode/node_modules/zod/src/v4/core/doc.ts": "496e63b612809bf031a1964bb2e74c1af4c1a4ba1f8e5cd6f164818ea135205a", - ".opencode/node_modules/zod/src/v4/core/errors.ts": "e31623519621b96a48f3b77c9ee06ed03a56ebe9e3084ec355028ab000d2c2f8", - ".opencode/node_modules/zod/src/v4/core/index.ts": "494aaa47240956231f7de5184f7f0828eaa15ba5c5f59fbda42da7b1348258ce", - ".opencode/node_modules/zod/src/v4/core/json-schema.ts": "42b03730793dc873cce370997da2b1347b00d27effab1793173d7fe7bf422f0c", - ".opencode/node_modules/zod/src/v4/core/parse.ts": "d57d675f81cc59fb20fbc25305f38379af0600bdfe2779343cc2b4dfc5bc97bf", - ".opencode/node_modules/zod/src/v4/core/regexes.ts": "2d2760dcf3fe783459076b0d4abfbc339e6950d49a5546f8a48238424e499f6f", - ".opencode/node_modules/zod/src/v4/core/registries.ts": "6b4495ef30e97ed0d2a596627db079c529778230b30a2349d60211fd7d1b0a62", - ".opencode/node_modules/zod/src/v4/core/schemas.ts": "a2622476b8e40f9437a30b3c43060292a8288134b68ef229aa6197ef5f37fa93", - ".opencode/node_modules/zod/src/v4/core/standard-schema.ts": "a735603c641e08e97ac18157e4a427334ed7026ecc49ddb8abd8e3fc8d841294", - ".opencode/node_modules/zod/src/v4/core/tests/extend.test.ts": "e4705c146e848c2d9a3b4051136e6c38a1b176ce2e223e474de29e227f4fc078", - ".opencode/node_modules/zod/src/v4/core/tests/index.test.ts": "f9a514f603c4350f532d14c5a3fd4748935c4ffb1ab90fa8e8df8671163a7524", - ".opencode/node_modules/zod/src/v4/core/tests/locales/be.test.ts": "d6c1dbf1710130c39d58aa149c5f33cea1e445f05fda5fff6c6e6393358d699d", - ".opencode/node_modules/zod/src/v4/core/tests/locales/en.test.ts": "5bb1540b21984c313c14c4b6e91e14c8b4ec40dd42d0f30bdf24c04543971a29", - ".opencode/node_modules/zod/src/v4/core/tests/locales/es.test.ts": "08d570db1dd2cd1ddd16ecef4c6d9d7e3e293937e255ef6cb960fbabb5ba4a02", - ".opencode/node_modules/zod/src/v4/core/tests/locales/ru.test.ts": "1944b0643c6ef9e194e5256515363d9f475eacb7339ff1745a7629ca38109dd8", - ".opencode/node_modules/zod/src/v4/core/tests/locales/tr.test.ts": "ac6abcbff8137f499ab657115612c32ceb9b0c12903e27f988e8a97d208d2652", - ".opencode/node_modules/zod/src/v4/core/to-json-schema.ts": "207d5dd7d8d3f7a95ba12ce19b1a796e05fb1469e62e03e0163d84378a6d1829", - ".opencode/node_modules/zod/src/v4/core/util.ts": "964d81422e18b3e2645eb4377a24d68a648c938edfc55193efc70b36a35ad437", - ".opencode/node_modules/zod/src/v4/core/versions.ts": "10019f749ef18db03e5e02505ecf581180a183f9838d2571de6d051805906661", - ".opencode/node_modules/zod/src/v4/core/zsf.ts": "975e61b52c8de9fad0a0086952b9ee8a1abbacfa4489659529c53e80dd78094e", - ".opencode/node_modules/zod/src/v4/index.ts": "9f0dfd9a085d4ec23e8a073406300e4bffeb0fd3540b1f395c44b566f27d4d49", - ".opencode/node_modules/zod/src/v4/locales/ar.ts": "94cc038af03f0b0c8b264c1c55f107c93aa4b17c8c39759cac69c69ed5d9663a", - ".opencode/node_modules/zod/src/v4/locales/az.ts": "3d2f271c94a005a23cc831ed1a3a39167832f950048f6845bb5aab510e6b1188", - ".opencode/node_modules/zod/src/v4/locales/be.ts": "58396251205234d11850d154228b5cdbf96f7887f48b19d59ebc59a7c48adee7", - ".opencode/node_modules/zod/src/v4/locales/bg.ts": "480c87b4f81eacca8777c572611425944ebd705401ea922e0e3ee97640783514", - ".opencode/node_modules/zod/src/v4/locales/ca.ts": "d76dc77da7f7d89c3625ed821e333474f964fbfb69ab256007491d483a7fd11c", - ".opencode/node_modules/zod/src/v4/locales/cs.ts": "cef69a633387974e6703cc4eca16c242f889fa3754bc559d9277b705e627a1c9", - ".opencode/node_modules/zod/src/v4/locales/da.ts": "c76ab429619634c96263dbad70aca205e1cb9708f4e5bbe1159d69f7680c3026", - ".opencode/node_modules/zod/src/v4/locales/de.ts": "5abfa7d3751f211b66b833c8183c9614e371f9c987fa7baa60857135fec14d64", - ".opencode/node_modules/zod/src/v4/locales/en.ts": "f6e748ebd8de82a83281883a4180536070c96c2db4a159f23987b0e4a026c69d", - ".opencode/node_modules/zod/src/v4/locales/eo.ts": "3c0a3c8f8be4c6aa1ad0f792d6b420c29aa7c4ca92dd4832078d0b25538505f5", - ".opencode/node_modules/zod/src/v4/locales/es.ts": "e0e85ea85b55e3723a19337d98b33b9eb4d7ab5f1e803b27c1a0ff4b6c6a47fb", - ".opencode/node_modules/zod/src/v4/locales/fa.ts": "60b1d48339a83add0c5ab7b342072ee5112f868c7e0e66714aa7d2e6da005d13", - ".opencode/node_modules/zod/src/v4/locales/fi.ts": "2fe0d68b72a538a5bccffdf8ea27479e7321d26f0555e29cf827d33663c8e05f", - ".opencode/node_modules/zod/src/v4/locales/fr-CA.ts": "c5ad7b6f17961f82dd13cd046532d0c7b668f7e464132b2f1a9e7d5b531e37e9", - ".opencode/node_modules/zod/src/v4/locales/fr.ts": "da7a384cdf216c57feb3bbc3aef63a43b489a55353602888596b9f8b6e4027d7", - ".opencode/node_modules/zod/src/v4/locales/he.ts": "50e7e62b9079e19baeff8890eb60757eaef186527d464baaf93ebb6bb3c2a048", - ".opencode/node_modules/zod/src/v4/locales/hu.ts": "825c84d6fe0d5d1d410609df4efa20db893dd4c22eb93f2aa075f599fdec375b", - ".opencode/node_modules/zod/src/v4/locales/id.ts": "8f61c1779a45459f0606edc8e5e85eba0bc79de96b9ae6d3af9753d77fab5027", - ".opencode/node_modules/zod/src/v4/locales/index.ts": "26965e789e579cd10968fb6ca81caa2ae50c16608185b16dbe84eb00a652b0cf", - ".opencode/node_modules/zod/src/v4/locales/is.ts": "59d68dc8bb4c4655c301b0d875f27d0c9f9a8af72b8acb85463b6205de0b1cc1", - ".opencode/node_modules/zod/src/v4/locales/it.ts": "28f0f40702fd7d91461ccbb466d1d1ab3fdc3a702707e1ed356ff5e51453345d", - ".opencode/node_modules/zod/src/v4/locales/ja.ts": "87383f9f7101075eb087968f0d12084cdecf23a8e4af0b91ebf78ad8b788effb", - ".opencode/node_modules/zod/src/v4/locales/ka.ts": "cfe200ef72f4a56b05a6554d1af37d9bd00fe4c3413b0fd3a5cf97ed8a35a9c5", - ".opencode/node_modules/zod/src/v4/locales/kh.ts": "b55c1375cf0fbb40248ccc5d940074ef1448d38902ef0c8017d7ebda43649efb", - ".opencode/node_modules/zod/src/v4/locales/km.ts": "f68820276449ef124e35fa38a1b1d784dc5b4411bf9ee7127ffdaae3393882d8", - ".opencode/node_modules/zod/src/v4/locales/ko.ts": "ca90a3c670657d1f59253534bbc5ca56b73549d9a500229c605f269650802361", - ".opencode/node_modules/zod/src/v4/locales/lt.ts": "c8008b07f65ee7d3edfc61521dea423e1c4f746ef21e607cea0a3e8f4c6c40bd", - ".opencode/node_modules/zod/src/v4/locales/mk.ts": "536ccf5033c0153b424cafff555b7d4c6044b3bcc6ec73d65bca27c871d5be6b", - ".opencode/node_modules/zod/src/v4/locales/ms.ts": "689764fc38f037e660712af64ad285855c545762009cb7a8ad880ec6dfdf99df", - ".opencode/node_modules/zod/src/v4/locales/nl.ts": "cd4bf4207ff3a00fecb01b8d3cf56fa704d240b13f57527a5089f585aa657dc2", - ".opencode/node_modules/zod/src/v4/locales/no.ts": "09548cb18fceb85bb5d76cd618b8a822db9d170290bd51bc39806620a39ecf88", - ".opencode/node_modules/zod/src/v4/locales/ota.ts": "24bf7362b0b056f4fda7638b473401ecf218d3741f5face257735194a5328dee", - ".opencode/node_modules/zod/src/v4/locales/pl.ts": "09fe366cf11786b78a03c87c2224bac3358e629c944a232bbc32dc503ed19c47", - ".opencode/node_modules/zod/src/v4/locales/ps.ts": "a9c963e2339118f0c19b77e0743a8cf910c6edef62f189fb0a3643e336c2ffec", - ".opencode/node_modules/zod/src/v4/locales/pt.ts": "42f35b9c59c2bba49922ad3ada63b0408fe8de9b88807d6858af890ec255b622", - ".opencode/node_modules/zod/src/v4/locales/ru.ts": "99fff5403ea8c01263675e99ba7b80249fe3aa00dac829d1a20f8e26331268ac", - ".opencode/node_modules/zod/src/v4/locales/sl.ts": "a381cd500472dae38b617c0b32a4617b89ad9475218fd87bff0192f528ae787f", - ".opencode/node_modules/zod/src/v4/locales/sv.ts": "67aee549129f6f460bd500278347e13c96322a35f0c12c76aa3632cc3dbe724d", - ".opencode/node_modules/zod/src/v4/locales/ta.ts": "db63f8cc63fb88be90e822a0803e9c4b03bf96935182eb1c86229b4cc9300221", - ".opencode/node_modules/zod/src/v4/locales/th.ts": "b1c1e68c1b1d81fe26ba945b6cbec2dc2c4138c94b243b6827dd6a156ac7181e", - ".opencode/node_modules/zod/src/v4/locales/tr.ts": "57bfe52d1d8554cce8a2df4953cffea1fd69e59cd539fc0f8b54210abd1288ad", - ".opencode/node_modules/zod/src/v4/locales/ua.ts": "46a90664b22f2b079c7ee1d36c957df8b39442717c3df68c8bbb6a0df20eadce", - ".opencode/node_modules/zod/src/v4/locales/uk.ts": "db64683bb605b98c1a75e64dce0debf3717d1c44d8d196091975d7cf0c348e4a", - ".opencode/node_modules/zod/src/v4/locales/ur.ts": "1a1f050550cfcefb762ca6d9f2c0f46550613a5ec4ec835c5786f40c8ae8daf9", - ".opencode/node_modules/zod/src/v4/locales/vi.ts": "c417610d93565c91c8e730fb510d56baa8024d6757f21be03c54ad31d662f5e0", - ".opencode/node_modules/zod/src/v4/locales/yo.ts": "e8ce5e170c20aff2bb4e02a0708f68be021c1a3a4cbf4f41597e801c97b55f02", - ".opencode/node_modules/zod/src/v4/locales/zh-CN.ts": "a3d8b1bc3758099a6385f175f312f28505d15178d65557fd49db8bd686069cc0", - ".opencode/node_modules/zod/src/v4/locales/zh-TW.ts": "28b342ec5c1e2de8d2525893f4ee81407635fc500fa6560a74209513730eb8ae", - ".opencode/node_modules/zod/src/v4/mini/checks.ts": "c4f16c0e97aa6f0385c0bd147a0438f522343797d72f4cf0cb466213e0b74635", - ".opencode/node_modules/zod/src/v4/mini/coerce.ts": "e80b80452e5c3a248d3deb1044f14d9810d2e86642f943c10999f47116f226fc", - ".opencode/node_modules/zod/src/v4/mini/external.ts": "5ead78dfebae73aa37f3a9e4778f5558582e7caa442bbebe22587dcb00ff0da2", - ".opencode/node_modules/zod/src/v4/mini/index.ts": "455d93e660b61feec18cd4c31e2f394627546ae8ab31f9bacdab6318112ac31c", - ".opencode/node_modules/zod/src/v4/mini/iso.ts": "2d1a5e84a00aca5c3f1b2cebeb1b9a12ccfb7b38c15c96219571a20d72e39a03", - ".opencode/node_modules/zod/src/v4/mini/parse.ts": "f56f7674e03f0ff117dc804c6cb50361d9dc6ad33a12277009ec753ff99ccf0e", - ".opencode/node_modules/zod/src/v4/mini/schemas.ts": "3927bad5c4ac8c77fced35055da583975ad8e8dcdfe8baf75b4550f381bfe553", - ".opencode/node_modules/zod/src/v4/mini/tests/assignability.test.ts": "bf52ca9e89d34717a46dce06d03f2000027009ceaa0798dfa3bf8e47b5d537f6", - ".opencode/node_modules/zod/src/v4/mini/tests/brand.test.ts": "72b40e92c5ad4442024edd3681afbdf986476efff01929efb427b82c5b124710", - ".opencode/node_modules/zod/src/v4/mini/tests/checks.test.ts": "14c8c7c6fba2bc5a06a5f7fbfdf7774bf7a7135c233b47887cb306e5b7f71aa3", - ".opencode/node_modules/zod/src/v4/mini/tests/codec.test.ts": "e9d0836fc48f37a1e476b8a642140d57faecc0a885aefd4828c2ff6324750eec", - ".opencode/node_modules/zod/src/v4/mini/tests/computed.test.ts": "675fc1a036b656b796959fd8f90d6d3d5655c3f6788769e798f56447fba02d58", - ".opencode/node_modules/zod/src/v4/mini/tests/error.test.ts": "72d8e9e476dc934d09d5311fff1823b7f74f169ce30b696cf6870e6b514b1cf4", - ".opencode/node_modules/zod/src/v4/mini/tests/functions.test.ts": "438082fe06c6a89aa9f14ca53acd015be69205fb9aa26e8bb12d6494ee14005e", - ".opencode/node_modules/zod/src/v4/mini/tests/index.test.ts": "7ac070d07667b540db4ff4d87cd0c65574afa16df1e46ac459e720ae1cc08928", - ".opencode/node_modules/zod/src/v4/mini/tests/number.test.ts": "5e546f7b16d97b2a30fea914a900fa48d0de173ee58eb8a3bde04e1783e7c7e5", - ".opencode/node_modules/zod/src/v4/mini/tests/object.test.ts": "8806c1f526663a5a17d2c719dec7779b8070c9f7e8993d5787a303c862a61b21", - ".opencode/node_modules/zod/src/v4/mini/tests/prototypes.test.ts": "53d57aa1c4401cbe1958be7521b65c72ff55cf4993b1ae57543f4138f2c1e2e1", - ".opencode/node_modules/zod/src/v4/mini/tests/recursive-types.test.ts": "3797e364c9c68f42efca56dd1bb70209c70f1d16c3628d23ac302b2667d1e65c", - ".opencode/node_modules/zod/src/v4/mini/tests/string.test.ts": "38d0b96715d3d7472b8defb4e4e9ae2171b96a26f287f810eb30b52853c8db2b", - ".opencode/node_modules/zod/src/v4-mini/index.ts": "cf94ca2939d655e17fdfd1d0a3aceb4cbeb040d8946e9c9c1eaa5cc734dc69c8", - ".opencode/node_modules/zod/v3/ZodError.cjs": "88b9cdd780a91656965e07b94c86c0e4729f97923d45cc9d7944b0973a462503", - ".opencode/node_modules/zod/v3/ZodError.d.cts": "206e73f49f16633113787cc651dc03dc900379395dfa02ab1ef4c9cbbcd5adc2", - ".opencode/node_modules/zod/v3/ZodError.d.ts": "98ee86deadaf36f67986bae4987cfa4b7dfa135ab03706362187d5d00f6282a7", - ".opencode/node_modules/zod/v3/ZodError.js": "f5ac9e86b92e201d41e294d7aa35986a2aa28829e1bcc8ac25f7c8dce0674b99", - ".opencode/node_modules/zod/v3/errors.cjs": "bd32dbc86faeec2de1cc8e42ee3b29af2cbeb24fb5e3b57d7f056bb09cf8d4bd", - ".opencode/node_modules/zod/v3/errors.d.cts": "e3498cf5e428e6c6b9e97bd88736f26d6cf147dedbfa5a8ad3ed8e05e059af8a", - ".opencode/node_modules/zod/v3/errors.d.ts": "8e71e53b02c152a38af6aec45e288cc65bede077b92b9b43b3cb54a37978bb33", - ".opencode/node_modules/zod/v3/errors.js": "d40831f478288d76e82bbbcc3b7e95c8513b6e76471f2d49a37c979bc57d492d", - ".opencode/node_modules/zod/v3/external.cjs": "224ff76e204434ec469f71dbba41d475db1ea873d3f3a5c26d75ab3a9c9f5f11", - ".opencode/node_modules/zod/v3/external.d.cts": "a9ebb67d6bbead6044b43714b50dcb77b8f7541ffe803046fdec1714c1eba206", - ".opencode/node_modules/zod/v3/external.d.ts": "5d26d2e47e2352def36f89a3e8bf8581da22b7f857e07ef3114cd52cf4813445", - ".opencode/node_modules/zod/v3/external.js": "5d26d2e47e2352def36f89a3e8bf8581da22b7f857e07ef3114cd52cf4813445", - ".opencode/node_modules/zod/v3/helpers/enumUtil.cjs": "d43aa81f5bc89faa359e0f97c814ba25155591ff078fbb9bfd40f8c7c9683230", - ".opencode/node_modules/zod/v3/helpers/enumUtil.d.cts": "f672c876c1a04a223cf2023b3d91e8a52bb1544c576b81bf64a8fec82be9969c", - ".opencode/node_modules/zod/v3/helpers/enumUtil.d.ts": "f672c876c1a04a223cf2023b3d91e8a52bb1544c576b81bf64a8fec82be9969c", - ".opencode/node_modules/zod/v3/helpers/enumUtil.js": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", - ".opencode/node_modules/zod/v3/helpers/errorUtil.cjs": "9c9e5dec7d1e9fce6a967735adf0333abd44d5e94a0225537c60ceed0c4f606a", - ".opencode/node_modules/zod/v3/helpers/errorUtil.d.cts": "e4b03ddcf8563b1c0aee782a185286ed85a255ce8a30df8453aade2188bbc904", - ".opencode/node_modules/zod/v3/helpers/errorUtil.d.ts": "e4b03ddcf8563b1c0aee782a185286ed85a255ce8a30df8453aade2188bbc904", - ".opencode/node_modules/zod/v3/helpers/errorUtil.js": "68589c0cf44c8b4ad19b77418f53caad2cd1dbc8d714f526e1876ebdee4828c5", - ".opencode/node_modules/zod/v3/helpers/parseUtil.cjs": "40aaa18765f13ae1a933c6f215ecceb6d3afee89eaa23bb080997e08c30a5b00", - ".opencode/node_modules/zod/v3/helpers/parseUtil.d.cts": "dba3f34531fd9b1b6e072928b6f885aa4d28dd6789cbd0e93563d43f4b62da53", - ".opencode/node_modules/zod/v3/helpers/parseUtil.d.ts": "754a9396b14ca3a4241591afb4edc644b293ccc8a3397f49be4dfd520c08acb3", - ".opencode/node_modules/zod/v3/helpers/parseUtil.js": "b57484013bb360bac2e334d58fcd5f6cbfa63ffcc7d8ff3dbbb3380ca659b956", - ".opencode/node_modules/zod/v3/helpers/partialUtil.cjs": "d43aa81f5bc89faa359e0f97c814ba25155591ff078fbb9bfd40f8c7c9683230", - ".opencode/node_modules/zod/v3/helpers/partialUtil.d.cts": "2329d90062487e1eaca87b5e06abcbbeeecf80a82f65f949fd332cfcf824b87b", - ".opencode/node_modules/zod/v3/helpers/partialUtil.d.ts": "de2316e90fc6d379d83002f04ad9698bc1e5285b4d52779778f454dd12ce9f44", - ".opencode/node_modules/zod/v3/helpers/partialUtil.js": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", - ".opencode/node_modules/zod/v3/helpers/typeAliases.cjs": "d43aa81f5bc89faa359e0f97c814ba25155591ff078fbb9bfd40f8c7c9683230", - ".opencode/node_modules/zod/v3/helpers/typeAliases.d.cts": "d3cfde44f8089768ebb08098c96d01ca260b88bccf238d55eee93f1c620ff5a5", - ".opencode/node_modules/zod/v3/helpers/typeAliases.d.ts": "d3cfde44f8089768ebb08098c96d01ca260b88bccf238d55eee93f1c620ff5a5", - ".opencode/node_modules/zod/v3/helpers/typeAliases.js": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", - ".opencode/node_modules/zod/v3/helpers/util.cjs": "9b75d4955de684d11371054bbf8e4d747cb1ad7284ae1eda85d4c188b84c47fc", - ".opencode/node_modules/zod/v3/helpers/util.d.cts": "293eadad9dead44c6fd1db6de552663c33f215c55a1bfa2802a1bceed88ff0ec", - ".opencode/node_modules/zod/v3/helpers/util.d.ts": "293eadad9dead44c6fd1db6de552663c33f215c55a1bfa2802a1bceed88ff0ec", - ".opencode/node_modules/zod/v3/helpers/util.js": "906ef685e5853d496e293ee8f8a2bb8b1ed2d078a1bcdfedb2dfd61d75b6330c", - ".opencode/node_modules/zod/v3/index.cjs": "d58a1fea61e0f0bccdb2f4fd3b7b098adcfddb44f036302c4d2c4e173540a292", - ".opencode/node_modules/zod/v3/index.d.cts": "833e92c058d033cde3f29a6c7603f517001d1ddd8020bc94d2067a3bc69b2a8e", - ".opencode/node_modules/zod/v3/index.d.ts": "3db2efd285e7328d8014b54a7fce3f4861ebcdc655df40517092ed0050983617", - ".opencode/node_modules/zod/v3/index.js": "3db2efd285e7328d8014b54a7fce3f4861ebcdc655df40517092ed0050983617", - ".opencode/node_modules/zod/v3/locales/en.cjs": "5fe55414e39de7fff8ec19983adc1421c238b679f8aa6b9d3311c7f9f12a21f3", - ".opencode/node_modules/zod/v3/locales/en.d.cts": "fec412ded391a7239ef58f455278154b62939370309c1fed322293d98c8796a6", - ".opencode/node_modules/zod/v3/locales/en.d.ts": "c25ce98cca43a3bfa885862044be0d59557be4ecd06989b2001a83dcf69620fd", - ".opencode/node_modules/zod/v3/locales/en.js": "51a6dcc5d4bd52a64da127c012c6c963395c1d9a343ad75d4a8eca662fc8b205", - ".opencode/node_modules/zod/v3/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", - ".opencode/node_modules/zod/v3/standard-schema.cjs": "d43aa81f5bc89faa359e0f97c814ba25155591ff078fbb9bfd40f8c7c9683230", - ".opencode/node_modules/zod/v3/standard-schema.d.cts": "25b3f581e12ede11e5739f57a86e8668fbc0124f6649506def306cad2c59d262", - ".opencode/node_modules/zod/v3/standard-schema.d.ts": "25b3f581e12ede11e5739f57a86e8668fbc0124f6649506def306cad2c59d262", - ".opencode/node_modules/zod/v3/standard-schema.js": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", - ".opencode/node_modules/zod/v3/types.cjs": "9c7618e57545b5e44faecbcfc3bb00eed67ca46efbb11bd1b6ad3a1d69de95eb", - ".opencode/node_modules/zod/v3/types.d.cts": "93c3e73824ad57f98fd23b39335dbdae2db0bd98199b0dc0b9ccc60bf3c5134a", - ".opencode/node_modules/zod/v3/types.d.ts": "7941f67bd576a61bb48f46cf55c5eb0292c8ee3eb2db621815eb1d02b1aa09fb", - ".opencode/node_modules/zod/v3/types.js": "2faaeed1150d0d7126f6aa85b01594fabdd06771a0b7cb5df2d13f725e175ef1", - ".opencode/node_modules/zod/v4/classic/checks.cjs": "a3ec0b1f2ce36c04dff99eced43eef15bd378c50d0113c723c20ad89ac535fae", - ".opencode/node_modules/zod/v4/classic/checks.d.cts": "7b9496d2e1664155c3c293e1fbbe2aba288614163c88cb81ed6061905924b8f9", - ".opencode/node_modules/zod/v4/classic/checks.d.ts": "3a8449ac50228a454f709360f32277f3670ec62e7aabf1e6710538dd3353b2b4", - ".opencode/node_modules/zod/v4/classic/checks.js": "f20eb2e1153f7b80a52d30261dc0b90ad4a5adcfccc9b150d3998afdaea7f9d3", - ".opencode/node_modules/zod/v4/classic/coerce.cjs": "d25b07eead67ff339c3e2a6d5b7364647e11f57fff3e82055d49575304dce057", - ".opencode/node_modules/zod/v4/classic/coerce.d.cts": "e818471014c77c103330aee11f00a7a00b37b35500b53ea6f337aefacd6174c9", - ".opencode/node_modules/zod/v4/classic/coerce.d.ts": "1f4a8bb5e841d3a1510d12a84640ee327f5a4b55484e8f16e2b285d54eb19924", - ".opencode/node_modules/zod/v4/classic/coerce.js": "fb2efe3b6eacc475c77cbb571b5fb8650d92a10f316dc869e49b722973d83c3c", - ".opencode/node_modules/zod/v4/classic/compat.cjs": "dcd3c3b05fbc82ed0241ec7e015071a528a1e2e8025e96259be932e7340608e7", - ".opencode/node_modules/zod/v4/classic/compat.d.cts": "e27451b24234dfed45f6cf22112a04955183a99c42a2691fb4936d63cfe42761", - ".opencode/node_modules/zod/v4/classic/compat.d.ts": "2197ba08e54182521b3cf4fec0a875a9ff76235849875a42acd19f7e8a0c09b8", - ".opencode/node_modules/zod/v4/classic/compat.js": "0e948b101771cc0e7b753e54b3f5b486929d3db9bd4c09d98fe4ce86271bcf7b", - ".opencode/node_modules/zod/v4/classic/errors.cjs": "f704ed9292288d685a33123242399179eba77f1190299bc83042b434c1ae8cd6", - ".opencode/node_modules/zod/v4/classic/errors.d.cts": "6042774c61ece4ba77b3bf375f15942eb054675b7957882a00c22c0e4fe5865c", - ".opencode/node_modules/zod/v4/classic/errors.d.ts": "03be953961283699e72d526e1bff478be5af0840094b7e37d90a15ffd1426f64", - ".opencode/node_modules/zod/v4/classic/errors.js": "fd807f79ded4f88928ab8205de1922901a4d3a8c9b93d072e1ab8c0a44a95df5", - ".opencode/node_modules/zod/v4/classic/external.cjs": "9df0ca0fe9053f1fb7cda1a7c8cc952c6d3d49fbec811fb3bb63d2a845bef211", - ".opencode/node_modules/zod/v4/classic/external.d.cts": "2fbc91ba70096f93f57e22d1f0af22b707dbb3f9f5692cc4f1200861d3b75d88", - ".opencode/node_modules/zod/v4/classic/external.d.ts": "8bae377629b666ce6e171351d3bf9cb1e041dc69409e54f84c366921384ae1cd", - ".opencode/node_modules/zod/v4/classic/external.js": "f4c3233385332f4fba37de6c0e38ccd8c7ba6fe315a58e9777571a5cfd852815", - ".opencode/node_modules/zod/v4/classic/index.cjs": "d58a1fea61e0f0bccdb2f4fd3b7b098adcfddb44f036302c4d2c4e173540a292", - ".opencode/node_modules/zod/v4/classic/index.d.cts": "d8bc0c5487582c6d887c32c92d8b4ffb23310146fcb1d82adf4b15c77f57c4ac", - ".opencode/node_modules/zod/v4/classic/index.d.ts": "d49030b9a324bab9bcf9f663a70298391b0f5a25328409174d86617512bf3037", - ".opencode/node_modules/zod/v4/classic/index.js": "d49030b9a324bab9bcf9f663a70298391b0f5a25328409174d86617512bf3037", - ".opencode/node_modules/zod/v4/classic/iso.cjs": "f5d840d941b87a868da63fba9e4eb5917cadd87100af4940113f0ddd75dd9043", - ".opencode/node_modules/zod/v4/classic/iso.d.cts": "58d65a2803c3b6629b0e18c8bf1bc883a686fcf0333230dd0151ab6e85b74307", - ".opencode/node_modules/zod/v4/classic/iso.d.ts": "50f3d54845c748597afd9fe6a474862ab957f61f274d5fcba6509f5e4174c988", - ".opencode/node_modules/zod/v4/classic/iso.js": "3cc5e6b4da086b8ffc7ee3188c69e4d3c945b368092e78d2299b5321182752c5", - ".opencode/node_modules/zod/v4/classic/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", - ".opencode/node_modules/zod/v4/classic/parse.cjs": "0fb97825ed1c15a1f6a238df9506e23f2e720edeae4774aa46a60ce7b2a75319", - ".opencode/node_modules/zod/v4/classic/parse.d.cts": "5a3bd57ed7a9d9afef74c75f77fce79ba3c786401af9810cdf45907c4e93f30e", - ".opencode/node_modules/zod/v4/classic/parse.d.ts": "07831f097149800006656666f2d1ea5d8e26caf8b40a2dfe951ed941eb2650ce", - ".opencode/node_modules/zod/v4/classic/parse.js": "14cbcc416ab9169abc2cde97af77da54ca769684eac1e03a3753c16ea4a7cd95", - ".opencode/node_modules/zod/v4/classic/schemas.cjs": "3b61b9192a59d6b28b3ea0c832a7e4a76315b92b71b432f497110ebc74193480", - ".opencode/node_modules/zod/v4/classic/schemas.d.cts": "8610f5dc475d74c4b095aafa0c191548bfd43f65802e6da54b5e526202b8cfe0", - ".opencode/node_modules/zod/v4/classic/schemas.d.ts": "0d6d068c3e5309ebef75a4d17eaf5af0b08debc3987f2f89e29f08e61ad26a4a", - ".opencode/node_modules/zod/v4/classic/schemas.js": "e185cecdafd77117e8081811ef006dd0bd478ebeef3a86b458a258d27b34eafe", - ".opencode/node_modules/zod/v4/core/api.cjs": "89921ccd93f2d360deb9c5ef52856cdf0d38fbca5d6b081079d45a40368d5aa0", - ".opencode/node_modules/zod/v4/core/api.d.cts": "56ccc6238510b913f5e6c21afdc447632873f76748d0b30a87cb313b42f1c196", - ".opencode/node_modules/zod/v4/core/api.d.ts": "2bfc055ab5b626d39e4b3e055fadf0622ad7e958316dccc9b1a52d3f40129f4e", - ".opencode/node_modules/zod/v4/core/api.js": "8722c715be582089faa0b96816db906a913697e8777ec247ed11bd2eed2e9fd7", - ".opencode/node_modules/zod/v4/core/checks.cjs": "1c60ee0c8eea5cdfd8439981ed87daa60a63cb5bbd25e2c3419f2ba1c7b65235", - ".opencode/node_modules/zod/v4/core/checks.d.cts": "8d67b13da77316a8a2fabc21d340866ddf8a4b99e76a6c951cc45189142df652", - ".opencode/node_modules/zod/v4/core/checks.d.ts": "3b22881e19fba860247cde1f60807cca7ce44ad78814fe5789316c3cc16208db", - ".opencode/node_modules/zod/v4/core/checks.js": "6d5342cbfc770541ef5f010c7ac534529012036c6482af1c3e57a1afb78c1828", - ".opencode/node_modules/zod/v4/core/core.cjs": "3238fe404d2e0e6e53d221ad0408567f31689c2e204db1007cfd2a4e0513587b", - ".opencode/node_modules/zod/v4/core/core.d.cts": "21360500b20e0ec570f26f1cbb388c155ede043698970f316969840da4f16465", - ".opencode/node_modules/zod/v4/core/core.d.ts": "f0ebd1c2e1708e7e7d105883430d3ee1d856560e26b346469ff584ec68859e9e", - ".opencode/node_modules/zod/v4/core/core.js": "a1f78ff62ae173c6a722b92b394075f3075c7f7d731bc443827aec4f30302d92", - ".opencode/node_modules/zod/v4/core/doc.cjs": "ae6c5bfe9570d30c119cd6bc9dff33f6956858b4fa6887b2b1f9680f11d1b65d", - ".opencode/node_modules/zod/v4/core/doc.d.cts": "add0ce7b77ba5b308492fa68f77f24d1ed1d9148534bdf05ac17c30763fc1a79", - ".opencode/node_modules/zod/v4/core/doc.d.ts": "add0ce7b77ba5b308492fa68f77f24d1ed1d9148534bdf05ac17c30763fc1a79", - ".opencode/node_modules/zod/v4/core/doc.js": "e084bbcc536746a8942fd33b08afe4db345554b3a0383114f1dca95261c958d9", - ".opencode/node_modules/zod/v4/core/errors.cjs": "f919e2356deaef1fd7555e3e15fb3cb8d7594dd8391ed9662b3a1ef6fcb5acda", - ".opencode/node_modules/zod/v4/core/errors.d.cts": "7952419455ca298776db0005b9b5b75571d484d526a29bfbdf041652213bce6f", - ".opencode/node_modules/zod/v4/core/errors.d.ts": "25dac9cd43c711463382917a7e8997d4a34871f864ba8ab0f53df85d03f90a38", - ".opencode/node_modules/zod/v4/core/errors.js": "66c310c429dd28309b17a5fe7310b99ae7e347fd502dc62ec628ebcc900c4e1b", - ".opencode/node_modules/zod/v4/core/index.cjs": "6f688ca5ed36ee7ca8c69f6169f0b06589b6698e743bb8427cf02150969159ea", - ".opencode/node_modules/zod/v4/core/index.d.cts": "d91805544905a40fbd639ba1b85f65dc13d6996a07034848d634aa9edb63479e", - ".opencode/node_modules/zod/v4/core/index.d.ts": "494aaa47240956231f7de5184f7f0828eaa15ba5c5f59fbda42da7b1348258ce", - ".opencode/node_modules/zod/v4/core/index.js": "494aaa47240956231f7de5184f7f0828eaa15ba5c5f59fbda42da7b1348258ce", - ".opencode/node_modules/zod/v4/core/json-schema.cjs": "d43aa81f5bc89faa359e0f97c814ba25155591ff078fbb9bfd40f8c7c9683230", - ".opencode/node_modules/zod/v4/core/json-schema.d.cts": "c1a2e05eb6d7ca8d7e4a7f4c93ccf0c2857e842a64c98eaee4d85841ee9855e6", - ".opencode/node_modules/zod/v4/core/json-schema.d.ts": "c1a2e05eb6d7ca8d7e4a7f4c93ccf0c2857e842a64c98eaee4d85841ee9855e6", - ".opencode/node_modules/zod/v4/core/json-schema.js": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", - ".opencode/node_modules/zod/v4/core/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", - ".opencode/node_modules/zod/v4/core/parse.cjs": "ea957a211bf66fd211842cfd2bcf4bea43aa27c643dc44da012ca63739672ef6", - ".opencode/node_modules/zod/v4/core/parse.d.cts": "3a819c2928ee06bbcc84e2797fd3558ae2ebb7e0ed8d87f71732fb2e2acc87b4", - ".opencode/node_modules/zod/v4/core/parse.d.ts": "7007626fc0d98e012e02caf70ae36647d7288b06c9121b51b20af592ebec3d53", - ".opencode/node_modules/zod/v4/core/parse.js": "a58b9c7be78e29d71f969f01807fe5f9be1f68d222a0f60c25e32e24f3b6639c", - ".opencode/node_modules/zod/v4/core/regexes.cjs": "06962f6496478a089281df50a84c288f703ac00ee8499fe62cb5f4d3e038b832", - ".opencode/node_modules/zod/v4/core/regexes.d.cts": "1765e61249cb44bf5064d42bfa06956455bbc74dc05f074d5727e8962592c920", - ".opencode/node_modules/zod/v4/core/regexes.d.ts": "1765e61249cb44bf5064d42bfa06956455bbc74dc05f074d5727e8962592c920", - ".opencode/node_modules/zod/v4/core/regexes.js": "f2aee057ccd1e081bbec2d1bf9d3ae57e3df72a13e056ff1cd48829158983fad", - ".opencode/node_modules/zod/v4/core/registries.cjs": "e58ac2fb52c7a0e48857787fc92b382afdd1adb6dd03736403cf38d7782677a2", - ".opencode/node_modules/zod/v4/core/registries.d.cts": "6e5857f38aa297a859cab4ec891408659218a5a2610cd317b6dcbef9979459cc", - ".opencode/node_modules/zod/v4/core/registries.d.ts": "7576d06c1b52d6e3dbd8ea3c53778b5a8d8065fbff3678db495c6166a698eda3", - ".opencode/node_modules/zod/v4/core/registries.js": "cec90d369f7206f701fa3f540bc92855a8c9145d44e4d8642053645e73f70002", - ".opencode/node_modules/zod/v4/core/schemas.cjs": "b9ea344ee137a4715c1c827ef133c5b2e344a5dbe1a19170a3de0bec296bafd7", - ".opencode/node_modules/zod/v4/core/schemas.d.cts": "d9faf4a343833207c6c5cd2322fb6771b56dc1c8ece975072e85227c2d326bc2", - ".opencode/node_modules/zod/v4/core/schemas.d.ts": "c74b22865b9e58e6756484d86cd06e58d25987fbd5c9b8fa95a658e4fce4f7cb", - ".opencode/node_modules/zod/v4/core/schemas.js": "73eb7d7474af05d191b86d654fc551fb7e0aca02ff87f6d56dc72cea9218b91f", - ".opencode/node_modules/zod/v4/core/standard-schema.cjs": "d43aa81f5bc89faa359e0f97c814ba25155591ff078fbb9bfd40f8c7c9683230", - ".opencode/node_modules/zod/v4/core/standard-schema.d.cts": "309ebd217636d68cf8784cbc3272c16fb94fb8e969e18b6fe88c35200340aef1", - ".opencode/node_modules/zod/v4/core/standard-schema.d.ts": "309ebd217636d68cf8784cbc3272c16fb94fb8e969e18b6fe88c35200340aef1", - ".opencode/node_modules/zod/v4/core/standard-schema.js": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", - ".opencode/node_modules/zod/v4/core/to-json-schema.cjs": "966cf5cd00087863083890715da106793c4773707924ef96469117f463371b0a", - ".opencode/node_modules/zod/v4/core/to-json-schema.d.cts": "85021a58f728318a9c83977a8a3a09196dcfc61345e0b8bbbb39422c1594f36b", - ".opencode/node_modules/zod/v4/core/to-json-schema.d.ts": "bdc0676fe9b2011992b6abccd1c15a565d2c201412f30c906697422f591d805f", - ".opencode/node_modules/zod/v4/core/to-json-schema.js": "c5974e08383d8de3daac020b49b5d62cda094231d6471bba1e1e1770ab635ce1", - ".opencode/node_modules/zod/v4/core/util.cjs": "6851a80c1cf0dd4859c7321d2d54cad3d3f658db1c8156a74a16807caa31ec1e", - ".opencode/node_modules/zod/v4/core/util.d.cts": "f987c74a4b4baf361afbf22a16d230ee490d662f9aa2066853bb7ebbb8611355", - ".opencode/node_modules/zod/v4/core/util.d.ts": "7062f2d8d0544f85f4c7398d2b08109e3a8c6fb1aae5b4b32b98567f5a9023fe", - ".opencode/node_modules/zod/v4/core/util.js": "d99bf396f177afe547703eff48b9746c360bfc2a2708adfd4a43e73768107b6c", - ".opencode/node_modules/zod/v4/core/versions.cjs": "1206ffbacc4e789fcd8f5d52566b1bfcc5baae49876c419f9bcd51a0e2d323b6", - ".opencode/node_modules/zod/v4/core/versions.d.cts": "1ff91526fcdd634148c655ef86e912a273ce6a0239e2505701561f086678262b", - ".opencode/node_modules/zod/v4/core/versions.d.ts": "1ff91526fcdd634148c655ef86e912a273ce6a0239e2505701561f086678262b", - ".opencode/node_modules/zod/v4/core/versions.js": "bfacecc9f831ae6cc9fc756424f94fae36d4ed196c4a4cd3a34507e38fbeec6c", - ".opencode/node_modules/zod/v4/index.cjs": "35934fbd99d4ca1d46932dfcddb6071c203b5c980862cf58e254998c49e1e5b8", - ".opencode/node_modules/zod/v4/index.d.cts": "8cb31102790372bebfd78dd56d6752913b0f3e2cefbeb08375acd9f5ba737155", - ".opencode/node_modules/zod/v4/index.d.ts": "a4b634bb8c97cc700dbf165f3bb0095ec669042da72eaf28a7c5e2ddd98169ce", - ".opencode/node_modules/zod/v4/index.js": "a4b634bb8c97cc700dbf165f3bb0095ec669042da72eaf28a7c5e2ddd98169ce", - ".opencode/node_modules/zod/v4/locales/ar.cjs": "25d2ec93b17667a2b8d02fdb170b275c853fefcecdcf7ac95b46877c7048a16e", - ".opencode/node_modules/zod/v4/locales/ar.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/ar.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/ar.js": "0c5613adab2e098069d88056df27f0bc361028d94d6af8a6dc46dda381d2d82e", - ".opencode/node_modules/zod/v4/locales/az.cjs": "06ba53956425e1718ce4c9d818a06c04bc999f8fd00f1981c7c74316d4e99f5b", - ".opencode/node_modules/zod/v4/locales/az.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/az.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/az.js": "cfb3ebe34580912dfb02fd3c22dcc80213f29d0ad99062f30c0cf56a9f511258", - ".opencode/node_modules/zod/v4/locales/be.cjs": "8269e9059c1575b909f559f39e49e2e226f1c6eb329c6f1c083c879cb234de09", - ".opencode/node_modules/zod/v4/locales/be.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/be.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/be.js": "b33d29e5b0f20b0520a8a05bbfcb1d96a43c205abac0f852d7e5e3eefc8450be", - ".opencode/node_modules/zod/v4/locales/bg.cjs": "3658e48af9e2858ad89158e6af06a188e57de14aa7cddc6a4903fbd08adcba40", - ".opencode/node_modules/zod/v4/locales/bg.d.cts": "26384fb401f582cae1234213c3dc75fdc80e3d728a0a1c55b405be8a0c6dddbe", - ".opencode/node_modules/zod/v4/locales/bg.d.ts": "3b25e966fd93475d8ca2834194ea78321d741a21ca9d1f606b25ec99c1bbc29a", - ".opencode/node_modules/zod/v4/locales/bg.js": "cdf9a0d1e081b22d62dc927a8ec1bcc850261bd7b56a8dc84d5e94a306ac0db9", - ".opencode/node_modules/zod/v4/locales/ca.cjs": "9adc2f9540839430681dcbf9b0bcc1b78b2d0a94dfc3eac170df43d9e5a577d1", - ".opencode/node_modules/zod/v4/locales/ca.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/ca.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/ca.js": "e3f8525c1ed6a8c1fa6ae9e2871e1a7bd2aec67c89764d51ef920553042337e3", - ".opencode/node_modules/zod/v4/locales/cs.cjs": "c42ea716c2809e37666fb31185718ffb62edb3e458996b30d2fa91df178a0643", - ".opencode/node_modules/zod/v4/locales/cs.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/cs.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/cs.js": "ca1527d68c849ff85e65b8697688719dca8df431724d8f3a64831439017fc5ef", - ".opencode/node_modules/zod/v4/locales/da.cjs": "6f090b1e6a8e61cafadfe9a8395e4fd0744c2d6a47129843afb40a0638064085", - ".opencode/node_modules/zod/v4/locales/da.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/da.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/da.js": "3187af9e566fdd251d09f3d73435a18025ae35ad3d46aa37272b7685a659ec3f", - ".opencode/node_modules/zod/v4/locales/de.cjs": "f08c911edf7e22b55c9bdef7235eaea4d4c36e6467744705652651cb140f7675", - ".opencode/node_modules/zod/v4/locales/de.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/de.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/de.js": "5b416bd06886b8eeedf0de4f162725636cf4b4af286a439fd4fca6bfae26b717", - ".opencode/node_modules/zod/v4/locales/en.cjs": "05a540ad5c222aab1cb15d99fc2c0984140c27f9b761501fc0641a2d582be63a", - ".opencode/node_modules/zod/v4/locales/en.d.cts": "26384fb401f582cae1234213c3dc75fdc80e3d728a0a1c55b405be8a0c6dddbe", - ".opencode/node_modules/zod/v4/locales/en.d.ts": "3b25e966fd93475d8ca2834194ea78321d741a21ca9d1f606b25ec99c1bbc29a", - ".opencode/node_modules/zod/v4/locales/en.js": "e9c8a8c547a75b2c7e87a22387c21e8ae0592f849c7ebd2c92df9d23c6fb9f44", - ".opencode/node_modules/zod/v4/locales/eo.cjs": "12df106b30a0f2e9d2c4256a5b39a29f7fd7d0f0e73ec573f06ddd8c1f3df898", - ".opencode/node_modules/zod/v4/locales/eo.d.cts": "26384fb401f582cae1234213c3dc75fdc80e3d728a0a1c55b405be8a0c6dddbe", - ".opencode/node_modules/zod/v4/locales/eo.d.ts": "3b25e966fd93475d8ca2834194ea78321d741a21ca9d1f606b25ec99c1bbc29a", - ".opencode/node_modules/zod/v4/locales/eo.js": "bbe19901c076716e9fa04400aa7020d4f15c7e574d31b5c351132f3ffc75ca55", - ".opencode/node_modules/zod/v4/locales/es.cjs": "95aee7c4ee66d1b4b8d36b714175ef58fbd1eb32ee0f28b3e88a066ef35d9248", - ".opencode/node_modules/zod/v4/locales/es.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/es.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/es.js": "d8c2c16a0bfa3159692d76bcba304f21197e3daffffbb1c5bd95ae0b347e6fc9", - ".opencode/node_modules/zod/v4/locales/fa.cjs": "5008aabcc546f9175ef10109475d32266e42d608c4dee9ca8d098f3a960d2560", - ".opencode/node_modules/zod/v4/locales/fa.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/fa.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/fa.js": "a7e02bbae31de8be066017ace75dc6651d22a7a9cb8fe98e0a6075d027a902db", - ".opencode/node_modules/zod/v4/locales/fi.cjs": "9dfc1de66f375928dc558cb705e93c0b9a834a3187de88e2b28dea536582e648", - ".opencode/node_modules/zod/v4/locales/fi.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/fi.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/fi.js": "6e4fe271feac955c71c8597a47c4467191e8146ad548d8d3dcd9564e6b5a86cd", - ".opencode/node_modules/zod/v4/locales/fr-CA.cjs": "510114ebc76a692a7bb944afacae441d577c8d0952444b1616c8575dfb63aa68", - ".opencode/node_modules/zod/v4/locales/fr-CA.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/fr-CA.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/fr-CA.js": "a2d5178451c0bd924d1e44cdbfa87109a3ababaf749eb1bc9db00039a2aa4cf4", - ".opencode/node_modules/zod/v4/locales/fr.cjs": "b251a10358ef0839ea36a945c23ab93905b91a55691821ba3e44ec69d0efe742", - ".opencode/node_modules/zod/v4/locales/fr.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/fr.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/fr.js": "e82a9f7655cd269e03fabfa04bb74096674dfec0ee61308c700eab6f2b7a9bb9", - ".opencode/node_modules/zod/v4/locales/he.cjs": "4214761eb524ab2024b7c3ed70b31dc02e7e3d4a9c32878423b61b11376cf5ea", - ".opencode/node_modules/zod/v4/locales/he.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/he.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/he.js": "015ddb02e660e6fd950f26432c6a9748c84f0ef2dbacd9ebf7b7563ab35abba0", - ".opencode/node_modules/zod/v4/locales/hu.cjs": "e1d62ddc5fb9c235248e6b1cf1c32917bafe53081304aeacd39f4ac555f255cb", - ".opencode/node_modules/zod/v4/locales/hu.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/hu.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/hu.js": "f5d7a779f7c4f2ea52bec6aca1c81f4c3a79924639727d2ca311a393ecfbb045", - ".opencode/node_modules/zod/v4/locales/id.cjs": "da98986abda8993196e484c5e5a33ba25c81cdf5b4ca16608dcfa65e6ef1249f", - ".opencode/node_modules/zod/v4/locales/id.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/id.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/id.js": "b67c815e4a8b91c178b610b5876083de40753f1811928e0a9682ff63223d88c6", - ".opencode/node_modules/zod/v4/locales/index.cjs": "203b86031d42ea980a2eebb40c26fd7778108a0f5de40d1caac5f15156d01885", - ".opencode/node_modules/zod/v4/locales/index.d.cts": "7b9e6b3c726d47935bdc9ebc78fe5398e28e751ba7d70e9e011f01fbd5b618be", - ".opencode/node_modules/zod/v4/locales/index.d.ts": "26965e789e579cd10968fb6ca81caa2ae50c16608185b16dbe84eb00a652b0cf", - ".opencode/node_modules/zod/v4/locales/index.js": "26965e789e579cd10968fb6ca81caa2ae50c16608185b16dbe84eb00a652b0cf", - ".opencode/node_modules/zod/v4/locales/is.cjs": "d37d44638830952defe01c248387522fc006645dccdd117d540fe2c1150c8313", - ".opencode/node_modules/zod/v4/locales/is.d.cts": "26384fb401f582cae1234213c3dc75fdc80e3d728a0a1c55b405be8a0c6dddbe", - ".opencode/node_modules/zod/v4/locales/is.d.ts": "3b25e966fd93475d8ca2834194ea78321d741a21ca9d1f606b25ec99c1bbc29a", - ".opencode/node_modules/zod/v4/locales/is.js": "703d8a1d77fb77192acf93fc4787f02d19d91d5bbd5a3aec21e7695b412cb9b2", - ".opencode/node_modules/zod/v4/locales/it.cjs": "fb2e0ab0d2eaf7d0ab5abd37bc00cbe24dd2564de984dc982ad0f33db4c0330a", - ".opencode/node_modules/zod/v4/locales/it.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/it.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/it.js": "5e444bce52d1505992a9803bf3abd30b91ccf5b022a2a3c21a9885daa2b687f1", - ".opencode/node_modules/zod/v4/locales/ja.cjs": "27ce4a1f40a7b777e905ac942d7a9c3f71f43cfbcf6366a1f416b6ee7467d5cf", - ".opencode/node_modules/zod/v4/locales/ja.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/ja.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/ja.js": "c7ecc379e0230066cc25a63597506521e68a76987b6c99f0b861d5240ce1e7d4", - ".opencode/node_modules/zod/v4/locales/ka.cjs": "12120b7997f17d4fad888d0907ebc34957a919df1053d18f7780512f2cc025fa", - ".opencode/node_modules/zod/v4/locales/ka.d.cts": "26384fb401f582cae1234213c3dc75fdc80e3d728a0a1c55b405be8a0c6dddbe", - ".opencode/node_modules/zod/v4/locales/ka.d.ts": "3b25e966fd93475d8ca2834194ea78321d741a21ca9d1f606b25ec99c1bbc29a", - ".opencode/node_modules/zod/v4/locales/ka.js": "90a891f22c612ad6eecf6975d58e7ce322b193e2fe3895cf8b7c800d9a2c8638", - ".opencode/node_modules/zod/v4/locales/kh.cjs": "5a5f3a63e4e97a25b44692b0910f73cf15e54fc5b03c61f9d87e2a9eb6e2a098", - ".opencode/node_modules/zod/v4/locales/kh.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/kh.d.ts": "e2a9a2ee24da53cf39abdc8ccb2c8b09e8b6b7c8f692448b90698d5208ed5e2d", - ".opencode/node_modules/zod/v4/locales/kh.js": "78c3d1991041bb8c8fc0f7c5d745aae422d4c2e4e8f30bae44f3fef4765900a8", - ".opencode/node_modules/zod/v4/locales/km.cjs": "d363ebe070cb240e3d3acc335c72bf21598329cd274f4f1e8738f45597c257d6", - ".opencode/node_modules/zod/v4/locales/km.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/km.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/km.js": "52059ef27113a2c5609014d8de482a6b7e644f1721691644f818ebc2aa50e6be", - ".opencode/node_modules/zod/v4/locales/ko.cjs": "6cbf291a630b59641d29d86cf01d7efa581675e838e6edb697b268c5ff024f2d", - ".opencode/node_modules/zod/v4/locales/ko.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/ko.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/ko.js": "f87cdcf6b3f89762d1331a670cbff687aa52d34beb7d0e0bcba9f9202f667308", - ".opencode/node_modules/zod/v4/locales/lt.cjs": "b900d411a793f16885c9a85d02eaf82fabd09f52621b4936b36ec3930ee61b44", - ".opencode/node_modules/zod/v4/locales/lt.d.cts": "26384fb401f582cae1234213c3dc75fdc80e3d728a0a1c55b405be8a0c6dddbe", - ".opencode/node_modules/zod/v4/locales/lt.d.ts": "3b25e966fd93475d8ca2834194ea78321d741a21ca9d1f606b25ec99c1bbc29a", - ".opencode/node_modules/zod/v4/locales/lt.js": "f3a346e1f39e0dead4d57ae412f6e40247efc5344de30a3326fbf0cbbd2b3647", - ".opencode/node_modules/zod/v4/locales/mk.cjs": "d0bff5ed169eeb21ce723024b291815a9ddbb6a7a415ab397f7d622a70419bb3", - ".opencode/node_modules/zod/v4/locales/mk.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/mk.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/mk.js": "dc64be99e35535aa7ffd648182dd7cfe3a51be08ad373d3bd12907b08acde24b", - ".opencode/node_modules/zod/v4/locales/ms.cjs": "fdf00d04224748328af8859affc695a1c0c4a110b763a75c6e028c0d924b5819", - ".opencode/node_modules/zod/v4/locales/ms.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/ms.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/ms.js": "5bd7da46aa238951a3f65dfd99d658582005dbb6b6b5586866fc4123fe9da582", - ".opencode/node_modules/zod/v4/locales/nl.cjs": "c53dfc2b6d22aadf40ab20100b6dffbbb6089446ab1c6b0560d0ed1d8f50b236", - ".opencode/node_modules/zod/v4/locales/nl.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/nl.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/nl.js": "b3bc897777d2ab5435c08dc65a75ec3e29f168b7e3fd70cbddd52a4960471712", - ".opencode/node_modules/zod/v4/locales/no.cjs": "4aeaa653cc2c45c2e9a860111bc6d58a9ebe8476b14e90dbbf7f1867128e0e48", - ".opencode/node_modules/zod/v4/locales/no.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/no.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/no.js": "c38a1e0b0cb4398fdabbffdbd1dd59a176535372f6af0226cfd6a600914f918a", - ".opencode/node_modules/zod/v4/locales/ota.cjs": "bfcf6b4ee261e84ac2a3083b6d5b9afb90b63a2d1218075f1edcabf8bad63332", - ".opencode/node_modules/zod/v4/locales/ota.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/ota.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/ota.js": "1a52c600e988011d30a150fac78176323a3ddf9d93db44cc13c665bca2ddc297", - ".opencode/node_modules/zod/v4/locales/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", - ".opencode/node_modules/zod/v4/locales/pl.cjs": "bbe265c24c400f5b889a4e3cb2e0b67d9bb722c825e646ebbc2e3c7a9a892981", - ".opencode/node_modules/zod/v4/locales/pl.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/pl.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/pl.js": "482246e5da9d86498189e027bcc7e034b9c729dc07a875f78de3ec5ee47f0fc9", - ".opencode/node_modules/zod/v4/locales/ps.cjs": "86cc84fc9ce3b849207baada9ffe6b6426f21c02febd2e754c8f57e37e3103d6", - ".opencode/node_modules/zod/v4/locales/ps.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/ps.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/ps.js": "29f11ca76393fc30b3f7aa0af116bd29a3caf07b09e3113bb16bd0b443339552", - ".opencode/node_modules/zod/v4/locales/pt.cjs": "9e28743eea308d73f4671b3f780cb64bbb9a0f89682a82c55110dee5b43e56b4", - ".opencode/node_modules/zod/v4/locales/pt.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/pt.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/pt.js": "d9a033407c895b3cb239d7ae7c7946b7487a5c7c5988e4523fe338566190ac9d", - ".opencode/node_modules/zod/v4/locales/ru.cjs": "6f4e0e348f2c8eca9f8311bc829f8d3ff3c7a18f6b47139fb80b9b61ccfae31c", - ".opencode/node_modules/zod/v4/locales/ru.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/ru.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/ru.js": "12ca3536916e5b947e920e29cd48fba0b1daf0712eff575b9ff43b1f15a4ed47", - ".opencode/node_modules/zod/v4/locales/sl.cjs": "489fce4f844fe664c259836e31f9409dd601c908d373b99631fce26394107aa3", - ".opencode/node_modules/zod/v4/locales/sl.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/sl.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/sl.js": "a6513b3ae705f46deaf3adaf6e7d38642ccb2b8c9844e8d8086efb4d33876007", - ".opencode/node_modules/zod/v4/locales/sv.cjs": "5d918fda16df1c5e16a3b17c74fafb9b35e5f76f44adb93d51fe062f6319fb2a", - ".opencode/node_modules/zod/v4/locales/sv.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/sv.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/sv.js": "132cb1f90932fd7f687a1f7b6f050a589c9e25dab5f78078d1a60bc981d91a1d", - ".opencode/node_modules/zod/v4/locales/ta.cjs": "3c8c82fb7c9c4019a3e642f61e22cac12c77fed7167aa5878d3889e39725da58", - ".opencode/node_modules/zod/v4/locales/ta.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/ta.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/ta.js": "8ceaba6e203b9fc6ff2ef8062f5fe0677c38a06aee0e2eaee4f5c6b6d4c92bad", - ".opencode/node_modules/zod/v4/locales/th.cjs": "d12932c74180558fbc0a049fedf3c7f59a52bf539826dd1740da7dc568cdd2c9", - ".opencode/node_modules/zod/v4/locales/th.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/th.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/th.js": "0c219854cfd59b9c7fac532130c2d8a3c344a493dd3c361b60d2fadea4afa45b", - ".opencode/node_modules/zod/v4/locales/tr.cjs": "28ca52e08a6f4a9433d6302fd2e075b4173ba71fb3ad89c9c6772889d559dad8", - ".opencode/node_modules/zod/v4/locales/tr.d.cts": "26384fb401f582cae1234213c3dc75fdc80e3d728a0a1c55b405be8a0c6dddbe", - ".opencode/node_modules/zod/v4/locales/tr.d.ts": "3b25e966fd93475d8ca2834194ea78321d741a21ca9d1f606b25ec99c1bbc29a", - ".opencode/node_modules/zod/v4/locales/tr.js": "5899564068c44d6321135e441c8841a0c87fcee7f1c2ffb6e075c81b861a5963", - ".opencode/node_modules/zod/v4/locales/ua.cjs": "0565126e33d30fd26046fd5399fe6293e8827fa846c3a7ff414e4e63acbfbf0a", - ".opencode/node_modules/zod/v4/locales/ua.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/ua.d.ts": "6484b25a0de66353ff65d498318289e1ee89515bd6105c73d64dddfff8835699", - ".opencode/node_modules/zod/v4/locales/ua.js": "9391169ffbc72f777a83230b6ffcc3f4cf46ab5945ec540de9ef4609dca1f4cc", - ".opencode/node_modules/zod/v4/locales/uk.cjs": "9d72ea5a461879fe16821cafffdb365d0b4a22ec7c08200d2b4e289bd2443426", - ".opencode/node_modules/zod/v4/locales/uk.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/uk.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/uk.js": "67a00c4c9a06d6a9c84c5d598230d28a9be97eeb023c455c4300bcbb67f85baf", - ".opencode/node_modules/zod/v4/locales/ur.cjs": "c5e01648e76fec946d1b6694ce400208171269a7de767d95a99d76299f51c30e", - ".opencode/node_modules/zod/v4/locales/ur.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/ur.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/ur.js": "673c79cb79ea713c7db86a20d69e8e7276bbc5aa9877c1b9f1230e309aa30ac8", - ".opencode/node_modules/zod/v4/locales/vi.cjs": "8bf4ad084e0cdb1b63dae321139a2704b52392f219c832cee61edf104a0a90e5", - ".opencode/node_modules/zod/v4/locales/vi.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/vi.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/vi.js": "669dc347036da69f481fd43aaf60c24badb4ff52906aa59ea86735028274ac5c", - ".opencode/node_modules/zod/v4/locales/yo.cjs": "5d6308e49a6797df8e4874d35e5368beb565a2d98dd1912837db200fbeb6d29d", - ".opencode/node_modules/zod/v4/locales/yo.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/yo.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/yo.js": "48269d5416f0a497c777ae28f1724263e2051ff69134fcc307c57faabbc3b470", - ".opencode/node_modules/zod/v4/locales/zh-CN.cjs": "e1d86c2f7f1cd5dfec5182b00b6a6db4d788f3898a32fe58e330b256840ed715", - ".opencode/node_modules/zod/v4/locales/zh-CN.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/zh-CN.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/zh-CN.js": "88dd19ebe066ad328ce315b9da17d8c6dbe76a44bda9b48128809d864392637a", - ".opencode/node_modules/zod/v4/locales/zh-TW.cjs": "7149854c4ea48287a43219f14f1cf0638ff6fc1257359d6726e7c9c062d1e449", - ".opencode/node_modules/zod/v4/locales/zh-TW.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/zh-TW.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/zh-TW.js": "40d3639fd0b600c2eea303d359f36dcb34ccaceccbb27c00fabfd1d6d5761415", - ".opencode/node_modules/zod/v4/mini/checks.cjs": "751987cbe8e6346d30c25dc3ca04643e3ab7301a83cf31d0ee0ecf4dfb8b9149", - ".opencode/node_modules/zod/v4/mini/checks.d.cts": "ffcc1e3d8a6c4a3dcc6291d1f559f6853d2cbdb3df8c7e57eb99059c90d89925", - ".opencode/node_modules/zod/v4/mini/checks.d.ts": "3fbf0aa59f99b6e4d640cd2b062cff9537530af3f5bfed4f862a7f18a9caa839", - ".opencode/node_modules/zod/v4/mini/checks.js": "3fbf0aa59f99b6e4d640cd2b062cff9537530af3f5bfed4f862a7f18a9caa839", - ".opencode/node_modules/zod/v4/mini/coerce.cjs": "6dca70ae45d9a876d730898fada9316a066944d6dfd90d92c43fb10c6bb6b2b2", - ".opencode/node_modules/zod/v4/mini/coerce.d.cts": "d50434c282f9e0cfe9d728f6e7a12988bfc6de7be655f40ad44d6bee6f9fe9a3", - ".opencode/node_modules/zod/v4/mini/coerce.d.ts": "ddbf61f8fb60c82085677c1ea3393c534f3885f61ff7ebb4559575a03f82a4cf", - ".opencode/node_modules/zod/v4/mini/coerce.js": "ef3cb07ee9217997976afe9e2639d63eb4b926ef58a5fb9c24ac80855e529a93", - ".opencode/node_modules/zod/v4/mini/external.cjs": "05c2bb69db0d26b115d9e146b85831c52f2341d23a40f8872f873af089e40661", - ".opencode/node_modules/zod/v4/mini/external.d.cts": "48a578dc7819fe791ca85d2aa6eea01122c0f7c460a6def56ca37fbb6cab3378", - ".opencode/node_modules/zod/v4/mini/external.d.ts": "305cb5e50a3e4e49a2004e59c17fce5caa25c8d22ec340b0e762b23164c5a641", - ".opencode/node_modules/zod/v4/mini/external.js": "d56f0c9c15524cd7dc217b912f68c589863fa515401c315982f06cac917c8727", - ".opencode/node_modules/zod/v4/mini/index.cjs": "2a1c77f9c68bd47cd079ea601248dad70025528bc1062c2c657444000edabcac", - ".opencode/node_modules/zod/v4/mini/index.d.cts": "0f29e474ba3555dd52ad61783dc6277683dd19c36473589d6fe2d29e06d99f5a", - ".opencode/node_modules/zod/v4/mini/index.d.ts": "455d93e660b61feec18cd4c31e2f394627546ae8ab31f9bacdab6318112ac31c", - ".opencode/node_modules/zod/v4/mini/index.js": "455d93e660b61feec18cd4c31e2f394627546ae8ab31f9bacdab6318112ac31c", - ".opencode/node_modules/zod/v4/mini/iso.cjs": "b461dc1b91755b88cd22a50b54d1ab8b515357343bb20c7b7e68e1c2ea2b5ba8", - ".opencode/node_modules/zod/v4/mini/iso.d.cts": "17a9a3fe407d0e6bfc00f7e480e6090efd455cd1ce4204572543d0492d17a721", - ".opencode/node_modules/zod/v4/mini/iso.d.ts": "3f2f3b037cd2ba39395aa6628fb616f4642ae8cbab3b0f9ef786213745538658", - ".opencode/node_modules/zod/v4/mini/iso.js": "1829996a8bbebfce7e4e6ca384e3ef6aeb2d88cda5ae7f7eeec24904ba688cef", - ".opencode/node_modules/zod/v4/mini/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", - ".opencode/node_modules/zod/v4/mini/parse.cjs": "3a1db8a15f2d19426e3a774543a1988e11d112c1e345a51b038d33eeaff573ac", - ".opencode/node_modules/zod/v4/mini/parse.d.cts": "18fe15c9645cea0541cb0664b82fdd03069cb33544989ad87e0091dc70cae250", - ".opencode/node_modules/zod/v4/mini/parse.d.ts": "db605a698f917c4006462596e6526fe6a9c6eff9b8409e8472fe545d4b70027c", - ".opencode/node_modules/zod/v4/mini/parse.js": "db605a698f917c4006462596e6526fe6a9c6eff9b8409e8472fe545d4b70027c", - ".opencode/node_modules/zod/v4/mini/schemas.cjs": "6ff53249f8d550e1559d99e37c6107b83a69b33302a57c299f078769db7d7952", - ".opencode/node_modules/zod/v4/mini/schemas.d.cts": "3a80a0f1075f9eac86dc6555ca4409535b74cfe1eb803c566c252014cff13912", - ".opencode/node_modules/zod/v4/mini/schemas.d.ts": "ec8e232365211878d47e3db95701346e8d3f1aea5c7049c6e84b3ca1be0d934b", - ".opencode/node_modules/zod/v4/mini/schemas.js": "d79e807c5eb58cfcb80597b7a38270862b1af876721baf8556210c9a27cb82cc", - ".opencode/node_modules/zod/v4/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", - ".opencode/node_modules/zod/v4-mini/index.cjs": "c3ef916ed5e1bb397f04352f010f97961e98f610c13ecc05c25a384ef0e5a5a2", - ".opencode/node_modules/zod/v4-mini/index.d.cts": "d34c4532b0004150342d04ab1a6f61d19751c4fc7c465c72ec582b180b0904c0", - ".opencode/node_modules/zod/v4-mini/index.d.ts": "cf94ca2939d655e17fdfd1d0a3aceb4cbeb040d8946e9c9c1eaa5cc734dc69c8", - ".opencode/node_modules/zod/v4-mini/index.js": "cf94ca2939d655e17fdfd1d0a3aceb4cbeb040d8946e9c9c1eaa5cc734dc69c8", - ".opencode/node_modules/zod/v4-mini/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", - ".opencode/opencode.json": "dcc34cd8e53f5df784c7c50970c830f598b7637c1b824d9089cd70c59389a797", - ".opencode/opencode.json.old": "245a87dd300b32ba610c3415fbbbcf4f6d902aac340e24107dd6e5ebbc2dded8", - ".opencode/package.json": "6867538b2f6025286a70c943d15db4191d2412daa8bf7431a846b00c863f26b8", - ".opencode/plugin/inject-subagent-context.js": "6a1257d7a2e485d3de3b0e34c40ab285dfe48e976d442288423eab23d2a0e717", - ".opencode/plugin/session-start.js": "ce013e3b329b954cd1317fc69d804ad69f6524ff73531b29bac8d67e6a4d8d81", - ".agents/skills/ui-ux-pro-max/SKILL.md": "9bd26c52dfd10dfd613dd3cc85df217dc25a172e380bde41fdfa6fedda288b2a", - ".agents/skills/ui-ux-pro-max/data/_sync_all.py": "b540b3a4f87598ae29048b3cceae1fc17b39b629aee2cbe5c802ffc7b48abf6d", - ".agents/skills/ui-ux-pro-max/data/app-interface.csv": "2a17ef810dab715ce1f339861817a8fbe3ccc38142b70517301e874803e838ac", - ".agents/skills/ui-ux-pro-max/data/charts.csv": "ebb565308115f955791b0431797a89d9b3587c25d5babb9428d10712c4924817", - ".agents/skills/ui-ux-pro-max/data/colors.csv": "69ee8c1147b269599d20ca418bdd6f32563a9aa2d962fb9a9b8dec66bf7b1ba4", - ".agents/skills/ui-ux-pro-max/data/design.csv": "6f0ae42f16b3cbfa3f07050268387557859ff666e2651b1b82763f099d724b3f", - ".agents/skills/ui-ux-pro-max/data/draft.csv": "e190c796b707858a5436dc4c27f9ee9bb6618014ca73f533e423abe5ce9e4d06", - ".agents/skills/ui-ux-pro-max/data/google-fonts.csv": "2c03a3cd134d126bd9d6a7dc2a6360dc5272219ad6df3eb9315b031f806e1487", - ".agents/skills/ui-ux-pro-max/data/icons.csv": "f376c29fb4df37b4bdb366a5aa70cb211ba3dd8b435390aaa03152a64b07d2e8", - ".agents/skills/ui-ux-pro-max/data/landing.csv": "080cedbcd61ff8ec9520f33929baa76bee9589e783f83b2f8d824a466b6a46d7", - ".agents/skills/ui-ux-pro-max/data/products.csv": "9fd9e776ba847cf44c1ea78f95fe5e33b2c56bb7e186e3cfff9c49bc7fcb691b", - ".agents/skills/ui-ux-pro-max/data/react-performance.csv": "904c8afcda229629545912dde0e8ac37503757131f0169f80b016f1f58c4fd3f", - ".agents/skills/ui-ux-pro-max/data/stacks/react-native.csv": "a08ca77fcf6b6d9531982dce465366296013bfcf12d2938ac72ad57cf0c4f085", - ".agents/skills/ui-ux-pro-max/data/styles.csv": "9b5089dcde8999333b36878252a255cc3bacbb2fe7b836c76cc7f7aa2abb643d", - ".agents/skills/ui-ux-pro-max/data/typography.csv": "dbea262a54e3bfa2e6c3b15989a365d5ef4c43349316aff46635e82ca825adce", - ".agents/skills/ui-ux-pro-max/data/ui-reasoning.csv": "41976082ecae1100da937c949215dc6694393e03f3c2a7444dd92a9edb43cb11", - ".agents/skills/ui-ux-pro-max/data/ux-guidelines.csv": "1870ee048f2a2bdd60709f8f7adf7f3b6dcad560bc005c8b2915a8ac8639820d", - ".agents/skills/ui-ux-pro-max/scripts/__pycache__/core.cpython-313.pyc": "6fbe6d7129ee4b3055472e4f8d6417229fd160ed764e94935b9b9c1b0fe1a41f", - ".agents/skills/ui-ux-pro-max/scripts/__pycache__/design_system.cpython-313.pyc": "225b14b080c8fec56ee3ba08904d7fccae04607e2806a688b368d31f7c66a869", - ".agents/skills/ui-ux-pro-max/scripts/core.py": "18e00b1a2952fb919dcba0010ee71f75129a670ad565e8d0907958d6be8caeca", - ".agents/skills/ui-ux-pro-max/scripts/design_system.py": "4da1d341f3c7749df51b51db4a543a48a427c3c746eb0e9882a1ab86acf3bb54", - ".agents/skills/ui-ux-pro-max/scripts/search.py": "18b1efa4ee5a2fc1cf14d7b25429ab423ef6026d123878fb93c5884f33cd10db" + "__version": 2, + "hashes": { + ".trellis/scripts/common/active_task.py": "6c88ed40ef7289bca0f6d2ecba0f8b8aef46cd58788080fbeeea88de138a431f", + ".trellis/scripts/common/io.py": "6480b181f2bc505323b28ed7a66963d7b7edc96251e83b4c8e7a45907cc721c8", + ".trellis/scripts/common/log.py": "471df6895cfac80f995edebbf9974f6b7440634b7a688f28b8331c868bc0f3cf", + ".trellis/scripts/common/git.py": "e14817be7de122d3a106f509c2825aeb9669d962ba73ba241642d2931cfdf1d6", + ".trellis/scripts/common/types.py": "9962081cc2608fb9d1deb32c6880e336f62cdca6b338e7ae813304701e155ee9", + ".trellis/scripts/common/tasks.py": "4436a8b0b53c270a35989e26d9dbd92669408c6562d88c02083a404562da85fe", + ".trellis/scripts/common/task_context.py": "1c16a7fa82d363010d0d0ebdc038296ae1552bf6e90214787d707f49567bc159", + ".trellis/scripts/common/task_store.py": "e3802a822dcd0a8005bac6ffe50af196944c2a42d56d70e68c622aaf9b1156e2", + ".trellis/scripts/common/session_context.py": "02e8d8209c2b88efeb83c9f1a3edf20d6344ceda5fcb9da53a3eb24fbfb47b17", + ".trellis/scripts/common/packages_context.py": "efe158d7c99c2268851d0216fbb08de22836e418a8dbeb73575b8cc249eed7b7", + ".trellis/scripts/common/workflow_phase.py": "3ca97e634b53a428206b04f87eba1700d4b2063cf367ee276ab0b1849994b81d", + ".claude/commands/trellis/continue.md": "0683093a150d189af02c5de33afe01ebb740e940f9325f7ba7390906895661a1", + ".claude/commands/trellis/finish-work.md": "d6aa570ab684f57e4845de2d84a1ff6d9f0908e04c5a56e14fd70ae739c369fc", + ".claude/skills/trellis-before-dev/SKILL.md": "208ad3fd5131fa0da603d4bc354a29826967397f5aeef483fa0564113df13e9e", + ".claude/skills/trellis-brainstorm/SKILL.md": "a5fe1fbd16221c52c259b766044ab62dd43dfce2fa18d8c1f3fff4e27658cc34", + ".claude/skills/trellis-break-loop/SKILL.md": "35afb53fef42cd494e566f1ef170dbf442ec2be7e19931f28a14079b4dda753f", + ".claude/skills/trellis-check/SKILL.md": "a3f17aef687aa3b475d12ee64c3293e5491bb7474336be2c0f9ec22042f13b6e", + ".claude/skills/trellis-update-spec/SKILL.md": "d975db7af166578488958751ae2c56edb827a68bddb569aa27acc3453f64e610", + ".claude/skills/trellis-meta/references/customize-local/add-project-local-conventions.md": "86009ccb5d0373f399582da0bc570c4e5c6053c3c764857424ff93384f0e04e5", + ".claude/skills/trellis-meta/references/customize-local/change-agents.md": "7f2982162463f107f8b1a4fa1a41fee2bc7dbd0cc8e90c48559aba30c3ea403c", + ".claude/skills/trellis-meta/references/customize-local/change-context-loading.md": "c7be53e038eac99ff4d0abb3fafc67cfdfd90352744ce09ad11e7ef5085cf933", + ".claude/skills/trellis-meta/references/customize-local/change-hooks.md": "91892f2cff53ae003736007e95172945acabf48b2fc889bd627cd2406ce449c4", + ".claude/skills/trellis-meta/references/customize-local/change-skills-or-commands.md": "b3009ef20a4f24e5d8b196109dc9bab6bd30fc030dbc4fb796afdd2ca912e1ea", + ".claude/skills/trellis-meta/references/customize-local/change-spec-structure.md": "a3fc9da294448b4a3525ae1ab7c8c9b895ef8f3b53bcf37a13ce6afbdc040fe6", + ".claude/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "60ff9efb93604b87a461a4af30322d76750402a51e40f31531a7ff88d309996d", + ".claude/skills/trellis-meta/references/customize-local/change-workflow.md": "6f1707a2cc032c50e41e5624cef46071dd53dc9810bc6b3cae66d86508dea1cb", + ".claude/skills/trellis-meta/references/customize-local/overview.md": "1a406c24b4c5737cf517ead5ecb0846c20b0648a117008d3f5a47614fc1793ca", + ".claude/skills/trellis-meta/references/local-architecture/context-injection.md": "31286b9c05e600db7d179100eca533f9b8a4aab3a9c255cb69e8dccacb4e8375", + ".claude/skills/trellis-meta/references/local-architecture/generated-files.md": "4356517517cef0ba7f3ba01965a4ba8953505702e4085f0797d3e36817c9669f", + ".claude/skills/trellis-meta/references/local-architecture/overview.md": "45ffd4ee95020f58201adc885f3dfc89b26483c2b350d96ca7f2f57f94d5ff5f", + ".claude/skills/trellis-meta/references/local-architecture/spec-system.md": "8996504201e2a5460516948fe29c4ce7ed6b960f15da8c514855d2d467046985", + ".claude/skills/trellis-meta/references/local-architecture/task-system.md": "a6f905e53fe5a7fa5be8a4bbe1d102bd90b69c6e63a1a3be51ac3f7f164f1d21", + ".claude/skills/trellis-meta/references/local-architecture/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6", + ".claude/skills/trellis-meta/references/local-architecture/workspace-memory.md": "e6427b46aba744563c2444b30df4043cd856561b7709ec2dece26095416421fd", + ".claude/skills/trellis-meta/references/platform-files/agents.md": "ffee78fc3c29114c7409ca4805ebd51d44d8a6acb630e9f73d47013e10db5ac5", + ".claude/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "6e2d6d88719c2779fe34004f63d36cff203d8f64e7fb620f7cb1cde15c37c462", + ".claude/skills/trellis-meta/references/platform-files/overview.md": "6479cd2393166b4b369b511c44b78cbc64975c8b1df96ee1d4d1bd06b75cd48d", + ".claude/skills/trellis-meta/references/platform-files/platform-map.md": "ded6751c06f31d0a701d33c9dd69c482a583539ad3ed464aaad9e705f793b212", + ".claude/skills/trellis-meta/references/platform-files/skills-and-commands.md": "85435eb8bb6921283575bca51268fc534c22fd3ca33782e841ee5c76140ae48f", + ".claude/skills/trellis-meta/SKILL.md": "942e898a6fd769a93a3ca6f43f9fe0412d0adae011654fd384e9cacbd2af4f34", + ".claude/agents/trellis-check.md": "bfef8b996ae19a23fc8a71b63cd0e5d1cbc9f37fb6e879c2cbb3724448872e70", + ".claude/agents/trellis-implement.md": "f2e9f4cf54a3a2a554688b88627dcb946610c92ea9d9af6cf0bab64d132f3b8e", + ".claude/agents/trellis-research.md": "f82244b2a88a1f77b09813f58a7fdf506f8e9603a5c34b3abe6e170f73ab68a8", + ".claude/hooks/inject-subagent-context.py": "6b44a4043cf12a87b1cc34f682b33ea2f8e16a2bc3a9998a42d62e6e8672569e", + ".claude/hooks/inject-workflow-state.py": "0684fb17d0d42b36d1549e9bc0a905d4c06f714e2b2008d74d9ab0d2c1c2b626", + ".claude/hooks/session-start.py": "e8b84b8651df9039e8292cb9ce1a87ce07bb2405b46b80617b7b99b07856dfed", + ".claude/settings.json": "4defe9eae17b3101dae357179503683d868859e570931a7d3181e4ab10d82a9f", + ".trellis/scripts/common/__init__.py": "3d5e9347141f0296319a5beb29d69ae714c5a474b9078caeb3edd7c5f6562e22", + ".trellis/scripts/common/paths.py": "05898ef136cc7c4d861b05fbf2b16d53ddd3e6f311a231d4fcfcb81bde7c45ee", + ".trellis/scripts/common/developer.py": "f5f833123abe68890171b4da825a324216d24913f6b5ad9245afc556424ffd7b", + ".trellis/scripts/common/git_context.py": "7533c08335791e50c3a6f9d551d5b1af0bdaa2a0a746721cb3e1a2140f4d9683", + ".trellis/scripts/common/task_queue.py": "0be61f713462b1fe4574927c82fc4704e678afe72dcb9813543aedf2f9e9e0c5", + ".trellis/scripts/common/task_utils.py": "f5ef4af87ba3e11d8b19630c0c96d009de1811fc9be56c2027a9c96e21ed103e", + ".trellis/scripts/common/cli_adapter.py": "cd844d1e84b1a09b373b3a7609e4d5606ee9d4825154c002cc9bb3f54c8e2fb9", + ".trellis/scripts/common/config.py": "671a3591f97b75ec19f25814d2ee3f7e9b38e048f6f67442519fe0715c454eeb", + ".trellis/scripts/task.py": "a07b33b519037aadabce00ea1a3d2bd309bcca881244d43c72fb50e51322e9ab", + ".trellis/scripts/add_session.py": "a97a6c88ff7def8045a5dffa5c698a823392d7f73c1641e8a0c08db0168bd913", + ".trellis/config.yaml": "c3c4af7d82c09a1638f63c1f560119507735b060a4780ef7e6d0cdef447c215d", + ".trellis/.gitignore": "3bc42434cbe61c937a1c26ea3c880b496bfad4e7d683750169e27ade509aa798", + ".trellis/workflow.md": "d407beb736c69673c91dae69197f8db45d410f1b80e93a9b48277d35289dc595" + } } \ No newline at end of file diff --git a/.trellis/.version b/.trellis/.version index 81de5c5..7bc8de8 100644 --- a/.trellis/.version +++ b/.trellis/.version @@ -1 +1 @@ -0.3.10 \ No newline at end of file +0.5.0-rc.6 \ No newline at end of file diff --git a/.trellis/config.yaml b/.trellis/config.yaml index 7d18551..8e7fcc7 100644 --- a/.trellis/config.yaml +++ b/.trellis/config.yaml @@ -31,3 +31,29 @@ max_journal_lines: 2000 # - "echo 'Task finished'" # after_archive: # - "echo 'Task archived'" + +#------------------------------------------------------------------------------- +# Monorepo / Packages +#------------------------------------------------------------------------------- + +# Declare packages for monorepo projects. +# Trellis auto-detects workspaces during `trellis init`, but you can also +# configure them manually here. +# +# packages: +# frontend: +# path: packages/frontend +# backend: +# path: packages/backend +# docs: +# path: docs-site +# type: submodule +# # For polyrepo / meta-repo layouts (independent .git in each subdir), +# # mark the package with `git: true`. The runtime treats it as an +# # independent repository for things like git-context display. +# webapp: +# path: ./webapp +# git: true + +# Default package used when --package is not specified. +# default_package: frontend diff --git a/.trellis/scripts/add_session.py b/.trellis/scripts/add_session.py index 71606e5..be2c005 100755 --- a/.trellis/scripts/add_session.py +++ b/.trellis/scripts/add_session.py @@ -4,8 +4,19 @@ Add a new session to journal file and update index.md. Usage: - python3 add_session.py --title "Title" --commit "hash" --summary "Summary" - echo "content" | python3 add_session.py --title "Title" --commit "hash" + python3 add_session.py --title "Title" --commit "hash" --summary "Summary" [--package cli] + python3 add_session.py --title "Title" --branch "feat/my-branch" + + # Pipe detailed content via stdin (use --stdin to opt in): + cat << 'EOF' | python3 add_session.py --stdin --title "Title" --summary "Summary" + <session content here> + EOF + +Branch resolution order: + 1. --branch CLI arg (explicit) + 2. task.json branch field (from active task) + 3. git branch --show-current (auto-detect) + 4. None (omitted gracefully) """ from __future__ import annotations @@ -20,11 +31,21 @@ from pathlib import Path from common.paths import ( FILE_JOURNAL_PREFIX, get_repo_root, + get_current_task, get_developer, get_workspace_dir, ) from common.developer import ensure_developer -from common.config import get_session_commit_message, get_max_journal_lines +from common.git import run_git +from common.tasks import load_task +from common.config import ( + get_packages, + get_session_commit_message, + get_max_journal_lines, + is_monorepo, + resolve_package, + validate_package, +) # ============================================================================= @@ -123,7 +144,9 @@ def generate_session_content( commit: str, summary: str, extra_content: str, - today: str + today: str, + package: str | None = None, + branch: str | None = None, ) -> str: """Generate session content.""" if commit and commit != "-": @@ -135,12 +158,15 @@ def generate_session_content( else: commit_table = "(No commits - planning session)" + package_line = f"\n**Package**: {package}" if package else "" + branch_line = f"\n**Branch**: `{branch}`" if branch else "" + return f""" ## Session {session_num}: {title} **Date**: {today} -**Task**: {title} +**Task**: {title}{package_line}{branch_line} ### Summary @@ -175,7 +201,8 @@ def update_index( commit: str, new_session: int, active_file: str, - today: str + today: str, + branch: str | None = None, ) -> bool: """Update index.md with new session info.""" # Format commit for display @@ -254,10 +281,25 @@ def update_index( continue if in_session_history: - new_lines.append(line) - if re.match(r"^\|\s*-", line) and not header_written: - new_lines.append(f"| {new_session} | {today} | {title} | {commit_display} |") + # Migrate old 4/6-column headers to 5-column Branch-only history. + if re.match( + r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*Base Branch\s*\|\s*$", + line, + ): + new_lines.append("| # | Date | Title | Commits | Branch |") + continue + if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*$", line): + new_lines.append("| # | Date | Title | Commits | Branch |") + continue + if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*$", line): + new_lines.append("| # | Date | Title | Commits | Branch |") + continue + if re.match(r"^\|[-| ]+\|\s*$", line) and not header_written: + new_lines.append("|---|------|-------|---------|--------|") + new_lines.append(f"| {new_session} | {today} | {title} | {commit_display} | `{branch or '-'}` |") header_written = True + continue + new_lines.append(line) continue new_lines.append(line) @@ -274,11 +316,16 @@ def update_index( def _auto_commit_workspace(repo_root: Path) -> None: """Stage .trellis/workspace and .trellis/tasks, then commit with a configured message.""" commit_msg = get_session_commit_message(repo_root) - subprocess.run( + add_result = subprocess.run( ["git", "add", "-A", ".trellis/workspace", ".trellis/tasks"], cwd=repo_root, capture_output=True, + text=True, ) + if add_result.returncode != 0: + print(f"[WARN] git add failed (exit {add_result.returncode}): {add_result.stderr.strip()}", file=sys.stderr) + print("[WARN] Please commit .trellis/ changes manually: git add .trellis && git commit", file=sys.stderr) + return # Check if there are staged changes result = subprocess.run( ["git", "diff", "--cached", "--quiet", "--", ".trellis/workspace", ".trellis/tasks"], @@ -305,6 +352,8 @@ def add_session( summary: str = "(Add summary)", extra_content: str = "(Add details)", auto_commit: bool = True, + package: str | None = None, + branch: str | None = None, ) -> int: """Add a new session.""" repo_root = get_repo_root() @@ -330,7 +379,8 @@ def add_session( new_session = current_session + 1 session_content = generate_session_content( - new_session, title, commit, summary, extra_content, today + new_session, title, commit, summary, extra_content, today, package, + branch, ) content_lines = len(session_content.splitlines()) @@ -367,7 +417,16 @@ def add_session( # Update index.md active_file = f"{FILE_JOURNAL_PREFIX}{target_num}.md" - if not update_index(index_file, dev_dir, title, commit, new_session, active_file, today): + if not update_index( + index_file, + dev_dir, + title, + commit, + new_session, + active_file, + today, + branch, + ): return 1 print("", file=sys.stderr) @@ -400,8 +459,12 @@ def main() -> int: parser.add_argument("--commit", default="-", help="Comma-separated commit hashes") parser.add_argument("--summary", default="(Add summary)", help="Brief summary") parser.add_argument("--content-file", help="Path to file with detailed content") + parser.add_argument("--package", help="Package name tag (e.g., cli, docs-site)") + parser.add_argument("--branch", help="Branch name (auto-detected if omitted)") parser.add_argument("--no-commit", action="store_true", help="Skip auto-commit of workspace changes") + parser.add_argument("--stdin", action="store_true", + help="Read extra content from stdin (explicit opt-in)") args = parser.parse_args() @@ -410,12 +473,47 @@ def main() -> int: content_path = Path(args.content_file) if content_path.is_file(): extra_content = content_path.read_text(encoding="utf-8") - elif not sys.stdin.isatty(): + elif args.stdin: extra_content = sys.stdin.read() + # Load active task once — shared by package and branch resolution + repo_root = get_repo_root() + current = get_current_task(repo_root) + task_data = load_task(repo_root / current) if current else None + + package = args.package + if package: + # CLI source: fail-fast in monorepo, ignore in single-repo + if not is_monorepo(repo_root): + print("Warning: --package ignored in single-repo project", file=sys.stderr) + package = None + elif not validate_package(package, repo_root): + packages = get_packages(repo_root) + available = ", ".join(sorted(packages.keys())) if packages else "(none)" + print(f"Error: unknown package '{package}'. Available: {available}", file=sys.stderr) + return 1 + else: + # Inferred: active task's task.json.package → default_package → None + task_package = task_data.package if task_data else None + package = resolve_package(task_package, repo_root) + + # Resolve branch: CLI → task.json → git auto-detect → None + branch = args.branch + + if not branch: + if task_data and task_data.raw.get("branch"): + branch = task_data.raw["branch"] + else: + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) + detected = branch_out.strip() + if detected: + branch = detected + return add_session( args.title, args.commit, args.summary, extra_content, auto_commit=not args.no_commit, + package=package, + branch=branch, ) diff --git a/.trellis/scripts/common/__init__.py b/.trellis/scripts/common/__init__.py index 1772978..6d72360 100755 --- a/.trellis/scripts/common/__init__.py +++ b/.trellis/scripts/common/__init__.py @@ -75,8 +75,18 @@ from .paths import ( count_lines, get_current_task, get_current_task_abs, + normalize_task_ref, + resolve_task_ref, set_current_task, clear_current_task, has_current_task, generate_task_date_prefix, ) + +from .active_task import ( + ActiveTask, + clear_active_task, + resolve_active_task, + resolve_context_key, + set_active_task, +) diff --git a/.trellis/scripts/common/active_task.py b/.trellis/scripts/common/active_task.py new file mode 100755 index 0000000..e6597e8 --- /dev/null +++ b/.trellis/scripts/common/active_task.py @@ -0,0 +1,626 @@ +#!/usr/bin/env python3 +"""Session-scoped active task resolution. + +The user-facing concept is a single "active task". Trellis stores that pointer +per AI session/window under `.trellis/.runtime/sessions/`; without a stable +session key there is no active task. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import sys +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +DIR_WORKFLOW = ".trellis" +DIR_TASKS = "tasks" +DIR_RUNTIME = ".runtime" +DIR_SESSIONS = "sessions" +DIR_CURSOR_SHELL = "cursor-shell" +CURSOR_SHELL_TICKET_TTL_SECONDS = 30 +TASK_SESSION_COMMANDS = {"start", "current", "finish"} + +_SESSION_KEYS = ("session_id", "sessionId", "sessionID") +_CONVERSATION_KEYS = ("conversation_id", "conversationId", "conversationID") +_TRANSCRIPT_KEYS = ("transcript_path", "transcriptPath", "transcript") +_NESTED_KEYS = ("input", "properties", "event", "hook_input", "hookInput") +_KNOWN_PLATFORMS = { + "claude", + "codex", + "cursor", + "opencode", + "gemini", + "droid", + "qoder", + "codebuddy", + "kiro", + "copilot", + "pi", +} + +_ENV_SESSION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("claude", ("CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID")), + ("codex", ("CODEX_SESSION_ID", "CODEX_THREAD_ID")), + ("cursor", ("CURSOR_SESSION_ID",)), + ("opencode", ("OPENCODE_SESSION_ID", "OPENCODE_SESSIONID", "OPENCODE_RUN_ID")), + ("gemini", ("GEMINI_SESSION_ID",)), + ("droid", ("FACTORY_SESSION_ID", "DROID_SESSION_ID")), + ("qoder", ("QODER_SESSION_ID",)), + ("codebuddy", ("CODEBUDDY_SESSION_ID",)), + ("kiro", ("KIRO_SESSION_ID",)), + ("copilot", ("COPILOT_SESSION_ID", "COPILOT_SESSIONID")), + ("pi", ("PI_SESSION_ID", "PI_SESSIONID")), +) +_ENV_CONVERSATION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("cursor", ("CURSOR_CONVERSATION_ID", "CURSOR_CONVERSATIONID")), +) +_ENV_TRANSCRIPT_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("claude", ("CLAUDE_TRANSCRIPT_PATH",)), + ("codex", ("CODEX_TRANSCRIPT_PATH",)), + ("cursor", ("CURSOR_TRANSCRIPT_PATH",)), + ("gemini", ("GEMINI_TRANSCRIPT_PATH",)), + ("droid", ("FACTORY_TRANSCRIPT_PATH", "DROID_TRANSCRIPT_PATH")), + ("qoder", ("QODER_TRANSCRIPT_PATH",)), + ("codebuddy", ("CODEBUDDY_TRANSCRIPT_PATH",)), +) +_ENV_PLATFORM_ALIASES = { + "claude-code": "claude", + "factory": "droid", + "factory-ai": "droid", + "github-copilot": "copilot", +} + + +@dataclass(frozen=True) +class ActiveTask: + """Resolved active task state.""" + + task_path: str | None + source_type: str + context_key: str | None = None + stale: bool = False + + @property + def source(self) -> str: + """Human-readable source label.""" + if self.source_type == "session" and self.context_key: + return f"session:{self.context_key}" + if self.source_type == "session-fallback" and self.context_key: + return f"session-fallback:{self.context_key}" + return self.source_type + + +def normalize_task_ref(task_ref: str) -> str: + """Normalize a task ref for stable storage and comparison.""" + normalized = task_ref.strip() + if not normalized: + return "" + + path_obj = Path(normalized) + if path_obj.is_absolute(): + return str(path_obj) + + normalized = normalized.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + + if normalized.startswith(f"{DIR_TASKS}/"): + return f"{DIR_WORKFLOW}/{normalized}" + + return normalized + + +def resolve_task_ref(task_ref: str, repo_root: Path) -> Path | None: + """Resolve a task ref to an absolute task directory.""" + normalized = normalize_task_ref(task_ref) + if not normalized: + return None + + path_obj = Path(normalized) + if path_obj.is_absolute(): + return path_obj + + if normalized.startswith(f"{DIR_WORKFLOW}/"): + return repo_root / path_obj + + return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj + + +def _runtime_sessions_dir(repo_root: Path) -> Path: + return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_SESSIONS + + +def _sanitize_key(raw: str) -> str: + safe = re.sub(r"[^A-Za-z0-9._-]+", "_", raw.strip()) + safe = safe.strip("._-") + return safe[:160] if safe else "" + + +def _hash_value(raw: str) -> str: + return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24] + + +def _as_dict(value: Any) -> dict[str, Any] | None: + return value if isinstance(value, dict) else None + + +def _string_value(value: Any) -> str | None: + if isinstance(value, str): + stripped = value.strip() + return stripped or None + return None + + +def _lookup_string(data: dict[str, Any], keys: tuple[str, ...]) -> str | None: + for key in keys: + value = _string_value(data.get(key)) + if value: + return value + + for nested_key in _NESTED_KEYS: + nested = _as_dict(data.get(nested_key)) + if not nested: + continue + value = _lookup_string(nested, keys) + if value: + return value + + return None + + +def _detect_platform(platform_input: dict[str, Any] | None, platform: str | None) -> str: + if platform: + return _sanitize_key(platform) or "session" + if platform_input: + for key in ("_trellis_platform", "trellis_platform", "platform", "source"): + value = _string_value(platform_input.get(key)) + if value: + return _sanitize_key(value) or "session" + if _string_value(platform_input.get("cursor_version")): + return "cursor" + return "session" + + +def _context_key(platform_name: str, kind: str, value: str) -> str: + if kind == "transcript": + return f"{platform_name}_transcript_{_hash_value(value)}" + safe_value = _sanitize_key(value) + if safe_value: + return f"{platform_name}_{safe_value}" + return f"{platform_name}_{_hash_value(value)}" + + +def _iter_env_keys( + env_keys: tuple[tuple[str, tuple[str, ...]], ...], + platform_name: str | None, +) -> tuple[tuple[str, tuple[str, ...]], ...]: + if not platform_name: + return env_keys + matched = tuple((name, keys) for name, keys in env_keys if name == platform_name) + return matched + + +def _env_platform_name(platform_name: str | None) -> str | None: + if not platform_name or platform_name == "session": + return None + return _ENV_PLATFORM_ALIASES.get(platform_name, platform_name) + + +def _lookup_env_context_key(platform_name: str | None) -> str | None: + """Resolve a context key from platform-provided environment variables. + + Hooks pass `TRELLIS_CONTEXT_ID` to subprocesses they launch, but an AI-run + shell command can only see session identity if the host platform exports it + in the command environment. These names are best-effort adapters; if none + are present, there is no session-scoped active task. + """ + env_platform_name = _env_platform_name(platform_name) + + for name, keys in _iter_env_keys(_ENV_SESSION_KEYS, env_platform_name): + for key in keys: + value = _string_value(os.environ.get(key)) + if value: + return _context_key(name, "session", value) + + for name, keys in _iter_env_keys(_ENV_CONVERSATION_KEYS, env_platform_name): + for key in keys: + value = _string_value(os.environ.get(key)) + if value: + return _context_key(name, "conversation", value) + + for name, keys in _iter_env_keys(_ENV_TRANSCRIPT_KEYS, env_platform_name): + for key in keys: + value = _string_value(os.environ.get(key)) + if value: + return _context_key(name, "transcript", value) + + return None + + +def _find_repo_root_from_cwd() -> Path | None: + current = Path.cwd().resolve() + while True: + if (current / DIR_WORKFLOW).is_dir(): + return current + if current == current.parent: + return None + current = current.parent + + +def _cursor_shell_ticket_dir(repo_root: Path) -> Path: + return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_CURSOR_SHELL + + +def _remove_file(path: Path) -> bool: + try: + path.unlink() + return True + except OSError: + return False + + +def _task_refs_match(left: str | None, right: str | None, repo_root: Path) -> bool: + if not left or not right: + return False + left_path = resolve_task_ref(left, repo_root) + right_path = resolve_task_ref(right, repo_root) + if left_path is not None and right_path is not None: + return left_path == right_path + return normalize_task_ref(left) == normalize_task_ref(right) + + +def _pending_ticket_matches_args(ticket: dict[str, Any], repo_root: Path) -> bool: + if Path(sys.argv[0]).name != "task.py": + return False + args = tuple(sys.argv[1:]) + if not args: + return False + + command_name = args[0] + if command_name not in TASK_SESSION_COMMANDS: + return False + + subcommands = ticket.get("subcommands") + if not isinstance(subcommands, list): + return False + + for subcommand in subcommands: + if not isinstance(subcommand, dict): + continue + if _string_value(subcommand.get("name")) != command_name: + continue + if command_name != "start": + return True + task_ref = args[1] if len(args) > 1 else None + if _task_refs_match(_string_value(subcommand.get("task_ref")), task_ref, repo_root): + return True + + return False + + +def _ticket_is_fresh(ticket: dict[str, Any], ticket_path: Path, now: float) -> bool: + expires_at = ticket.get("expires_at_epoch") + if isinstance(expires_at, (int, float)) and expires_at < now: + _remove_file(ticket_path) + return False + + created_at = ticket.get("created_at_epoch") + if isinstance(created_at, (int, float)): + if now - created_at <= CURSOR_SHELL_TICKET_TTL_SECONDS: + return True + _remove_file(ticket_path) + return False + return True + + +def _ticket_cwd_matches_repo(ticket: dict[str, Any], repo_root: Path) -> bool: + cwd = _string_value(ticket.get("cwd")) + if not cwd: + return True + try: + Path(cwd).resolve().relative_to(repo_root) + except ValueError: + return False + return True + + +def _matching_cursor_ticket_context_key( + ticket_path: Path, + repo_root: Path, + now: float, +) -> str | None: + ticket = _read_json(ticket_path) + if ticket is None or ticket.get("platform") != "cursor": + return None + if not _ticket_is_fresh(ticket, ticket_path, now): + return None + if not _ticket_cwd_matches_repo(ticket, repo_root): + return None + if not _pending_ticket_matches_args(ticket, repo_root): + return None + return _string_value(ticket.get("context_key")) + + +def _lookup_cursor_shell_ticket_context_key() -> str | None: + """Resolve Cursor conversation identity from a short-lived shell ticket. + + Cursor exposes `conversation_id` to `beforeShellExecution`, but does not + export it into the shell command environment. The Cursor hook writes a + short-lived ticket just before `task.py` runs. We accept a ticket only when + the current `task.py` subcommand matches and exactly one fresh context key + matches, which avoids cross-window pointer contamination. + """ + repo_root = _find_repo_root_from_cwd() + if repo_root is None: + return None + + ticket_dir = _cursor_shell_ticket_dir(repo_root) + if not ticket_dir.is_dir(): + return None + + now = time.time() + candidates: set[str] = set() + for ticket_path in ticket_dir.glob("*.json"): + context_key = _matching_cursor_ticket_context_key(ticket_path, repo_root, now) + if context_key: + candidates.add(context_key) + + if len(candidates) == 1: + return next(iter(candidates)) + return None + + +def resolve_context_key( + platform_input: dict[str, Any] | None = None, + platform: str | None = None, +) -> str | None: + """Resolve a stable session/window context key, if one is available. + + `TRELLIS_CONTEXT_ID` is an explicit context-key override used by CLI + scripts and subprocesses. It does not store the task itself. + """ + override = _string_value(os.environ.get("TRELLIS_CONTEXT_ID")) + if override: + return _sanitize_key(override) or _hash_value(override) + + data = _as_dict(platform_input) + platform_name = _detect_platform(data, platform) if data or platform else None + + if data: + session_id = _lookup_string(data, _SESSION_KEYS) + if session_id: + return _context_key(platform_name or "session", "session", session_id) + + conversation_id = _lookup_string(data, _CONVERSATION_KEYS) + if conversation_id: + return _context_key(platform_name or "session", "conversation", conversation_id) + + transcript_path = _lookup_string(data, _TRANSCRIPT_KEYS) + if transcript_path: + return _context_key(platform_name or "session", "transcript", transcript_path) + + env_context_key = _lookup_env_context_key(platform_name) + if env_context_key: + return env_context_key + + if platform_name in (None, "session", "cursor"): + return _lookup_cursor_shell_ticket_context_key() + return None + + +def _read_json(path: Path) -> dict[str, Any] | None: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + return data if isinstance(data, dict) else None + + +def _write_json(path: Path, data: dict[str, Any]) -> bool: + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(data, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + return True + except OSError: + return False + + +def _canonical_task_ref(task_path: str, repo_root: Path) -> str | None: + normalized = normalize_task_ref(task_path) + if not normalized: + return None + full_path = resolve_task_ref(normalized, repo_root) + if full_path is None or not full_path.is_dir(): + return None + try: + return full_path.relative_to(repo_root).as_posix() + except ValueError: + return str(full_path) + + +def _active_from_ref( + task_ref: str | None, + repo_root: Path, + source_type: str, + context_key: str | None = None, +) -> ActiveTask | None: + if not task_ref: + return None + resolved = resolve_task_ref(task_ref, repo_root) + stale = resolved is None or not resolved.is_dir() + return ActiveTask(task_ref, source_type, context_key, stale) + + +def _context_path(repo_root: Path, context_key: str) -> Path: + return _runtime_sessions_dir(repo_root) / f"{context_key}.json" + + +def resolve_active_task( + repo_root: Path, + platform_input: dict[str, Any] | None = None, + platform: str | None = None, +) -> ActiveTask: + """Resolve the active task from session runtime state only. + + A stale session task is returned as stale. Missing context identity or a + missing/empty session context falls back to single-session inference: if + exactly one session file exists in the runtime, return its task with + source_type="session-fallback" — covers class-2 platform sub-agents (codex, + copilot, gemini, qoder) that don't inherit the parent's session id. ≥2 + files or 0 files yield ActiveTask(None) — refuses to guess across windows. + """ + context_key = resolve_context_key(platform_input, platform) + if context_key: + context = _read_json(_context_path(repo_root, context_key)) or {} + task_ref = _string_value(context.get("current_task")) + active = _active_from_ref(task_ref, repo_root, "session", context_key) + if active: + return active + + fallback = _resolve_single_session_fallback(repo_root) + if fallback is not None: + return fallback + + return ActiveTask(None, "none", context_key) + + +def _resolve_single_session_fallback(repo_root: Path) -> ActiveTask | None: + """Return the task pointed at by the sole session file, if exactly one exists. + + Used when context-key resolution fails (typical for class-2 platform + sub-agents). Returns None if 0 or ≥2 session files are present — refuses + to pick across windows so 04-21's multi-session isolation contract holds. + """ + sessions_dir = _runtime_sessions_dir(repo_root) + if not sessions_dir.is_dir(): + return None + + session_files = sorted(sessions_dir.glob("*.json")) + if len(session_files) != 1: + return None + + session_file = session_files[0] + context = _read_json(session_file) or {} + task_ref = _string_value(context.get("current_task")) + if not task_ref: + return None + + fallback_key = session_file.stem + return _active_from_ref(task_ref, repo_root, "session-fallback", fallback_key) + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _context_metadata( + platform_input: dict[str, Any] | None, + platform: str | None, + context_key: str | None = None, +) -> dict[str, Any]: + data = _as_dict(platform_input) or {} + platform_name = _detect_platform(data, platform) + if platform_name == "session" and context_key: + prefix = context_key.split("_", 1)[0] + if prefix in _KNOWN_PLATFORMS: + platform_name = prefix + metadata: dict[str, Any] = { + "platform": platform_name, + "last_seen_at": _utc_now(), + } + for key in (*_SESSION_KEYS, *_CONVERSATION_KEYS, *_TRANSCRIPT_KEYS): + value = _lookup_string(data, (key,)) + if value: + metadata[key] = value + return metadata + + +def set_active_task( + task_path: str, + repo_root: Path, + platform_input: dict[str, Any] | None = None, + platform: str | None = None, +) -> ActiveTask | None: + """Set the active task in session scope. + + Returns None when no context key is available; callers should surface a + user-facing error that explains how to provide session identity. + """ + canonical = _canonical_task_ref(task_path, repo_root) + if canonical is None: + return None + + context_key = resolve_context_key(platform_input, platform) + if not context_key: + return None + + context_path = _context_path(repo_root, context_key) + context = _read_json(context_path) or {} + context.update(_context_metadata(platform_input, platform, context_key)) + context["current_task"] = canonical + context.setdefault("current_run", None) + if not _write_json(context_path, context): + return None + return ActiveTask(canonical, "session", context_key) + + +def clear_active_task( + repo_root: Path, + platform_input: dict[str, Any] | None = None, + platform: str | None = None, +) -> ActiveTask: + """Clear the active task by deleting the current session context file.""" + context_key = resolve_context_key(platform_input, platform) + if not context_key: + return ActiveTask(None, "none") + + previous = resolve_active_task(repo_root, platform_input, platform) + context_path = _context_path(repo_root, context_key) + if context_path.is_file(): + _remove_file(context_path) + return previous + + +def clear_task_from_sessions(task_path: str, repo_root: Path) -> int: + """Delete all session runtime files that point at a task.""" + target = _canonical_task_ref(task_path, repo_root) or normalize_task_ref(task_path) + if not target: + return 0 + + cleared = 0 + sessions_dir = _runtime_sessions_dir(repo_root) + if not sessions_dir.is_dir(): + return cleared + + for session_path in sessions_dir.glob("*.json"): + context = _read_json(session_path) or {} + current = _string_value(context.get("current_task")) + if not current: + continue + current_ref = _canonical_task_ref(current, repo_root) or normalize_task_ref(current) + if current_ref != target: + continue + if session_path.is_file() and _remove_file(session_path): + cleared += 1 + + return cleared + + +def get_current_task_source( + repo_root: Path, + platform_input: dict[str, Any] | None = None, + platform: str | None = None, +) -> tuple[str, str | None, str | None]: + """Return (`source_type`, `context_key`, `task_path`) for compatibility.""" + active = resolve_active_task(repo_root, platform_input, platform) + return active.source_type, active.context_key, active.task_path diff --git a/.trellis/scripts/common/cli_adapter.py b/.trellis/scripts/common/cli_adapter.py index ce3323b..b65f61a 100755 --- a/.trellis/scripts/common/cli_adapter.py +++ b/.trellis/scripts/common/cli_adapter.py @@ -1,7 +1,7 @@ """ CLI Adapter for Multi-Platform Support. -Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, and Qoder interfaces. +Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, Windsurf, Qoder, CodeBuddy, GitHub Copilot, Factory Droid, and Pi Agent interfaces. Supported platforms: - claude: Claude Code (default) @@ -13,7 +13,12 @@ Supported platforms: - kiro: Kiro Code (skills-based) - gemini: Gemini CLI - antigravity: Antigravity (workflow-based) +- windsurf: Windsurf (workflow-based) - qoder: Qoder +- codebuddy: CodeBuddy +- copilot: GitHub Copilot (VS Code) +- droid: Factory Droid (commands-based) +- pi: Pi Agent (extension-backed) Usage: from common.cli_adapter import CLIAdapter @@ -42,7 +47,12 @@ Platform = Literal[ "kiro", "gemini", "antigravity", + "windsurf", "qoder", + "codebuddy", + "copilot", + "droid", + "pi", ] @@ -87,7 +97,7 @@ class CLIAdapter: """Get platform-specific config directory name. Returns: - Directory name ('.claude', '.opencode', '.cursor', '.iflow', '.agents', '.kilocode', '.kiro', '.gemini', '.agent', or '.qoder') + Directory name ('.claude', '.opencode', '.cursor', '.iflow', '.codex', '.kilocode', '.kiro', '.gemini', '.agent', '.windsurf', '.qoder', '.codebuddy', '.github/copilot', '.factory', or '.pi') """ if self.platform == "opencode": return ".opencode" @@ -96,7 +106,7 @@ class CLIAdapter: elif self.platform == "iflow": return ".iflow" elif self.platform == "codex": - return ".agents" + return ".codex" elif self.platform == "kilo": return ".kilocode" elif self.platform == "kiro": @@ -105,8 +115,18 @@ class CLIAdapter: return ".gemini" elif self.platform == "antigravity": return ".agent" + elif self.platform == "windsurf": + return ".windsurf" elif self.platform == "qoder": return ".qoder" + elif self.platform == "codebuddy": + return ".codebuddy" + elif self.platform == "copilot": + return ".github/copilot" + elif self.platform == "droid": + return ".factory" + elif self.platform == "pi": + return ".pi" else: return ".claude" @@ -117,7 +137,7 @@ class CLIAdapter: project_root: Project root directory Returns: - Path to config directory (.claude, .opencode, .cursor, .iflow, .agents, .kilocode, .kiro, .gemini, .agent, or .qoder) + Path to config directory (.claude, .opencode, .cursor, .iflow, .codex, .kilocode, .kiro, .gemini, .agent, .windsurf, .qoder, .codebuddy, .github/copilot, .factory, or .pi) """ return project_root / self.config_dir_name @@ -129,9 +149,11 @@ class CLIAdapter: project_root: Project root directory Returns: - Path to agent .md file + Path to agent definition file (.md for most platforms, .toml for Codex) """ mapped_name = self.get_agent_name(agent) + if self.platform == "codex": + return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.toml" return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.md" def get_commands_path(self, project_root: Path, *parts: str) -> Path: @@ -147,8 +169,31 @@ class CLIAdapter: Note: Cursor uses prefix naming: .cursor/commands/trellis-<name>.md Antigravity uses workflow directory: .agent/workflows/<name>.md + Windsurf uses workflow directory: .windsurf/workflows/trellis-<name>.md + Copilot uses prompt files: .github/prompts/<name>.prompt.md + Pi uses prompt templates: .pi/prompts/trellis-<name>.md Claude/OpenCode use subdirectory: .claude/commands/trellis/<name>.md """ + if self.platform == "pi": + prompts_dir = self.get_config_dir(project_root) / "prompts" + if not parts: + return prompts_dir + if len(parts) >= 2 and parts[0] == "trellis": + filename = parts[-1] + if filename.endswith(".md"): + filename = filename[:-3] + return prompts_dir / f"trellis-{filename}.md" + return prompts_dir / Path(*parts) + + if self.platform == "windsurf": + workflow_dir = self.get_config_dir(project_root) / "workflows" + if not parts: + return workflow_dir + if len(parts) >= 2 and parts[0] == "trellis": + filename = parts[-1] + return workflow_dir / f"trellis-{filename}" + return workflow_dir / Path(*parts) + if self.platform in ("antigravity", "kilo"): workflow_dir = self.get_config_dir(project_root) / "workflows" if not parts: @@ -158,6 +203,17 @@ class CLIAdapter: return workflow_dir / filename return workflow_dir / Path(*parts) + if self.platform == "copilot": + prompts_dir = project_root / ".github" / "prompts" + if not parts: + return prompts_dir + if len(parts) >= 2 and parts[0] == "trellis": + filename = parts[-1] + if filename.endswith(".md"): + filename = filename[:-3] + return prompts_dir / f"{filename}.prompt.md" + return prompts_dir / Path(*parts) + if not parts: return self.get_config_dir(project_root) / "commands" @@ -175,31 +231,43 @@ class CLIAdapter: """Get relative path to a trellis command file. Args: - name: Command name without extension (e.g., 'finish-work', 'check-backend') + name: Command name without extension (e.g., 'finish-work', 'check') Returns: Relative path string for use in JSONL entries Note: Cursor: .cursor/commands/trellis-<name>.md - Codex: .agents/skills/<name>/SKILL.md - Kiro: .kiro/skills/<name>/SKILL.md + Codex: .agents/skills/trellis-<name>/SKILL.md + Kiro: .kiro/skills/trellis-<name>/SKILL.md Gemini: .gemini/commands/trellis/<name>.toml Antigravity: .agent/workflows/<name>.md + Windsurf: .windsurf/workflows/trellis-<name>.md + Pi: .pi/prompts/trellis-<name>.md Others: .{platform}/commands/trellis/<name>.md """ if self.platform == "cursor": return f".cursor/commands/trellis-{name}.md" elif self.platform == "codex": - return f".agents/skills/{name}/SKILL.md" + # 0.5.0-beta.0 renamed all skill dirs to add the `trellis-` prefix + # (see that release's manifest for the 60+ rename entries). + return f".agents/skills/trellis-{name}/SKILL.md" elif self.platform == "kiro": - return f".kiro/skills/{name}/SKILL.md" + return f".kiro/skills/trellis-{name}/SKILL.md" elif self.platform == "gemini": return f".gemini/commands/trellis/{name}.toml" elif self.platform == "antigravity": return f".agent/workflows/{name}.md" + elif self.platform == "windsurf": + return f".windsurf/workflows/trellis-{name}.md" elif self.platform == "kilo": return f".kilocode/workflows/{name}.md" + elif self.platform == "copilot": + return f".github/prompts/{name}.prompt.md" + elif self.platform == "droid": + return f".factory/commands/trellis/{name}.md" + elif self.platform == "pi": + return f".pi/prompts/trellis-{name}.md" else: return f"{self.config_dir_name}/commands/trellis/{name}.md" @@ -225,8 +293,18 @@ class CLIAdapter: return {} # Gemini CLI doesn't have a non-interactive env var elif self.platform == "antigravity": return {} + elif self.platform == "windsurf": + return {} elif self.platform == "qoder": return {} + elif self.platform == "codebuddy": + return {} + elif self.platform == "copilot": + return {} + elif self.platform == "droid": + return {} + elif self.platform == "pi": + return {} else: return {"CLAUDE_NON_INTERACTIVE": "1"} @@ -278,12 +356,8 @@ class CLIAdapter: cmd.append(prompt) elif self.platform == "iflow": - cmd = ["iflow", "-p"] - cmd.extend(["-y", "--agent", mapped_agent]) - # iFlow doesn't support --session-id on creation - if verbose: - cmd.append("--verbose") - cmd.append(prompt) + cmd = ["iflow", "-y", "-p"] + cmd.append(f"${mapped_agent} {prompt}") elif self.platform == "codex": cmd = ["codex", "exec"] cmd.append(prompt) @@ -296,8 +370,26 @@ class CLIAdapter: raise ValueError( "Antigravity workflows are UI slash commands; CLI agent run is not supported." ) + elif self.platform == "windsurf": + raise ValueError( + "Windsurf workflows are UI slash commands; CLI agent run is not supported." + ) elif self.platform == "qoder": cmd = ["qodercli", "-p", prompt] + elif self.platform == "codebuddy": + raise ValueError( + "CodeBuddy does not support non-interactive mode (no CLI agent)" + ) + elif self.platform == "copilot": + raise ValueError( + "GitHub Copilot is IDE-only; CLI agent run is not supported." + ) + elif self.platform == "droid": + raise ValueError( + "Factory Droid CLI agent run is not yet supported." + ) + elif self.platform == "pi": + cmd = ["pi", "-p", prompt] else: # claude cmd = ["claude", "-p"] @@ -344,8 +436,26 @@ class CLIAdapter: raise ValueError( "Antigravity workflows are UI slash commands; CLI resume is not supported." ) + elif self.platform == "windsurf": + raise ValueError( + "Windsurf workflows are UI slash commands; CLI resume is not supported." + ) elif self.platform == "qoder": return ["qodercli", "--resume", session_id] + elif self.platform == "codebuddy": + raise ValueError( + "CodeBuddy does not support non-interactive mode (no CLI agent)" + ) + elif self.platform == "copilot": + raise ValueError( + "GitHub Copilot is IDE-only; CLI resume is not supported." + ) + elif self.platform == "droid": + raise ValueError( + "Factory Droid CLI resume is not yet supported." + ) + elif self.platform == "pi": + return ["pi", "-c", session_id] else: return ["claude", "--resume", session_id] @@ -408,8 +518,18 @@ class CLIAdapter: return "gemini" elif self.platform == "antigravity": return "agy" + elif self.platform == "windsurf": + return "windsurf" elif self.platform == "qoder": return "qodercli" + elif self.platform == "codebuddy": + return "codebuddy" + elif self.platform == "copilot": + return "copilot" + elif self.platform == "droid": + return "droid" + elif self.platform == "pi": + return "pi" else: return "claude" @@ -417,9 +537,18 @@ class CLIAdapter: def supports_cli_agents(self) -> bool: """Check if platform supports running agents via CLI. - Claude Code, OpenCode, and iFlow support CLI agent execution. + Claude Code, OpenCode, iFlow, and Codex support CLI agent execution. Cursor is IDE-only and doesn't support CLI agents. """ + return self.platform in ("claude", "opencode", "iflow", "codex", "pi") + + @property + def requires_agent_definition_file(self) -> bool: + """Check if platform requires an agent definition file (.md/.toml) to run. + + Claude Code, OpenCode, iFlow: require agent .md files (--agent flag). + Codex: auto-discovers agents from .codex/agents/*.toml, no --agent flag. + """ return self.platform in ("claude", "opencode", "iflow") # ========================================================================= @@ -465,7 +594,7 @@ def get_cli_adapter(platform: str = "claude") -> CLIAdapter: """Get CLI adapter for the specified platform. Args: - platform: Platform name ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', or 'qoder') + platform: Platform name ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'windsurf', 'qoder', 'codebuddy', 'copilot', 'droid', or 'pi') Returns: CLIAdapter instance @@ -483,15 +612,53 @@ def get_cli_adapter(platform: str = "claude") -> CLIAdapter: "kiro", "gemini", "antigravity", + "windsurf", "qoder", + "codebuddy", + "copilot", + "droid", + "pi", ): raise ValueError( - f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', or 'qoder')" + f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'windsurf', 'qoder', 'codebuddy', 'copilot', 'droid', or 'pi')" ) return CLIAdapter(platform=platform) # type: ignore +_ALL_PLATFORM_CONFIG_DIRS = ( + ".claude", + ".cursor", + ".iflow", + ".opencode", + ".codex", + ".kilocode", + ".kiro", + ".gemini", + ".agent", + ".windsurf", + ".qoder", + ".codebuddy", + ".github/copilot", + ".factory", + ".pi", +) +"""Platform-specific config directory names used by detect_platform exclusion +checks. `.agents/skills/` is NOT listed here: it is a shared cross-platform +layer (written by Codex, also consumed by Amp/Cline/Warp/etc. via the +agentskills.io standard), not a single-platform signal. Its presence must not +block detection of Kiro, Antigravity, Windsurf, or other platforms.""" + + +def _has_other_platform_dir(project_root: Path, exclude: set[str]) -> bool: + """Check if any platform config dir exists besides those in *exclude*.""" + return any( + (project_root / d).is_dir() + for d in _ALL_PLATFORM_CONFIG_DIRS + if d not in exclude + ) + + def detect_platform(project_root: Path) -> Platform: """Auto-detect platform based on existing config directories. @@ -500,19 +667,22 @@ def detect_platform(project_root: Path) -> Platform: 2. .opencode directory exists → opencode 3. .iflow directory exists → iflow 4. .cursor directory exists (without .claude) → cursor - 5. .agents/skills exists and no other platform dirs → codex + 5. .codex exists and no other platform dirs → codex 6. .kilocode directory exists → kilo 7. .kiro/skills exists and no other platform dirs → kiro 8. .gemini directory exists → gemini 9. .agent/workflows exists and no other platform dirs → antigravity - 10. .qoder directory exists → qoder - 11. Default → claude + 10. .windsurf/workflows exists and no other platform dirs → windsurf + 11. .codebuddy directory exists → codebuddy + 12. .qoder directory exists → qoder + 13. .pi directory exists → pi + 14. Default → claude Args: project_root: Project root directory Returns: - Detected platform ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', or 'qoder') + Detected platform ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'windsurf', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', or default 'claude') """ import os @@ -528,17 +698,20 @@ def detect_platform(project_root: Path) -> Platform: "kiro", "gemini", "antigravity", + "windsurf", "qoder", + "codebuddy", + "copilot", + "droid", + "pi", ): return env_platform # type: ignore # Check for .opencode directory (OpenCode-specific) - # Note: .claude might exist in both platforms during migration if (project_root / ".opencode").is_dir(): return "opencode" # Check for .iflow directory (iFlow-specific) - # Note: .claude might exist in both platforms during migration if (project_root / ".iflow").is_dir(): return "iflow" @@ -551,21 +724,11 @@ def detect_platform(project_root: Path) -> Platform: if (project_root / ".gemini").is_dir(): return "gemini" - # Check for Codex skills directory only when no other platform config exists - other_platform_dirs_codex = ( - ".claude", - ".cursor", - ".iflow", - ".opencode", - ".kilocode", - ".kiro", - ".gemini", - ".agent", - ) - has_other_platform_config = any( - (project_root / directory).is_dir() for directory in other_platform_dirs_codex - ) - if (project_root / ".agents" / "skills").is_dir() and not has_other_platform_config: + # Check for .codex directory (Codex-specific) + # .agents/skills/ alone does NOT trigger codex detection (it's a shared standard) + if (project_root / ".codex").is_dir() and not _has_other_platform_dir( + project_root, {".codex", ".agents"} + ): return "codex" # Check for .kilocode directory (Kilo-specific) @@ -573,45 +736,65 @@ def detect_platform(project_root: Path) -> Platform: return "kilo" # Check for Kiro skills directory only when no other platform config exists - other_platform_dirs_kiro = ( - ".claude", - ".cursor", - ".iflow", - ".opencode", - ".agents", - ".kilocode", - ".gemini", - ".agent", - ) - has_other_platform_config = any( - (project_root / directory).is_dir() for directory in other_platform_dirs_kiro - ) - if (project_root / ".kiro" / "skills").is_dir() and not has_other_platform_config: + if (project_root / ".kiro" / "skills").is_dir() and not _has_other_platform_dir( + project_root, {".kiro"} + ): return "kiro" # Check for Antigravity workflow directory only when no other platform config exists - other_platform_dirs_antigravity = ( - ".claude", - ".cursor", - ".iflow", - ".opencode", - ".agents", - ".kilocode", - ".kiro", - ) - has_other_platform_config = any( - (project_root / directory).is_dir() - for directory in other_platform_dirs_antigravity - ) if ( project_root / ".agent" / "workflows" - ).is_dir() and not has_other_platform_config: + ).is_dir() and not _has_other_platform_dir( + project_root, {".agent", ".gemini"} + ): return "antigravity" + # Check for Windsurf workflow directory only when no other platform config exists + if ( + project_root / ".windsurf" / "workflows" + ).is_dir() and not _has_other_platform_dir( + project_root, {".windsurf"} + ): + return "windsurf" + + # Check for .codebuddy directory (CodeBuddy-specific) + if (project_root / ".codebuddy").is_dir(): + return "codebuddy" + # Check for .qoder directory (Qoder-specific) if (project_root / ".qoder").is_dir(): return "qoder" + # Check for .github/copilot directory (GitHub Copilot-specific) + if (project_root / ".github" / "copilot").is_dir(): + return "copilot" + + # Check for .factory directory (Factory Droid-specific) + if (project_root / ".factory").is_dir(): + return "droid" + + # Check for .pi directory (Pi Agent-specific) + if (project_root / ".pi").is_dir(): + return "pi" + + # Fallback: checkout only has the Codex shared-skills layer + # (.agents/skills/trellis-* dirs) and no explicit platform config dir. + # Happens on fresh clones where .codex/ is gitignored/absent but the + # shared skills were committed to git. Must guard against the case + # where .claude/ or any other platform dir also exists — .agents/skills/ + # can legitimately coexist with any platform as a shared consumption + # layer for Amp/Cline/Warp/etc. + agents_skills = project_root / ".agents" / "skills" + if agents_skills.is_dir() and not _has_other_platform_dir( + project_root, set() + ): + try: + for entry in agents_skills.iterdir(): + if entry.is_dir() and entry.name.startswith("trellis-"): + return "codex" + except OSError: + pass + return "claude" diff --git a/.trellis/scripts/common/config.py b/.trellis/scripts/common/config.py index 601ab32..ecae1b3 100755 --- a/.trellis/scripts/common/config.py +++ b/.trellis/scripts/common/config.py @@ -7,10 +7,136 @@ Reads settings from .trellis/config.yaml with sensible defaults. from __future__ import annotations +import sys from pathlib import Path from .paths import DIR_WORKFLOW, get_repo_root -from .worktree import parse_simple_yaml + + +# ============================================================================= +# YAML Simple Parser (no dependencies) +# ============================================================================= + + +def _unquote(s: str) -> str: + """Remove exactly one layer of matching surrounding quotes. + + Unlike str.strip('"'), this only removes the outermost pair, + preserving any nested quotes inside the value. + + Examples: + _unquote('"hello"') -> 'hello' + _unquote("'hello'") -> 'hello' + _unquote('"echo \\'hi\\'"') -> "echo 'hi'" + _unquote('hello') -> 'hello' + _unquote('"hello\\'') -> '"hello\\'' (mismatched, unchanged) + """ + if len(s) >= 2 and s[0] == s[-1] and s[0] in ('"', "'"): + return s[1:-1] + return s + + +def parse_simple_yaml(content: str) -> dict: + """Parse simple YAML with nested dict support (no dependencies). + + Supports: + - key: value (string) + - key: (followed by list items) + - item1 + - item2 + - key: (followed by nested dict) + nested_key: value + nested_key2: + - item + + Uses indentation to detect nesting (2+ spaces deeper = child). + + Args: + content: YAML content string. + + Returns: + Parsed dict (values can be str, list[str], or dict). + """ + lines = content.splitlines() + result: dict = {} + _parse_yaml_block(lines, 0, 0, result) + return result + + +def _parse_yaml_block( + lines: list[str], start: int, min_indent: int, target: dict +) -> int: + """Parse a YAML block into target dict, returning next line index.""" + i = start + current_list: list | None = None + + while i < len(lines): + line = lines[i] + stripped = line.strip() + + # Skip empty lines and comments + if not stripped or stripped.startswith("#"): + i += 1 + continue + + # Calculate indentation + indent = len(line) - len(line.lstrip()) + + # If dedented past our block, we're done + if indent < min_indent: + break + + if stripped.startswith("- "): + if current_list is not None: + current_list.append(_unquote(stripped[2:].strip())) + i += 1 + elif ":" in stripped: + key, _, value = stripped.partition(":") + key = key.strip() + value = _unquote(value.strip()) + current_list = None + + if value: + # key: value + target[key] = value + i += 1 + else: + # key: (no value) — peek ahead to determine list vs nested dict + next_i, next_line = _next_content_line(lines, i + 1) + if next_i >= len(lines): + target[key] = {} + i = next_i + elif next_line.strip().startswith("- "): + # It's a list + current_list = [] + target[key] = current_list + i += 1 + else: + next_indent = len(next_line) - len(next_line.lstrip()) + if next_indent > indent: + # It's a nested dict + nested: dict = {} + target[key] = nested + i = _parse_yaml_block(lines, i + 1, next_indent, nested) + else: + # Empty value, same or less indent follows + target[key] = {} + i += 1 + else: + i += 1 + + return i + + +def _next_content_line(lines: list[str], start: int) -> tuple[int, str]: + """Find the next non-empty, non-comment line.""" + i = start + while i < len(lines): + stripped = lines[i].strip() + if stripped and not stripped.startswith("#"): + return i, lines[i] + i += 1 + return i, "" # Defaults @@ -20,6 +146,15 @@ DEFAULT_MAX_JOURNAL_LINES = 2000 CONFIG_FILE = "config.yaml" +def _is_true_config_value(value: object) -> bool: + """Return True when a config value represents an enabled flag.""" + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() == "true" + return False + + def _get_config_path(repo_root: Path | None = None) -> Path: """Get path to config.yaml.""" root = repo_root or get_repo_root() @@ -70,3 +205,185 @@ def get_hooks(event: str, repo_root: Path | None = None) -> list[str]: if isinstance(commands, list): return [str(c) for c in commands] return [] + + +# ============================================================================= +# Monorepo / Packages +# ============================================================================= + + +def get_packages(repo_root: Path | None = None) -> dict[str, dict] | None: + """Get monorepo package declarations. + + Returns: + Dict mapping package name to its config (path, type, etc.), + or None if not configured (single-repo mode). + + Example return: + {"cli": {"path": "packages/cli"}, "docs-site": {"path": "docs-site", "type": "submodule"}} + """ + config = _load_config(repo_root) + packages = config.get("packages") + if not isinstance(packages, dict): + return None + # Ensure each value is a dict (filter out scalar entries) + filtered = {k: v for k, v in packages.items() if isinstance(v, dict)} + if not filtered: + return None + return filtered + + +def get_default_package(repo_root: Path | None = None) -> str | None: + """Get the default package name from config. + + Returns: + Package name string, or None if not configured. + """ + config = _load_config(repo_root) + value = config.get("default_package") + return str(value) if value else None + + +def get_submodule_packages(repo_root: Path | None = None) -> dict[str, str]: + """Get packages that are git submodules. + + Returns: + Dict mapping package name to its path for submodule-type packages. + Empty dict if none configured. + + Example return: + {"docs-site": "docs-site"} + """ + packages = get_packages(repo_root) + if packages is None: + return {} + return { + name: cfg.get("path", name) + for name, cfg in packages.items() + if cfg.get("type") == "submodule" + } + + +def get_git_packages(repo_root: Path | None = None) -> dict[str, str]: + """Get packages that have their own independent git repository. + + These are sub-directories with their own .git (not submodules), + marked with ``git: true`` in config.yaml. + + Returns: + Dict mapping package name to its path for git-repo packages. + Empty dict if none configured. + + Example config:: + + packages: + backend: + path: iqs + git: true + + Example return:: + + {"backend": "iqs"} + """ + packages = get_packages(repo_root) + if packages is None: + return {} + return { + name: cfg.get("path", name) + for name, cfg in packages.items() + if _is_true_config_value(cfg.get("git")) + } + + +def is_monorepo(repo_root: Path | None = None) -> bool: + """Check if the project is configured as a monorepo (has packages in config).""" + return get_packages(repo_root) is not None + + +def get_spec_base(package: str | None = None, repo_root: Path | None = None) -> str: + """Get the spec directory base path relative to .trellis/. + + Single-repo: returns "spec" + Monorepo with package: returns "spec/<package>" + Monorepo without package: returns "spec" (caller should specify package) + """ + if package and is_monorepo(repo_root): + return f"spec/{package}" + return "spec" + + +def validate_package(package: str, repo_root: Path | None = None) -> bool: + """Check if a package name is valid in this project. + + Single-repo (no packages configured): always returns True. + Monorepo: returns True only if package exists in config.yaml packages. + """ + packages = get_packages(repo_root) + if packages is None: + return True # Single-repo, no validation needed + return package in packages + + +def resolve_package( + task_package: str | None = None, + repo_root: Path | None = None, +) -> str | None: + """Resolve package from inferred sources with validation. + + Checks in order: task_package → default_package. + Invalid inferred values print a warning to stderr and are skipped. + + Returns: + Resolved package name, or None if no valid package found. + + Note: + CLI --package should be validated separately by the caller + (fail-fast with available packages list on error). + """ + packages = get_packages(repo_root) + if packages is None: + return None # Single-repo, no package needed + + # Try task_package (guard against non-string values from malformed JSON) + if task_package and isinstance(task_package, str): + if task_package in packages: + return task_package + print( + f"Warning: task.json package '{task_package}' not found in config, skipping", + file=sys.stderr, + ) + + # Try default_package + default = get_default_package(repo_root) + if default: + if default in packages: + return default + print( + f"Warning: default_package '{default}' not found in config, skipping", + file=sys.stderr, + ) + + return None + + +def get_spec_scope(repo_root: Path | None = None) -> list[str] | str | None: + """Get session.spec_scope configuration. + + Returns: + list[str]: Package names to include in spec scanning. + str: "active_task" to use current task's package. + None: No scope configured (scan all packages). + """ + config = _load_config(repo_root) + session = config.get("session") + if not isinstance(session, dict): + return None + + scope = session.get("spec_scope") + if scope is None: + return None + if isinstance(scope, str): + return scope # e.g. "active_task" + if isinstance(scope, list): + return [str(s) for s in scope] + return None diff --git a/.trellis/scripts/common/developer.py b/.trellis/scripts/common/developer.py index 7f3cf0c..c203a31 100755 --- a/.trellis/scripts/common/developer.py +++ b/.trellis/scripts/common/developer.py @@ -123,8 +123,8 @@ def init_developer(name: str, repo_root: Path | None = None) -> bool: ## Session History <!-- @@@auto:session-history --> -| # | Date | Title | Commits | -|---|------|-------|---------| +| # | Date | Title | Commits | Branch | +|---|------|-------|---------|--------| <!-- @@@/auto:session-history --> --- diff --git a/.trellis/scripts/common/git.py b/.trellis/scripts/common/git.py new file mode 100755 index 0000000..c4bf29f --- /dev/null +++ b/.trellis/scripts/common/git.py @@ -0,0 +1,31 @@ +""" +Git command execution utility. + +Single source of truth for running git commands across all Trellis scripts. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def run_git(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]: + """Run a git command and return (returncode, stdout, stderr). + + Uses UTF-8 encoding with -c i18n.logOutputEncoding=UTF-8 to ensure + consistent output across all platforms (Windows, macOS, Linux). + """ + try: + git_args = ["git", "-c", "i18n.logOutputEncoding=UTF-8"] + args + result = subprocess.run( + git_args, + cwd=cwd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + return result.returncode, result.stdout, result.stderr + except Exception as e: + return 1, "", str(e) diff --git a/.trellis/scripts/common/git_context.py b/.trellis/scripts/common/git_context.py index 39b9ff5..31a0e77 100755 --- a/.trellis/scripts/common/git_context.py +++ b/.trellis/scripts/common/git_context.py @@ -3,6 +3,8 @@ """ Git and Session Context utilities. +Entry shim — delegates to session_context and packages_context. + Provides: output_json - Output context in JSON format output_text - Output context in text format @@ -11,599 +13,34 @@ Provides: from __future__ import annotations import json -import subprocess -from pathlib import Path -from .paths import ( - DIR_SCRIPTS, - DIR_SPEC, - DIR_TASKS, - DIR_WORKFLOW, - DIR_WORKSPACE, - FILE_TASK_JSON, - count_lines, - get_active_journal_file, - get_current_task, - get_developer, - get_repo_root, - get_tasks_dir, +from .git import run_git +from .session_context import ( + get_context_json, + get_context_text, + get_context_record_json, + get_context_text_record, + output_json, + output_text, +) +from .packages_context import ( + get_context_packages_text, + get_context_packages_json, +) +from .workflow_phase import ( + filter_platform, + get_phase_index, + get_step, ) -# ============================================================================= -# Helper Functions -# ============================================================================= - - -def _run_git_command(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]: - """Run a git command and return (returncode, stdout, stderr). - - Uses UTF-8 encoding with -c i18n.logOutputEncoding=UTF-8 to ensure - consistent output across all platforms (Windows, macOS, Linux). - """ - try: - # Force git to output UTF-8 for consistent cross-platform behavior - git_args = ["git", "-c", "i18n.logOutputEncoding=UTF-8"] + args - result = subprocess.run( - git_args, - cwd=cwd, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - return result.returncode, result.stdout, result.stderr - except Exception as e: - return 1, "", str(e) - - -def _read_json_file(path: Path) -> dict | None: - """Read and parse a JSON file.""" - try: - return json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return None - - -# ============================================================================= -# JSON Output -# ============================================================================= - - -def get_context_json(repo_root: Path | None = None) -> dict: - """Get context as a dictionary. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Context dictionary. - """ - if repo_root is None: - repo_root = get_repo_root() - - developer = get_developer(repo_root) - tasks_dir = get_tasks_dir(repo_root) - journal_file = get_active_journal_file(repo_root) - - journal_lines = 0 - journal_relative = "" - if journal_file and developer: - journal_lines = count_lines(journal_file) - journal_relative = ( - f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}" - ) - - # Git info - _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root) - branch = branch_out.strip() or "unknown" - - _, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root) - git_status_count = len([line for line in status_out.splitlines() if line.strip()]) - is_clean = git_status_count == 0 - - # Recent commits - _, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root) - commits = [] - for line in log_out.splitlines(): - if line.strip(): - parts = line.split(" ", 1) - if len(parts) >= 2: - commits.append({"hash": parts[0], "message": parts[1]}) - elif len(parts) == 1: - commits.append({"hash": parts[0], "message": ""}) - - # Tasks - tasks = [] - if tasks_dir.is_dir(): - for d in tasks_dir.iterdir(): - if d.is_dir() and d.name != "archive": - task_json_path = d / FILE_TASK_JSON - if task_json_path.is_file(): - data = _read_json_file(task_json_path) - if data: - tasks.append( - { - "dir": d.name, - "name": data.get("name") or data.get("id") or "unknown", - "status": data.get("status", "unknown"), - "children": data.get("children", []), - "parent": data.get("parent"), - } - ) - - return { - "developer": developer or "", - "git": { - "branch": branch, - "isClean": is_clean, - "uncommittedChanges": git_status_count, - "recentCommits": commits, - }, - "tasks": { - "active": tasks, - "directory": f"{DIR_WORKFLOW}/{DIR_TASKS}", - }, - "journal": { - "file": journal_relative, - "lines": journal_lines, - "nearLimit": journal_lines > 1800, - }, - } - - -def output_json(repo_root: Path | None = None) -> None: - """Output context in JSON format. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - """ - context = get_context_json(repo_root) - print(json.dumps(context, indent=2, ensure_ascii=False)) - - -# ============================================================================= -# Text Output -# ============================================================================= - - -def get_context_text(repo_root: Path | None = None) -> str: - """Get context as formatted text. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Formatted text output. - """ - if repo_root is None: - repo_root = get_repo_root() - - lines = [] - lines.append("========================================") - lines.append("SESSION CONTEXT") - lines.append("========================================") - lines.append("") - - developer = get_developer(repo_root) - - # Developer section - lines.append("## DEVELOPER") - if not developer: - lines.append( - f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>" - ) - return "\n".join(lines) - - lines.append(f"Name: {developer}") - lines.append("") - - # Git status - lines.append("## GIT STATUS") - _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root) - branch = branch_out.strip() or "unknown" - lines.append(f"Branch: {branch}") - - _, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root) - status_lines = [line for line in status_out.splitlines() if line.strip()] - status_count = len(status_lines) - - if status_count == 0: - lines.append("Working directory: Clean") - else: - lines.append(f"Working directory: {status_count} uncommitted change(s)") - lines.append("") - lines.append("Changes:") - _, short_out, _ = _run_git_command(["status", "--short"], cwd=repo_root) - for line in short_out.splitlines()[:10]: - lines.append(line) - lines.append("") - - # Recent commits - lines.append("## RECENT COMMITS") - _, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root) - if log_out.strip(): - for line in log_out.splitlines(): - lines.append(line) - else: - lines.append("(no commits)") - lines.append("") - - # Current task - lines.append("## CURRENT TASK") - current_task = get_current_task(repo_root) - if current_task: - current_task_dir = repo_root / current_task - task_json_path = current_task_dir / FILE_TASK_JSON - lines.append(f"Path: {current_task}") - - if task_json_path.is_file(): - data = _read_json_file(task_json_path) - if data: - t_name = data.get("name") or data.get("id") or "unknown" - t_status = data.get("status", "unknown") - t_created = data.get("createdAt", "unknown") - t_desc = data.get("description", "") - - lines.append(f"Name: {t_name}") - lines.append(f"Status: {t_status}") - lines.append(f"Created: {t_created}") - if t_desc: - lines.append(f"Description: {t_desc}") - - # Check for prd.md - prd_file = current_task_dir / "prd.md" - if prd_file.is_file(): - lines.append("") - lines.append("[!] This task has prd.md - read it for task details") - else: - lines.append("(none)") - lines.append("") - - # Active tasks - lines.append("## ACTIVE TASKS") - tasks_dir = get_tasks_dir(repo_root) - task_count = 0 - - # Collect all task data for hierarchy display - all_task_data: dict[str, dict] = {} - if tasks_dir.is_dir(): - for d in sorted(tasks_dir.iterdir()): - if d.is_dir() and d.name != "archive": - dir_name = d.name - t_json = d / FILE_TASK_JSON - status = "unknown" - assignee = "-" - children: list[str] = [] - parent: str | None = None - - if t_json.is_file(): - data = _read_json_file(t_json) - if data: - status = data.get("status", "unknown") - assignee = data.get("assignee", "-") - children = data.get("children", []) - parent = data.get("parent") - - all_task_data[dir_name] = { - "status": status, - "assignee": assignee, - "children": children, - "parent": parent, - } - - def _children_progress(children_list: list[str]) -> str: - if not children_list: - return "" - done = 0 - for c in children_list: - if c in all_task_data and all_task_data[c]["status"] in ("completed", "done"): - done += 1 - return f" [{done}/{len(children_list)} done]" - - def _print_task_tree(name: str, indent: int = 0) -> None: - nonlocal task_count - info = all_task_data[name] - progress = _children_progress(info["children"]) if info["children"] else "" - prefix = " " * indent - lines.append(f"{prefix}- {name}/ ({info['status']}){progress} @{info['assignee']}") - task_count += 1 - for child in info["children"]: - if child in all_task_data: - _print_task_tree(child, indent + 1) - - for dir_name in sorted(all_task_data.keys()): - if not all_task_data[dir_name]["parent"]: - _print_task_tree(dir_name) - - if task_count == 0: - lines.append("(no active tasks)") - lines.append(f"Total: {task_count} active task(s)") - lines.append("") - - # My tasks - lines.append("## MY TASKS (Assigned to me)") - my_task_count = 0 - - if tasks_dir.is_dir(): - for d in sorted(tasks_dir.iterdir()): - if d.is_dir() and d.name != "archive": - t_json = d / FILE_TASK_JSON - if t_json.is_file(): - data = _read_json_file(t_json) - if data: - assignee = data.get("assignee", "") - status = data.get("status", "planning") - - if assignee == developer and status != "done": - title = data.get("title") or data.get("name") or "unknown" - priority = data.get("priority", "P2") - children_list = data.get("children", []) - progress = _children_progress(children_list) if children_list else "" - lines.append(f"- [{priority}] {title} ({status}){progress}") - my_task_count += 1 - - if my_task_count == 0: - lines.append("(no tasks assigned to you)") - lines.append("") - - # Journal file - lines.append("## JOURNAL FILE") - journal_file = get_active_journal_file(repo_root) - if journal_file: - journal_lines = count_lines(journal_file) - relative = f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}" - lines.append(f"Active file: {relative}") - lines.append(f"Line count: {journal_lines} / 2000") - if journal_lines > 1800: - lines.append("[!] WARNING: Approaching 2000 line limit!") - else: - lines.append("No journal file found") - lines.append("") - - # Paths - lines.append("## PATHS") - lines.append(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/") - lines.append(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/") - lines.append(f"Spec: {DIR_WORKFLOW}/{DIR_SPEC}/") - lines.append("") - - lines.append("========================================") - - return "\n".join(lines) - - -def get_context_record_json(repo_root: Path | None = None) -> dict: - """Get record-mode context as a dictionary. - - Focused on: my active tasks, git status, current task. - """ - if repo_root is None: - repo_root = get_repo_root() - - developer = get_developer(repo_root) - tasks_dir = get_tasks_dir(repo_root) - - # Git info - _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root) - branch = branch_out.strip() or "unknown" - - _, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root) - git_status_count = len([line for line in status_out.splitlines() if line.strip()]) - - _, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root) - commits = [] - for line in log_out.splitlines(): - if line.strip(): - parts = line.split(" ", 1) - if len(parts) >= 2: - commits.append({"hash": parts[0], "message": parts[1]}) - - # My tasks - my_tasks = [] - all_task_statuses: dict[str, str] = {} - if tasks_dir.is_dir(): - for d in sorted(tasks_dir.iterdir()): - if d.is_dir() and d.name != "archive": - t_json = d / FILE_TASK_JSON - if t_json.is_file(): - data = _read_json_file(t_json) - if data: - all_task_statuses[d.name] = data.get("status", "unknown") - - if tasks_dir.is_dir(): - for d in sorted(tasks_dir.iterdir()): - if d.is_dir() and d.name != "archive": - t_json = d / FILE_TASK_JSON - if t_json.is_file(): - data = _read_json_file(t_json) - if data and data.get("assignee") == developer: - children_list = data.get("children", []) - done = sum(1 for c in children_list if all_task_statuses.get(c) in ("completed", "done")) - my_tasks.append({ - "dir": d.name, - "title": data.get("title") or data.get("name") or "unknown", - "status": data.get("status", "unknown"), - "priority": data.get("priority", "P2"), - "children": children_list, - "childrenDone": done, - "parent": data.get("parent"), - "meta": data.get("meta", {}), - }) - - # Current task - current_task_info = None - current_task = get_current_task(repo_root) - if current_task: - task_json_path = (repo_root / current_task) / FILE_TASK_JSON - if task_json_path.is_file(): - data = _read_json_file(task_json_path) - if data: - current_task_info = { - "path": current_task, - "name": data.get("name") or data.get("id") or "unknown", - "status": data.get("status", "unknown"), - } - - return { - "developer": developer or "", - "git": { - "branch": branch, - "isClean": git_status_count == 0, - "uncommittedChanges": git_status_count, - "recentCommits": commits, - }, - "myTasks": my_tasks, - "currentTask": current_task_info, - } - - -def get_context_text_record(repo_root: Path | None = None) -> str: - """Get context as formatted text for record-session mode. - - Focused output: MY ACTIVE TASKS first (with [!!!] emphasis), - then GIT STATUS, RECENT COMMITS, CURRENT TASK. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Formatted text output for record-session. - """ - if repo_root is None: - repo_root = get_repo_root() - - lines: list[str] = [] - lines.append("========================================") - lines.append("SESSION CONTEXT (RECORD MODE)") - lines.append("========================================") - lines.append("") - - developer = get_developer(repo_root) - if not developer: - lines.append( - f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>" - ) - return "\n".join(lines) - - # MY ACTIVE TASKS — first and prominent - lines.append(f"## [!!!] MY ACTIVE TASKS (Assigned to {developer})") - lines.append("[!] Review whether any should be archived before recording this session.") - lines.append("") - - tasks_dir = get_tasks_dir(repo_root) - my_task_count = 0 - - # Collect task data for children progress - all_task_statuses: dict[str, str] = {} - if tasks_dir.is_dir(): - for d in sorted(tasks_dir.iterdir()): - if d.is_dir() and d.name != "archive": - t_json = d / FILE_TASK_JSON - if t_json.is_file(): - data = _read_json_file(t_json) - if data: - all_task_statuses[d.name] = data.get("status", "unknown") - - def _record_children_progress(children_list: list[str]) -> str: - if not children_list: - return "" - done = 0 - for c in children_list: - if all_task_statuses.get(c) in ("completed", "done"): - done += 1 - return f" [{done}/{len(children_list)} done]" - - if tasks_dir.is_dir(): - for d in sorted(tasks_dir.iterdir()): - if d.is_dir() and d.name != "archive": - t_json = d / FILE_TASK_JSON - if t_json.is_file(): - data = _read_json_file(t_json) - if data: - assignee = data.get("assignee", "") - status = data.get("status", "planning") - - if assignee == developer: - title = data.get("title") or data.get("name") or "unknown" - priority = data.get("priority", "P2") - children_list = data.get("children", []) - progress = _record_children_progress(children_list) if children_list else "" - lines.append(f"- [{priority}] {title} ({status}){progress} — {d.name}") - my_task_count += 1 - - if my_task_count == 0: - lines.append("(no active tasks assigned to you)") - lines.append("") - - # GIT STATUS - lines.append("## GIT STATUS") - _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root) - branch = branch_out.strip() or "unknown" - lines.append(f"Branch: {branch}") - - _, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root) - status_lines = [line for line in status_out.splitlines() if line.strip()] - status_count = len(status_lines) - - if status_count == 0: - lines.append("Working directory: Clean") - else: - lines.append(f"Working directory: {status_count} uncommitted change(s)") - lines.append("") - lines.append("Changes:") - _, short_out, _ = _run_git_command(["status", "--short"], cwd=repo_root) - for line in short_out.splitlines()[:10]: - lines.append(line) - lines.append("") - - # RECENT COMMITS - lines.append("## RECENT COMMITS") - _, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root) - if log_out.strip(): - for line in log_out.splitlines(): - lines.append(line) - else: - lines.append("(no commits)") - lines.append("") - - # CURRENT TASK - lines.append("## CURRENT TASK") - current_task = get_current_task(repo_root) - if current_task: - current_task_dir = repo_root / current_task - task_json_path = current_task_dir / FILE_TASK_JSON - lines.append(f"Path: {current_task}") - - if task_json_path.is_file(): - data = _read_json_file(task_json_path) - if data: - t_name = data.get("name") or data.get("id") or "unknown" - t_status = data.get("status", "unknown") - lines.append(f"Name: {t_name}") - lines.append(f"Status: {t_status}") - else: - lines.append("(none)") - lines.append("") - - lines.append("========================================") - - return "\n".join(lines) - - -def output_text(repo_root: Path | None = None) -> None: - """Output context in text format. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - """ - print(get_context_text(repo_root)) +# Backward-compatible alias — external modules import this name +_run_git_command = run_git # ============================================================================= # Main Entry # ============================================================================= - def main() -> None: """CLI entry point.""" import argparse @@ -618,9 +55,17 @@ def main() -> None: parser.add_argument( "--mode", "-m", - choices=["default", "record"], + choices=["default", "record", "packages", "phase"], default="default", - help="Output mode: default (full context) or record (for record-session)", + help="Output mode: default (full context), record (for record-session), packages (package info only), phase (workflow step extraction)", + ) + parser.add_argument( + "--step", + help="Step id for --mode phase, e.g. 1.1, 2.2. Omit to get the Phase Index.", + ) + parser.add_argument( + "--platform", + help="Platform name for --mode phase, e.g. cursor, claude-code. Filters platform-tagged blocks.", ) args = parser.parse_args() @@ -630,6 +75,21 @@ def main() -> None: print(json.dumps(get_context_record_json(), indent=2, ensure_ascii=False)) else: print(get_context_text_record()) + elif args.mode == "packages": + if args.json: + print(json.dumps(get_context_packages_json(), indent=2, ensure_ascii=False)) + else: + print(get_context_packages_text()) + elif args.mode == "phase": + content = get_step(args.step) if args.step else get_phase_index() + if not content.strip(): + if args.step: + parser.exit(2, f"Step not found: {args.step}\n") + else: + parser.exit(2, "Phase Index section not found in workflow.md\n") + if args.platform: + content = filter_platform(content, args.platform) + print(content, end="") else: if args.json: output_json() diff --git a/.trellis/scripts/common/io.py b/.trellis/scripts/common/io.py new file mode 100755 index 0000000..44288f4 --- /dev/null +++ b/.trellis/scripts/common/io.py @@ -0,0 +1,37 @@ +""" +JSON file I/O utilities. + +Provides read_json and write_json as the single source of truth +for JSON file operations across all Trellis scripts. +""" + +from __future__ import annotations + +import json +from pathlib import Path + + +def read_json(path: Path) -> dict | None: + """Read and parse a JSON file. + + Returns None if the file doesn't exist, is invalid JSON, or can't be read. + """ + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + + +def write_json(path: Path, data: dict) -> bool: + """Write dict to JSON file with pretty formatting. + + Returns True on success, False on error. + """ + try: + path.write_text( + json.dumps(data, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + return True + except (OSError, IOError): + return False diff --git a/.trellis/scripts/common/log.py b/.trellis/scripts/common/log.py new file mode 100755 index 0000000..839c643 --- /dev/null +++ b/.trellis/scripts/common/log.py @@ -0,0 +1,45 @@ +""" +Terminal output utilities: colors and structured logging. + +Single source of truth for Colors and log_* functions +used across all Trellis scripts. +""" + +from __future__ import annotations + + +class Colors: + """ANSI color codes for terminal output.""" + + RED = "\033[0;31m" + GREEN = "\033[0;32m" + YELLOW = "\033[1;33m" + BLUE = "\033[0;34m" + CYAN = "\033[0;36m" + DIM = "\033[2m" + NC = "\033[0m" # No Color / Reset + + +def colored(text: str, color: str) -> str: + """Apply ANSI color to text.""" + return f"{color}{text}{Colors.NC}" + + +def log_info(msg: str) -> None: + """Print info-level message with [INFO] prefix.""" + print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}") + + +def log_success(msg: str) -> None: + """Print success message with [SUCCESS] prefix.""" + print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}") + + +def log_warn(msg: str) -> None: + """Print warning message with [WARN] prefix.""" + print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}") + + +def log_error(msg: str) -> None: + """Print error message with [ERROR] prefix.""" + print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}") diff --git a/.trellis/scripts/common/packages_context.py b/.trellis/scripts/common/packages_context.py new file mode 100755 index 0000000..e7d4e8c --- /dev/null +++ b/.trellis/scripts/common/packages_context.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +""" +Package discovery and context output. + +Provides: + get_packages_info - Get structured package info + get_packages_section - Build PACKAGES text section + get_context_packages_text - Full packages text output (--mode packages) + get_context_packages_json - Full packages JSON output (--mode packages --json) +""" + +from __future__ import annotations + +from pathlib import Path + +from .config import _is_true_config_value, get_default_package, get_packages, get_spec_scope +from .paths import ( + DIR_SPEC, + DIR_WORKFLOW, + get_current_task, + get_repo_root, +) +from .tasks import load_task + + +# ============================================================================= +# Internal Helpers +# ============================================================================= + +def _scan_spec_layers(spec_dir: Path, package: str | None = None) -> list[str]: + """Scan spec directory for available layers (subdirectories). + + For monorepo: scans spec/<package>/ + For single-repo: scans spec/ + """ + target = spec_dir / package if package else spec_dir + if not target.is_dir(): + return [] + return sorted( + d.name for d in target.iterdir() if d.is_dir() and d.name != "guides" + ) + + +def _get_active_task_package(repo_root: Path) -> str | None: + """Get the package field from the active task's task.json.""" + current = get_current_task(repo_root) + if not current: + return None + ct = load_task(repo_root / current) + return ct.package if ct and ct.package else None + + +def _resolve_scope_set( + packages: dict, + spec_scope, + task_pkg: str | None, + default_pkg: str | None, +) -> set | None: + """Resolve spec_scope to a set of allowed package names, or None for full scan.""" + if not packages: + return None + + if spec_scope is None: + return None + + if isinstance(spec_scope, str) and spec_scope == "active_task": + if task_pkg and task_pkg in packages: + return {task_pkg} + if default_pkg and default_pkg in packages: + return {default_pkg} + return None + + if isinstance(spec_scope, list): + valid = {e for e in spec_scope if e in packages} + if valid: + return valid + # All invalid: fallback + if task_pkg and task_pkg in packages: + return {task_pkg} + if default_pkg and default_pkg in packages: + return {default_pkg} + return None + + return None + + +# ============================================================================= +# Public Functions +# ============================================================================= + +def get_packages_info(repo_root: Path) -> list[dict]: + """Get structured package info for monorepo projects. + + Returns list of dicts with keys: name, path, type, default, specLayers, + isSubmodule, isGitRepo. + Returns empty list for single-repo projects. + """ + packages = get_packages(repo_root) + if not packages: + return [] + + default_pkg = get_default_package(repo_root) + spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC + result = [] + + for pkg_name, pkg_config in packages.items(): + pkg_path = pkg_config.get("path", pkg_name) if isinstance(pkg_config, dict) else str(pkg_config) + pkg_type = pkg_config.get("type", "local") if isinstance(pkg_config, dict) else "local" + pkg_git = pkg_config.get("git", False) if isinstance(pkg_config, dict) else False + layers = _scan_spec_layers(spec_dir, pkg_name) + + result.append({ + "name": pkg_name, + "path": pkg_path, + "type": pkg_type, + "default": pkg_name == default_pkg, + "specLayers": layers, + "isSubmodule": pkg_type == "submodule", + "isGitRepo": _is_true_config_value(pkg_git), + }) + + return result + + +def get_packages_section(repo_root: Path) -> str: + """Build the PACKAGES section for text output.""" + spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC + pkg_info = get_packages_info(repo_root) + + lines: list[str] = [] + lines.append("## PACKAGES") + + if not pkg_info: + lines.append("(single-repo mode)") + layers = _scan_spec_layers(spec_dir) + if layers: + lines.append(f"Spec layers: {', '.join(layers)}") + return "\n".join(lines) + + default_pkg = get_default_package(repo_root) + + for pkg in pkg_info: + layers_str = f" [{', '.join(pkg['specLayers'])}]" if pkg["specLayers"] else "" + submodule_tag = " (submodule)" if pkg["isSubmodule"] else "" + git_repo_tag = " (git repo)" if pkg["isGitRepo"] else "" + default_tag = " *" if pkg["default"] else "" + lines.append( + f"- {pkg['name']:<16} {pkg['path']:<20}{layers_str}{submodule_tag}{git_repo_tag}{default_tag}" + ) + + if default_pkg: + lines.append(f"Default package: {default_pkg}") + + return "\n".join(lines) + + +def get_context_packages_text(repo_root: Path | None = None) -> str: + """Get packages context as formatted text (for --mode packages).""" + if repo_root is None: + repo_root = get_repo_root() + + pkg_info = get_packages_info(repo_root) + lines: list[str] = [] + + if not pkg_info: + spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC + lines.append("Single-repo project (no packages configured)") + lines.append("") + layers = _scan_spec_layers(spec_dir) + if layers: + lines.append(f"Spec layers: {', '.join(layers)}") + return "\n".join(lines) + + # Resolve scope for annotations + packages_dict = get_packages(repo_root) or {} + default_pkg = get_default_package(repo_root) + spec_scope = get_spec_scope(repo_root) + task_pkg = _get_active_task_package(repo_root) + scope_set = _resolve_scope_set(packages_dict, spec_scope, task_pkg, default_pkg) + + lines.append("## PACKAGES") + lines.append("") + for pkg in pkg_info: + default_tag = " (default)" if pkg["default"] else "" + type_tag = f" [{pkg['type']}]" if pkg["type"] != "local" else "" + git_tag = " [git repo]" if pkg["isGitRepo"] else "" + + # Scope annotation + scope_tag = "" + if scope_set is not None and pkg["name"] not in scope_set: + scope_tag = " (out of scope)" + + lines.append(f"### {pkg['name']}{default_tag}{type_tag}{git_tag}{scope_tag}") + lines.append(f"Path: {pkg['path']}") + if pkg["specLayers"]: + lines.append(f"Spec layers: {', '.join(pkg['specLayers'])}") + for layer in pkg["specLayers"]: + lines.append(f" - .trellis/spec/{pkg['name']}/{layer}/index.md") + else: + lines.append("Spec: not configured") + lines.append("") + + # Also show shared guides + guides_dir = repo_root / DIR_WORKFLOW / DIR_SPEC / "guides" + if guides_dir.is_dir(): + lines.append("### Shared Guides (always included)") + lines.append("Path: .trellis/spec/guides/index.md") + lines.append("") + + return "\n".join(lines) + + +def get_context_packages_json(repo_root: Path | None = None) -> dict: + """Get packages context as a dictionary (for --mode packages --json).""" + if repo_root is None: + repo_root = get_repo_root() + + pkg_info = get_packages_info(repo_root) + + if not pkg_info: + spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC + layers = _scan_spec_layers(spec_dir) + return { + "mode": "single-repo", + "specLayers": layers, + } + + default_pkg = get_default_package(repo_root) + spec_scope = get_spec_scope(repo_root) + task_pkg = _get_active_task_package(repo_root) + + return { + "mode": "monorepo", + "packages": pkg_info, + "defaultPackage": default_pkg, + "specScope": spec_scope, + "activeTaskPackage": task_pkg, + } diff --git a/.trellis/scripts/common/paths.py b/.trellis/scripts/common/paths.py index dcbb66b..1c5a58e 100755 --- a/.trellis/scripts/common/paths.py +++ b/.trellis/scripts/common/paths.py @@ -207,21 +207,55 @@ def count_lines(file_path: Path) -> int: # Current Task Management # ============================================================================= -def _get_current_task_file(repo_root: Path | None = None) -> Path: - """Get .current-task file path. +def normalize_task_ref(task_ref: str) -> str: + """Normalize a task ref for stable runtime storage. - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Path to .current-task file. + Stored refs should prefer repo-relative POSIX paths like + `.trellis/tasks/03-27-my-task`, even on Windows. Absolute paths are preserved + unless they can later be converted back to repo-relative form by callers. """ + normalized = task_ref.strip() + if not normalized: + return "" + + path_obj = Path(normalized) + if path_obj.is_absolute(): + return str(path_obj) + + normalized = normalized.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + + if normalized.startswith(f"{DIR_TASKS}/"): + return f"{DIR_WORKFLOW}/{normalized}" + + return normalized + + +def resolve_task_ref(task_ref: str, repo_root: Path | None = None) -> Path | None: + """Resolve a task ref to an absolute task directory path.""" if repo_root is None: repo_root = get_repo_root() - return repo_root / DIR_WORKFLOW / FILE_CURRENT_TASK + + normalized = normalize_task_ref(task_ref) + if not normalized: + return None + + path_obj = Path(normalized) + if path_obj.is_absolute(): + return path_obj + + if normalized.startswith(f"{DIR_WORKFLOW}/"): + return repo_root / path_obj + + return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj -def get_current_task(repo_root: Path | None = None) -> str | None: +def get_current_task( + repo_root: Path | None = None, + platform_input: dict | None = None, + platform: str | None = None, +) -> str | None: """Get current task directory path (relative to repo_root). Args: @@ -230,18 +264,19 @@ def get_current_task(repo_root: Path | None = None) -> str | None: Returns: Relative path to current task directory or None. """ - current_file = _get_current_task_file(repo_root) + if repo_root is None: + repo_root = get_repo_root() - if not current_file.is_file(): - return None + from .active_task import resolve_active_task - try: - return current_file.read_text(encoding="utf-8").strip() - except (OSError, IOError): - return None + return resolve_active_task(repo_root, platform_input, platform).task_path -def get_current_task_abs(repo_root: Path | None = None) -> Path | None: +def get_current_task_abs( + repo_root: Path | None = None, + platform_input: dict | None = None, + platform: str | None = None, +) -> Path | None: """Get current task directory absolute path. Args: @@ -253,14 +288,33 @@ def get_current_task_abs(repo_root: Path | None = None) -> Path | None: if repo_root is None: repo_root = get_repo_root() - relative = get_current_task(repo_root) + relative = get_current_task(repo_root, platform_input, platform) if relative: - return repo_root / relative + return resolve_task_ref(relative, repo_root) return None -def set_current_task(task_path: str, repo_root: Path | None = None) -> bool: - """Set current task. +def get_current_task_source( + repo_root: Path | None = None, + platform_input: dict | None = None, + platform: str | None = None, +) -> tuple[str, str | None, str | None]: + """Get active task source as (`source`, `context_key`, `task_path`).""" + if repo_root is None: + repo_root = get_repo_root() + + from .active_task import get_current_task_source as _get_source + + return _get_source(repo_root, platform_input, platform) + + +def set_current_task( + task_path: str, + repo_root: Path | None = None, + platform_input: dict | None = None, + platform: str | None = None, +) -> bool: + """Set current task in session scope. Args: task_path: Task directory path (relative to repo_root). @@ -272,25 +326,22 @@ def set_current_task(task_path: str, repo_root: Path | None = None) -> bool: if repo_root is None: repo_root = get_repo_root() - if not task_path: - return False + from .active_task import set_active_task - # Verify task directory exists - full_path = repo_root / task_path - if not full_path.is_dir(): - return False - - current_file = _get_current_task_file(repo_root) - - try: - current_file.write_text(task_path, encoding="utf-8") - return True - except (OSError, IOError): - return False + return set_active_task( + task_path, + repo_root, + platform_input=platform_input, + platform=platform, + ) is not None -def clear_current_task(repo_root: Path | None = None) -> bool: - """Clear current task. +def clear_current_task( + repo_root: Path | None = None, + platform_input: dict | None = None, + platform: str | None = None, +) -> bool: + """Clear current task in session scope. Args: repo_root: Repository root path. Defaults to auto-detected. @@ -298,14 +349,17 @@ def clear_current_task(repo_root: Path | None = None) -> bool: Returns: True on success. """ - current_file = _get_current_task_file(repo_root) + if repo_root is None: + repo_root = get_repo_root() - try: - if current_file.is_file(): - current_file.unlink() - return True - except (OSError, IOError): - return False + from .active_task import clear_active_task + + clear_active_task( + repo_root, + platform_input=platform_input, + platform=platform, + ) + return True def has_current_task(repo_root: Path | None = None) -> bool: @@ -333,6 +387,52 @@ def generate_task_date_prefix() -> str: return datetime.now().strftime("%m-%d") +# ============================================================================= +# Monorepo / Package Paths +# ============================================================================= + + +def get_spec_dir(package: str | None = None, repo_root: Path | None = None) -> Path: + """Get the spec directory path. + + Single-repo: .trellis/spec + Monorepo with package: .trellis/spec/<package> + + Uses lazy import to avoid circular dependency with config.py. + """ + if repo_root is None: + repo_root = get_repo_root() + + from .config import get_spec_base + + base = get_spec_base(package, repo_root) + return repo_root / DIR_WORKFLOW / base + + +def get_package_path(package: str, repo_root: Path | None = None) -> Path | None: + """Get a package's source directory absolute path from config. + + Returns: + Absolute path to the package directory, or None if not found. + """ + if repo_root is None: + repo_root = get_repo_root() + + from .config import get_packages + + packages = get_packages(repo_root) + if not packages or package not in packages: + return None + + info = packages[package] + if isinstance(info, dict): + rel_path = info.get("path", package) + else: + rel_path = str(info) + + return repo_root / rel_path + + # ============================================================================= # Main Entry (for testing) # ============================================================================= diff --git a/.trellis/scripts/common/session_context.py b/.trellis/scripts/common/session_context.py new file mode 100755 index 0000000..65cd0b8 --- /dev/null +++ b/.trellis/scripts/common/session_context.py @@ -0,0 +1,574 @@ +#!/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_current_task_source, + 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 + source_type, context_key, _ = get_current_task_source(repo_root) + lines.append(f"Path: {current_task}") + lines.append( + f"Source: {source_type}" + (f":{context_key}" if context_key else "") + ) + + 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: + source_type, context_key, _ = get_current_task_source(repo_root) + ct = load_task(repo_root / current_task) + if ct: + current_task_info = { + "path": current_task, + "name": ct.name, + "status": ct.status, + "source": source_type, + "contextKey": context_key, + } + + # 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: + source_type, context_key, _ = get_current_task_source(repo_root) + lines.append(f"Path: {current_task}") + lines.append( + f"Source: {source_type}" + (f":{context_key}" if context_key else "") + ) + ct = load_task(repo_root / current_task) + if ct: + lines.append(f"Name: {ct.name}") + lines.append(f"Status: {ct.status}") + else: + lines.append("(none)") + lines.append("") + + lines.append("========================================") + + return "\n".join(lines) + + +def output_text(repo_root: Path | None = None) -> None: + """Output context in text format. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + """ + print(get_context_text(repo_root)) diff --git a/.trellis/scripts/common/task_context.py b/.trellis/scripts/common/task_context.py new file mode 100755 index 0000000..fa88412 --- /dev/null +++ b/.trellis/scripts/common/task_context.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +""" +Task JSONL context management. + +Provides: + cmd_add_context - Add entry to JSONL context file + cmd_validate - Validate JSONL context files + cmd_list_context - List JSONL context entries + +Note: + ``cmd_init_context`` was removed in v0.5.0-beta.12. JSONL context files + are now seeded at ``task.py create`` time with a self-describing + ``_example`` line; the AI agent curates real entries during Phase 1.3 of + the workflow. See ``.trellis/workflow.md`` Phase 1.3 for the current + instructions. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from .log import Colors, colored +from .paths import get_repo_root +from .task_utils import resolve_task_dir + + +# ============================================================================= +# 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"]: + 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. + + Seed rows (no ``file`` field — typically ``{"_example": "..."}``) are + skipped silently; they are self-describing comments, not real entries. + """ + 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 + real_entries = 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: + # Seed / comment row — skip silently + continue + + real_entries += 1 + 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}: ✓ ({real_entries} 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"]: + jsonl_file = target_dir / jsonl_name + if not jsonl_file.is_file(): + continue + + print(colored(f"[{jsonl_name}]", Colors.CYAN)) + + count = 0 + seed_only = True + 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 + + file_path = data.get("file") + if not file_path: + # Seed / comment row — don't count as a real entry + continue + seed_only = False + + count += 1 + 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}") + + if seed_only: + print(f" {colored('(no curated entries yet — only seed row)', Colors.YELLOW)}") + + print() + + return 0 diff --git a/.trellis/scripts/common/task_queue.py b/.trellis/scripts/common/task_queue.py index 70378a1..f7485e2 100755 --- a/.trellis/scripts/common/task_queue.py +++ b/.trellis/scripts/common/task_queue.py @@ -12,23 +12,32 @@ Provides: from __future__ import annotations -import json from pathlib import Path from .paths import ( - FILE_TASK_JSON, get_repo_root, get_developer, get_tasks_dir, ) +from .tasks import iter_active_tasks -def _read_json_file(path: Path) -> dict | None: - """Read and parse a JSON file.""" - try: - return json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return None +# ============================================================================= +# Internal helper +# ============================================================================= + +def _task_to_dict(t) -> dict: + """Convert TaskInfo to the dict format callers expect.""" + return { + "priority": t.priority, + "id": t.raw.get("id", ""), + "title": t.title, + "status": t.status, + "assignee": t.assignee or "-", + "dir": t.dir_name, + "children": list(t.children), + "parent": t.parent, + } # ============================================================================= @@ -54,41 +63,10 @@ def list_tasks_by_status( tasks_dir = get_tasks_dir(repo_root) results = [] - if not tasks_dir.is_dir(): - return results - - for d in tasks_dir.iterdir(): - if not d.is_dir() or d.name == "archive": + for t in iter_active_tasks(tasks_dir): + if filter_status and t.status != filter_status: continue - - task_json = d / FILE_TASK_JSON - if not task_json.is_file(): - continue - - data = _read_json_file(task_json) - if not data: - continue - - task_id = data.get("id", "") - title = data.get("title") or data.get("name", "") - priority = data.get("priority", "P2") - status = data.get("status", "planning") - assignee = data.get("assignee", "-") - - # Apply filter - if filter_status and status != filter_status: - continue - - results.append({ - "priority": priority, - "id": task_id, - "title": title, - "status": status, - "assignee": assignee, - "dir": d.name, - "children": data.get("children", []), - "parent": data.get("parent"), - }) + results.append(_task_to_dict(t)) return results @@ -126,46 +104,12 @@ def list_tasks_by_assignee( tasks_dir = get_tasks_dir(repo_root) results = [] - if not tasks_dir.is_dir(): - return results - - for d in tasks_dir.iterdir(): - if not d.is_dir() or d.name == "archive": + for t in iter_active_tasks(tasks_dir): + if (t.assignee or "-") != assignee: continue - - task_json = d / FILE_TASK_JSON - if not task_json.is_file(): + if filter_status and t.status != filter_status: continue - - data = _read_json_file(task_json) - if not data: - continue - - task_assignee = data.get("assignee", "-") - - # Apply assignee filter - if task_assignee != assignee: - continue - - task_id = data.get("id", "") - title = data.get("title") or data.get("name", "") - priority = data.get("priority", "P2") - status = data.get("status", "planning") - - # Apply status filter - if filter_status and status != filter_status: - continue - - results.append({ - "priority": priority, - "id": task_id, - "title": title, - "status": status, - "assignee": task_assignee, - "dir": d.name, - "children": data.get("children", []), - "parent": data.get("parent"), - }) + results.append(_task_to_dict(t)) return results @@ -211,24 +155,9 @@ def get_task_stats(repo_root: Path | None = None) -> dict[str, int]: tasks_dir = get_tasks_dir(repo_root) stats = {"P0": 0, "P1": 0, "P2": 0, "P3": 0, "Total": 0} - if not tasks_dir.is_dir(): - return stats - - for d in tasks_dir.iterdir(): - if not d.is_dir() or d.name == "archive": - continue - - task_json = d / FILE_TASK_JSON - if not task_json.is_file(): - continue - - data = _read_json_file(task_json) - if not data: - continue - - priority = data.get("priority", "P2") - if priority in stats: - stats[priority] += 1 + for t in iter_active_tasks(tasks_dir): + if t.priority in stats: + stats[t.priority] += 1 stats["Total"] += 1 return stats diff --git a/.trellis/scripts/common/task_store.py b/.trellis/scripts/common/task_store.py new file mode 100755 index 0000000..6a628b1 --- /dev/null +++ b/.trellis/scripts/common/task_store.py @@ -0,0 +1,600 @@ +#!/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 json +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, + generate_task_date_prefix, + 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 + + +# ============================================================================= +# Sub-agent platform detection + JSONL seeding +# ============================================================================= + +# Config directories of platforms that consume implement.jsonl / check.jsonl. +# Keep in sync with src/types/ai-tools.ts AI_TOOLS entries — these are the +# platforms listed in workflow.md's "agent-capable" Skill Routing block +# (Class-1 hook-inject + Class-2 pull-based preludes). Kilo / Antigravity / +# Windsurf are NOT in this list: they do not consume JSONL. +_SUBAGENT_CONFIG_DIRS: tuple[str, ...] = ( + ".claude", + ".cursor", + ".codex", + ".kiro", + ".gemini", + ".opencode", + ".qoder", + ".codebuddy", + ".factory", # Factory Droid + ".github/copilot", + ".pi", # Pi Agent +) + +_SEED_EXAMPLE = ( + "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. " + "Put spec/research files only — no code paths. " + "Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. " + "Delete this line once real entries are added." +) + + +def _has_subagent_platform(repo_root: Path) -> bool: + """Return True if any sub-agent-capable platform is configured. + + Detected by probing well-known config directories at the repo root. Used + only to decide whether ``task.py create`` should seed empty + ``implement.jsonl`` / ``check.jsonl`` files. + """ + for config_dir in _SUBAGENT_CONFIG_DIRS: + if (repo_root / config_dir).is_dir(): + return True + return False + + +def _write_seed_jsonl(path: Path) -> None: + """Write a one-line seed JSONL file with a self-describing ``_example``. + + The seed row has no ``file`` field, so downstream consumers (hooks + + preludes) that iterate entries via ``item.get("file")`` naturally skip + it. The row exists purely as an in-file prompt for the AI curator. + """ + seed = {"_example": _SEED_EXAMPLE} + path.write_text(json.dumps(seed, ensure_ascii=False) + "\n", encoding="utf-8") + + +# ============================================================================= +# 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, + "commit": None, + "pr_url": None, + "subtasks": [], + "children": [], + "parent": None, + "relatedFiles": [], + "notes": "", + "meta": {}, + } + + write_json(task_json_path, task_data) + + # Seed implement.jsonl / check.jsonl for sub-agent-capable platforms. + # Agent curates real entries in Phase 1.3 (see .trellis/workflow.md). + # Agent-less platforms (Kilo / Antigravity / Windsurf) skip this — they + # load specs via the trellis-before-dev skill instead of JSONL. + seeded_jsonl = False + if _has_subagent_platform(repo_root): + for jsonl_name in ("implement.jsonl", "check.jsonl"): + jsonl_path = task_dir / jsonl_name + if not jsonl_path.exists(): + _write_seed_jsonl(jsonl_path) + seeded_jsonl = True + + # 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) + + # Auto-activate the new task so the per-turn breadcrumb fires planning + # state. Best-effort: gracefully degrade if no session identity (CLI run + # outside an AI session) — the task is still created, the user can run + # task.py start later. Pointer is session-scoped so this never affects + # other AI sessions. + try: + from .active_task import resolve_context_key, set_active_task + if resolve_context_key(): + try: + rel_dir = task_dir.relative_to(repo_root).as_posix() + except ValueError: + rel_dir = str(task_dir) + set_active_task(rel_dir, repo_root) + except Exception: + pass + + 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) + if seeded_jsonl: + print( + " 2. Curate implement.jsonl / check.jsonl (spec + research files only — " + "see .trellis/workflow.md Phase 1.3)", + file=sys.stderr, + ) + print(" 3. Run: python3 task.py start <dir>", file=sys.stderr) + else: + print(" 2. 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) + + # Resolve task directory (supports task name, relative path, or absolute path) + task_dir = resolve_task_dir(task_name, repo_root) + + 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. + # Keep this task in its parent's children list so progress + # counters (children_progress) stay consistent — children + # missing from the active set are treated as completed. + task_children = data.get("children", []) + + # 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 any session that still points at this task before the path moves. + from .active_task import clear_task_from_sessions + clear_task_from_sessions(str(task_dir), 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)) + return 0 + + +# ============================================================================= +# Command: set-base-branch +# ============================================================================= + +def cmd_set_base_branch(args: argparse.Namespace) -> int: + """Set the base branch (PR target) for task.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + base_branch = args.base_branch + + if not base_branch: + print(colored("Error: Missing arguments", Colors.RED)) + print("Usage: python3 task.py set-base-branch <task-dir> <base-branch>") + print("Example: python3 task.py set-base-branch <dir> develop") + print() + print("This sets the target branch for PR (the branch your feature will merge into).") + return 1 + + task_json = target_dir / FILE_TASK_JSON + if not task_json.is_file(): + print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) + return 1 + + data = read_json(task_json) + if not data: + return 1 + + data["base_branch"] = base_branch + write_json(task_json, data) + + print(colored(f"✓ Base branch set to: {base_branch}", Colors.GREEN)) + print(f" PR will target: {base_branch}") + return 0 + + +# ============================================================================= +# Command: set-scope +# ============================================================================= + +def cmd_set_scope(args: argparse.Namespace) -> int: + """Set scope for PR title.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + scope = args.scope + + if not scope: + print(colored("Error: Missing arguments", Colors.RED)) + print("Usage: python3 task.py set-scope <task-dir> <scope>") + return 1 + + task_json = target_dir / FILE_TASK_JSON + if not task_json.is_file(): + print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) + return 1 + + data = read_json(task_json) + if not data: + return 1 + + data["scope"] = scope + write_json(task_json, data) + + print(colored(f"✓ Scope set to: {scope}", Colors.GREEN)) + return 0 diff --git a/.trellis/scripts/common/task_utils.py b/.trellis/scripts/common/task_utils.py index 84df2fa..62c215e 100755 --- a/.trellis/scripts/common/task_utils.py +++ b/.trellis/scripts/common/task_utils.py @@ -3,9 +3,11 @@ Task utility functions. Provides: - is_safe_task_path - Validate task path is safe to operate on - find_task_by_name - Find task directory by name - archive_task_dir - Archive task to monthly directory + is_safe_task_path - Validate task path is safe to operate on + find_task_by_name - Find task directory by name + resolve_task_dir - Resolve task directory from name, relative, or absolute path + archive_task_dir - Archive task to monthly directory + run_task_hooks - Run lifecycle hooks for task events """ from __future__ import annotations @@ -15,7 +17,7 @@ import sys from datetime import datetime from pathlib import Path -from .paths import get_repo_root +from .paths import get_repo_root, get_tasks_dir # ============================================================================= @@ -35,23 +37,25 @@ def is_safe_task_path(task_path: str, repo_root: Path | None = None) -> bool: if repo_root is None: repo_root = get_repo_root() + normalized = task_path.replace("\\", "/") + # Check empty or null - if not task_path or task_path == "null": + if not normalized or normalized == "null": print("Error: empty or null task path", file=sys.stderr) return False # Reject absolute paths - if task_path.startswith("/"): + if Path(task_path).is_absolute(): print(f"Error: absolute path not allowed: {task_path}", file=sys.stderr) return False # Reject ".", "..", paths starting with "./" or "../", or containing ".." - if task_path in (".", "..") or task_path.startswith("./") or task_path.startswith("../") or ".." in task_path: + if normalized in (".", "..") or normalized.startswith("./") or normalized.startswith("../") or ".." in normalized: print(f"Error: path traversal not allowed: {task_path}", file=sys.stderr) return False # Final check: ensure resolved path is not the repo root - abs_path = repo_root / task_path + abs_path = repo_root / Path(normalized) if abs_path.exists(): try: resolved = abs_path.resolve() @@ -163,13 +167,105 @@ def archive_task_complete( return {} +# ============================================================================= +# Task Directory Resolution +# ============================================================================= + +def resolve_task_dir(target_dir: str, repo_root: Path) -> Path: + """Resolve task directory to absolute path. + + Supports: + - Absolute path: /path/to/task + - Relative path: .trellis/tasks/01-31-my-task + - Task name: my-task (uses find_task_by_name for lookup) + + Args: + target_dir: Task directory specification. + repo_root: Repository root path. + + Returns: + Resolved absolute path. + """ + if not target_dir: + return Path() + + normalized = target_dir.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + + # Absolute path + if Path(target_dir).is_absolute(): + return Path(target_dir) + + # Relative path (contains path separator or starts with .trellis) + if "/" in normalized or normalized.startswith(".trellis"): + return repo_root / Path(normalized) + + # Task name - try to find in tasks directory + tasks_dir = get_tasks_dir(repo_root) + found = find_task_by_name(target_dir, tasks_dir) + if found: + return found + + # Fallback to treating as relative path + return repo_root / Path(normalized) + + +# ============================================================================= +# Lifecycle Hooks +# ============================================================================= + +def run_task_hooks(event: str, task_json_path: Path, repo_root: Path) -> None: + """Run lifecycle hooks for a task event. + + Args: + event: Event name (e.g. "after_create"). + task_json_path: Absolute path to the task's task.json. + repo_root: Repository root for cwd and config lookup. + """ + import os + import subprocess + + from .config import get_hooks + from .log import Colors, colored + + commands = get_hooks(event, repo_root) + if not commands: + return + + env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)} + + for cmd in commands: + try: + result = subprocess.run( + cmd, + shell=True, + cwd=repo_root, + env=env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if result.returncode != 0: + print( + colored(f"[WARN] Hook failed ({event}): {cmd}", Colors.YELLOW), + file=sys.stderr, + ) + if result.stderr.strip(): + print(f" {result.stderr.strip()}", file=sys.stderr) + except Exception as e: + print( + colored(f"[WARN] Hook error ({event}): {cmd} — {e}", Colors.YELLOW), + file=sys.stderr, + ) + + # ============================================================================= # Main Entry (for testing) # ============================================================================= if __name__ == "__main__": - from .paths import get_tasks_dir - repo = get_repo_root() tasks = get_tasks_dir(repo) diff --git a/.trellis/scripts/common/tasks.py b/.trellis/scripts/common/tasks.py new file mode 100755 index 0000000..7b44094 --- /dev/null +++ b/.trellis/scripts/common/tasks.py @@ -0,0 +1,112 @@ +""" +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 "" + # A child missing from active statuses has been archived (cmd_archive + # sets status=completed before moving the dir). Count it as done so + # parent progress doesn't regress when children are archived. + done = sum( + 1 for c in children + if c not in all_statuses or all_statuses.get(c) in ("completed", "done") + ) + return f" [{done}/{len(children)} done]" diff --git a/.trellis/scripts/common/types.py b/.trellis/scripts/common/types.py new file mode 100755 index 0000000..5802e10 --- /dev/null +++ b/.trellis/scripts/common/types.py @@ -0,0 +1,110 @@ +""" +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 + commit: str | None + pr_url: str | None + subtasks: list[str] + children: list[str] + parent: str | None + relatedFiles: list[str] + notes: str + meta: dict + + +# ============================================================================= +# Loaded task object (frozen dataclass — the public API type) +# ============================================================================= + +@dataclass(frozen=True) +class TaskInfo: + """Immutable view of a loaded task. + + Created by load_task() / iter_active_tasks(). + Contains the commonly accessed fields; the original dict + is preserved in `raw` for write-back and uncommon field access. + """ + + dir_name: str + directory: Path + title: str + status: str + assignee: str + priority: str + children: tuple[str, ...] + parent: str | None + package: str | None + raw: dict # original dict — use for writes and uncommon fields + + @property + def name(self) -> str: + """Task name (id or name field).""" + return self.raw.get("name") or self.raw.get("id") or self.dir_name + + @property + def description(self) -> str: + return self.raw.get("description", "") + + @property + def branch(self) -> str | None: + return self.raw.get("branch") + + @property + def meta(self) -> dict: + return self.raw.get("meta", {}) + + +# ============================================================================= +# registry.json agent entry +# ============================================================================= + +class AgentRecord(TypedDict, total=False): + """Shape of an agent entry in registry.json.""" + + id: str + pid: int + task_dir: str + worktree_path: str + branch: str + platform: str + started_at: str + status: str diff --git a/.trellis/scripts/common/workflow_phase.py b/.trellis/scripts/common/workflow_phase.py new file mode 100755 index 0000000..a9970f0 --- /dev/null +++ b/.trellis/scripts/common/workflow_phase.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Workflow Phase Extraction. + +Extracts step-level content from .trellis/workflow.md and optionally filters +platform-specific blocks. + +Platform marker syntax in workflow.md: + + [Claude Code, Cursor, ...] + agent-capable content + [/Claude Code, Cursor, ...] + +Provides: + get_phase_index - Extract the Phase Index section (no --step) + get_step - Extract a single step (#### X.X) section + filter_platform - Strip platform blocks that don't include the given name +""" + +from __future__ import annotations + +import re + +from .paths import DIR_WORKFLOW, get_repo_root + + +def _workflow_md_path(): + return get_repo_root() / DIR_WORKFLOW / "workflow.md" + +# Match a line that *is* a platform marker: "[A, B, C]" or "[/A, B, C]" +_MARKER_RE = re.compile(r"^\[(/?)([A-Za-z][^\[\]]*)\]\s*$") + +# Step heading: "#### 1.0 Title" or "#### 1.0 ..." +_STEP_HEADING_RE = re.compile(r"^####\s+(\d+\.\d+)\b.*$") + +# Phase Index starts here; Phase 1/2/3 step bodies follow; ends at Breadcrumbs. +_PHASE_INDEX_HEADING = "## Phase Index" + + +def _read_workflow() -> str: + path = _workflow_md_path() + if not path.exists(): + raise FileNotFoundError(f"workflow.md not found: {path}") + return path.read_text(encoding="utf-8") + + +def _parse_marker(line: str) -> tuple[bool, list[str]] | None: + """Parse a platform marker line. + + Returns: + (is_closing, [platform_names]) if line is a marker, else None. + """ + m = _MARKER_RE.match(line) + if not m: + return None + is_closing = m.group(1) == "/" + names = [p.strip() for p in m.group(2).split(",") if p.strip()] + return is_closing, names + + +def get_phase_index() -> str: + """Return Phase Index + Phase 1/2/3 step bodies from workflow.md. + + Matches what the SessionStart hook injects into the `<workflow>` block: + starts at `## Phase Index`, continues through `## Phase 1: Plan`, + `## Phase 2: Execute`, `## Phase 3: Finish`, stops at + `## Customizing Trellis (for forks)` (the docs-for-forks footer). + `[workflow-state:STATUS]` tag blocks (now embedded in Phase Index since + v0.5.0-rc.0) are consumed by the UserPromptSubmit hook so they're + stripped from this output. + """ + text = _read_workflow() + lines = text.splitlines() + + start: int | None = None + end: int | None = None + for i, line in enumerate(lines): + stripped = line.strip() + if start is None and stripped == _PHASE_INDEX_HEADING: + start = i + continue + if start is not None and stripped == "## Customizing Trellis (for forks)": + end = i + break + + if start is None: + return "" + if end is None: + end = len(lines) + + section = "\n".join(lines[start:end]).rstrip() + # Strip [workflow-state:STATUS]...[/workflow-state:STATUS] blocks since + # they're injected separately by inject-workflow-state.py per-turn. + import re as _re + tag_re = _re.compile( + r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n.*?\n\s*\[/workflow-state:\1\]\n?", + _re.DOTALL, + ) + return tag_re.sub("", section).rstrip() + "\n" + + +def get_step(step_id: str) -> str: + """Return the `#### X.X` section matching step_id (header + body). + + Body ends at the next `####` or `---` or `##` heading (whichever comes first). + """ + text = _read_workflow() + lines = text.splitlines() + + start: int | None = None + for i, line in enumerate(lines): + m = _STEP_HEADING_RE.match(line) + if m and m.group(1) == step_id: + start = i + break + if start is None: + return "" + + end: int = len(lines) + for j in range(start + 1, len(lines)): + line = lines[j] + if line.startswith("#### "): + end = j + break + if line.startswith("## "): + end = j + break + # Horizontal rule at column 0 + if line.strip() == "---": + end = j + break + + return "\n".join(lines[start:end]).rstrip() + "\n" + + +def _platform_matches(platform: str, block_names: list[str]) -> bool: + """Case-insensitive fuzzy match: accept 'cursor', 'Cursor', 'claude-code', 'Claude Code'.""" + needle = platform.lower().replace("-", "").replace("_", "").replace(" ", "") + for name in block_names: + hay = name.lower().replace("-", "").replace("_", "").replace(" ", "") + if needle == hay: + return True + return False + + +def filter_platform(content: str, platform: str) -> str: + """Keep lines outside any `[...]` block + lines inside blocks that include platform. + + Marker lines themselves are dropped from the output. + """ + lines = content.splitlines() + out: list[str] = [] + + in_block = False + keep_block = False + + for line in lines: + marker = _parse_marker(line) + if marker is not None: + is_closing, names = marker + if not is_closing: + in_block = True + keep_block = _platform_matches(platform, names) + else: + in_block = False + keep_block = False + continue # drop the marker line itself + + if in_block: + if keep_block: + out.append(line) + continue + out.append(line) + + # Collapse runs of 3+ blank lines that may arise from dropped markers + collapsed: list[str] = [] + blank_run = 0 + for line in out: + if line.strip() == "": + blank_run += 1 + if blank_run <= 2: + collapsed.append(line) + else: + blank_run = 0 + collapsed.append(line) + + return "\n".join(collapsed).rstrip() + "\n" diff --git a/.trellis/scripts/create_bootstrap.py b/.trellis/scripts/create_bootstrap.py deleted file mode 100755 index 201146f..0000000 --- a/.trellis/scripts/create_bootstrap.py +++ /dev/null @@ -1,293 +0,0 @@ -#!/usr/bin/env python3 -""" -Create Bootstrap Task for First-Time Setup. - -Creates a guided task to help users fill in project guidelines -after initializing Trellis for the first time. - -Usage: - python3 create_bootstrap.py [project-type] - -Arguments: - project-type: frontend | backend | fullstack (default: fullstack) - -Prerequisites: - - .trellis/.developer must exist (run init_developer.py first) - -Creates: - .trellis/tasks/00-bootstrap-guidelines/ - - task.json # Task metadata - - prd.md # Task description and guidance -""" - -from __future__ import annotations - -import json -import sys -from datetime import datetime -from pathlib import Path - -from common.paths import ( - DIR_WORKFLOW, - DIR_SCRIPTS, - DIR_TASKS, - get_repo_root, - get_developer, - get_tasks_dir, - set_current_task, -) - - -# ============================================================================= -# Constants -# ============================================================================= - -TASK_NAME = "00-bootstrap-guidelines" - - -# ============================================================================= -# PRD Content -# ============================================================================= - -def write_prd_header() -> str: - """Write PRD header section.""" - return """# Bootstrap: Fill Project Development Guidelines - -## Purpose - -Welcome to Trellis! This is your first task. - -AI agents use `.trellis/spec/` to understand YOUR project's coding conventions. -**Empty templates = AI writes generic code that doesn't match your project style.** - -Filling these guidelines is a one-time setup that pays off for every future AI session. - ---- - -## Your Task - -Fill in the guideline files based on your **existing codebase**. -""" - - -def write_prd_backend_section() -> str: - """Write PRD backend section.""" - return """ - -### Backend Guidelines - -| File | What to Document | -|------|------------------| -| `.trellis/spec/backend/directory-structure.md` | Where different file types go (routes, services, utils) | -| `.trellis/spec/backend/database-guidelines.md` | ORM, migrations, query patterns, naming conventions | -| `.trellis/spec/backend/error-handling.md` | How errors are caught, logged, and returned | -| `.trellis/spec/backend/logging-guidelines.md` | Log levels, format, what to log | -| `.trellis/spec/backend/quality-guidelines.md` | Code review standards, testing requirements | -""" - - -def write_prd_frontend_section() -> str: - """Write PRD frontend section.""" - return """ - -### Frontend Guidelines - -| File | What to Document | -|------|------------------| -| `.trellis/spec/frontend/directory-structure.md` | Component/page/hook organization | -| `.trellis/spec/frontend/component-guidelines.md` | Component patterns, props conventions | -| `.trellis/spec/frontend/hook-guidelines.md` | Custom hook naming, patterns | -| `.trellis/spec/frontend/state-management.md` | State library, patterns, what goes where | -| `.trellis/spec/frontend/type-safety.md` | TypeScript conventions, type organization | -| `.trellis/spec/frontend/quality-guidelines.md` | Linting, testing, accessibility | -""" - - -def write_prd_footer() -> str: - """Write PRD footer section.""" - return """ - -### Thinking Guides (Optional) - -The `.trellis/spec/guides/` directory contains thinking guides that are already -filled with general best practices. You can customize them for your project if needed. - ---- - -## How to Fill Guidelines - -### Principle: Document Reality, Not Ideals - -Write what your codebase **actually does**, not what you wish it did. -AI needs to match existing patterns, not introduce new ones. - -### Steps - -1. **Look at existing code** - Find 2-3 examples of each pattern -2. **Document the pattern** - Describe what you see -3. **Include file paths** - Reference real files as examples -4. **List anti-patterns** - What does your team avoid? - ---- - -## Tips for Using AI - -Ask AI to help analyze your codebase: - -- "Look at my codebase and document the patterns you see" -- "Analyze my code structure and summarize the conventions" -- "Find error handling patterns and document them" - -The AI will read your code and help you document it. - ---- - -## Completion Checklist - -- [ ] Guidelines filled for your project type -- [ ] At least 2-3 real code examples in each guideline -- [ ] Anti-patterns documented - -When done: - -```bash -python3 ./.trellis/scripts/task.py finish -python3 ./.trellis/scripts/task.py archive 00-bootstrap-guidelines -``` - ---- - -## Why This Matters - -After completing this task: - -1. AI will write code that matches your project style -2. Relevant `/trellis:before-*-dev` commands will inject real context -3. `/trellis:check-*` commands will validate against your actual standards -4. Future developers (human or AI) will onboard faster -""" - - -def write_prd(task_dir: Path, project_type: str) -> None: - """Write prd.md file.""" - content = write_prd_header() - - if project_type == "frontend": - content += write_prd_frontend_section() - elif project_type == "backend": - content += write_prd_backend_section() - else: # fullstack - content += write_prd_backend_section() - content += write_prd_frontend_section() - - content += write_prd_footer() - - prd_file = task_dir / "prd.md" - prd_file.write_text(content, encoding="utf-8") - - -# ============================================================================= -# Task JSON -# ============================================================================= - -def write_task_json(task_dir: Path, developer: str, project_type: str) -> None: - """Write task.json file.""" - today = datetime.now().strftime("%Y-%m-%d") - - # Generate subtasks and related files based on project type - if project_type == "frontend": - subtasks = [ - {"name": "Fill frontend guidelines", "status": "pending"}, - {"name": "Add code examples", "status": "pending"}, - ] - related_files = [".trellis/spec/frontend/"] - elif project_type == "backend": - subtasks = [ - {"name": "Fill backend guidelines", "status": "pending"}, - {"name": "Add code examples", "status": "pending"}, - ] - related_files = [".trellis/spec/backend/"] - else: # fullstack - subtasks = [ - {"name": "Fill backend guidelines", "status": "pending"}, - {"name": "Fill frontend guidelines", "status": "pending"}, - {"name": "Add code examples", "status": "pending"}, - ] - related_files = [".trellis/spec/backend/", ".trellis/spec/frontend/"] - - task_data = { - "id": TASK_NAME, - "name": "Bootstrap Guidelines", - "description": "Fill in project development guidelines for AI agents", - "status": "in_progress", - "dev_type": "docs", - "priority": "P1", - "creator": developer, - "assignee": developer, - "createdAt": today, - "completedAt": None, - "commit": None, - "subtasks": subtasks, - "children": [], - "parent": None, - "relatedFiles": related_files, - "notes": f"First-time setup task created by trellis init ({project_type} project)", - "meta": {}, - } - - task_json = task_dir / "task.json" - task_json.write_text(json.dumps(task_data, indent=2, ensure_ascii=False), encoding="utf-8") - - -# ============================================================================= -# Main -# ============================================================================= - -def main() -> int: - """Main entry point.""" - # Parse project type argument - project_type = "fullstack" - if len(sys.argv) > 1: - project_type = sys.argv[1] - - # Validate project type - if project_type not in ("frontend", "backend", "fullstack"): - print(f"Unknown project type: {project_type}, defaulting to fullstack") - project_type = "fullstack" - - repo_root = get_repo_root() - developer = get_developer(repo_root) - - # Check developer initialized - if not developer: - print("Error: Developer not initialized") - print(f"Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <your-name>") - return 1 - - tasks_dir = get_tasks_dir(repo_root) - task_dir = tasks_dir / TASK_NAME - relative_path = f"{DIR_WORKFLOW}/{DIR_TASKS}/{TASK_NAME}" - - # Check if already exists - if task_dir.exists(): - print(f"Bootstrap task already exists: {relative_path}") - return 0 - - # Create task directory - task_dir.mkdir(parents=True, exist_ok=True) - - # Write files - write_task_json(task_dir, developer, project_type) - write_prd(task_dir, project_type) - - # Set as current task - set_current_task(relative_path, repo_root) - - # Silent output - init command handles user-facing messages - # Only output the task path for programmatic use - print(relative_path) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.trellis/scripts/multi_agent/__init__.py b/.trellis/scripts/multi_agent/__init__.py deleted file mode 100755 index c7c7e7d..0000000 --- a/.trellis/scripts/multi_agent/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -""" -Multi-Agent Pipeline Scripts. - -This module provides orchestration for multi-agent workflows. -""" diff --git a/.trellis/scripts/multi_agent/cleanup.py b/.trellis/scripts/multi_agent/cleanup.py deleted file mode 100755 index f81e370..0000000 --- a/.trellis/scripts/multi_agent/cleanup.py +++ /dev/null @@ -1,403 +0,0 @@ -#!/usr/bin/env python3 -""" -Multi-Agent Pipeline: Cleanup Worktree. - -Usage: - python3 cleanup.py <branch-name> Remove specific worktree - python3 cleanup.py --list List all worktrees - python3 cleanup.py --merged Remove merged worktrees - python3 cleanup.py --all Remove all worktrees (with confirmation) - -Options: - -y, --yes Skip confirmation prompts - --keep-branch Don't delete the git branch - -This script: -1. Archives task directory to archive/{YYYY-MM}/ -2. Removes agent from registry -3. Removes git worktree -4. Optionally deletes git branch -""" - -from __future__ import annotations - -import argparse -import shutil -import subprocess -import sys -from pathlib import Path - -# Add parent directory to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from common.git_context import _run_git_command -from common.paths import get_repo_root -from common.registry import ( - registry_get_file, - registry_get_task_dir, - registry_remove_by_id, - registry_remove_by_worktree, - registry_search_agent, -) -from common.task_utils import ( - archive_task_complete, - is_safe_task_path, -) - -# ============================================================================= -# Colors -# ============================================================================= - - -class Colors: - RED = "\033[0;31m" - GREEN = "\033[0;32m" - YELLOW = "\033[1;33m" - BLUE = "\033[0;34m" - NC = "\033[0m" - - -def log_info(msg: str) -> None: - print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}") - - -def log_success(msg: str) -> None: - print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}") - - -def log_warn(msg: str) -> None: - print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}") - - -def log_error(msg: str) -> None: - print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}") - - -# ============================================================================= -# Helper Functions -# ============================================================================= - - -def confirm(prompt: str, skip_confirm: bool) -> bool: - """Ask for confirmation.""" - if skip_confirm: - return True - - if not sys.stdin.isatty(): - log_error("Non-interactive mode detected. Use -y to skip confirmation.") - return False - - response = input(f"{prompt} [y/N] ") - return response.lower() in ("y", "yes") - - -# ============================================================================= -# Commands -# ============================================================================= - - -def cmd_list(repo_root: Path) -> int: - """List worktrees.""" - print(f"{Colors.BLUE}=== Git Worktrees ==={Colors.NC}") - print() - - subprocess.run(["git", "worktree", "list"], cwd=repo_root) - print() - - # Show registry info - registry_file = registry_get_file(repo_root) - if registry_file and registry_file.is_file(): - print(f"{Colors.BLUE}=== Registered Agents ==={Colors.NC}") - print() - - import json - - data = json.loads(registry_file.read_text(encoding="utf-8")) - agents = data.get("agents", []) - - if agents: - for agent in agents: - print( - f" {agent.get('id', '?')}: PID={agent.get('pid', '?')} [{agent.get('worktree_path', '?')}]" - ) - else: - print(" (none)") - print() - - return 0 - - -def archive_task(worktree_path: str, repo_root: Path) -> None: - """Archive task directory.""" - task_dir = registry_get_task_dir(worktree_path, repo_root) - - if not task_dir or not is_safe_task_path(task_dir, repo_root): - return - - task_dir_abs = repo_root / task_dir - if not task_dir_abs.is_dir(): - return - - result = archive_task_complete(task_dir_abs, repo_root) - if "archived_to" in result: - dest = Path(result["archived_to"]) - log_success(f"Archived task: {dest.name} -> archive/{dest.parent.name}/") - - -def cleanup_registry_only(search: str, repo_root: Path, skip_confirm: bool) -> int: - """Cleanup from registry only (no worktree).""" - agent_info = registry_search_agent(search, repo_root) - - if not agent_info: - log_error(f"No agent found in registry matching: {search}") - return 1 - - agent_id = agent_info.get("id", "?") - task_dir = agent_info.get("task_dir", "?") - - print() - print(f"{Colors.BLUE}=== Cleanup Agent (no worktree) ==={Colors.NC}") - print(f" Agent ID: {agent_id}") - print(f" Task Dir: {task_dir}") - print() - - if not confirm("Archive task and remove from registry?", skip_confirm): - log_info("Aborted") - return 0 - - # Archive task directory if exists - if task_dir and is_safe_task_path(task_dir, repo_root): - task_dir_abs = repo_root / task_dir - if task_dir_abs.is_dir(): - result = archive_task_complete(task_dir_abs, repo_root) - if "archived_to" in result: - dest = Path(result["archived_to"]) - log_success( - f"Archived task: {dest.name} -> archive/{dest.parent.name}/" - ) - else: - log_warn("Invalid task_dir in registry, skipping archive") - - # Remove from registry - registry_remove_by_id(agent_id, repo_root) - log_success(f"Removed from registry: {agent_id}") - - log_success("Cleanup complete") - return 0 - - -def cleanup_worktree( - branch: str, repo_root: Path, skip_confirm: bool, keep_branch: bool -) -> int: - """Cleanup single worktree.""" - # Find worktree path for branch - _, worktree_list, _ = _run_git_command( - ["worktree", "list", "--porcelain"], cwd=repo_root - ) - - worktree_path = None - current_worktree = None - - for line in worktree_list.splitlines(): - if line.startswith("worktree "): - current_worktree = line[9:] # Remove "worktree " prefix - elif line.startswith("branch refs/heads/"): - current_branch = line[18:] # Remove "branch refs/heads/" prefix - if current_branch == branch: - worktree_path = current_worktree - break - - if not worktree_path: - # No worktree found, try to cleanup from registry only - log_warn(f"No worktree found for: {branch}") - log_info("Trying to cleanup from registry...") - return cleanup_registry_only(branch, repo_root, skip_confirm) - - print() - print(f"{Colors.BLUE}=== Cleanup Worktree ==={Colors.NC}") - print(f" Branch: {branch}") - print(f" Worktree: {worktree_path}") - print() - - if not confirm("Remove this worktree?", skip_confirm): - log_info("Aborted") - return 0 - - # 1. Archive task - archive_task(worktree_path, repo_root) - - # 2. Remove from registry - registry_remove_by_worktree(worktree_path, repo_root) - log_info("Removed from registry") - - # 3. Remove worktree - log_info("Removing worktree...") - ret, _, _ = _run_git_command( - ["worktree", "remove", worktree_path, "--force"], cwd=repo_root - ) - if ret != 0: - # Try removing directory manually - try: - shutil.rmtree(worktree_path) - except Exception as e: - log_error(f"Failed to remove worktree: {e}") - - log_success("Worktree removed") - - # 4. Delete branch (optional) - if not keep_branch: - log_info("Deleting branch...") - ret, _, _ = _run_git_command(["branch", "-D", branch], cwd=repo_root) - if ret != 0: - log_warn("Could not delete branch (may be checked out elsewhere)") - - log_success(f"Cleanup complete for: {branch}") - return 0 - - -def cmd_merged(repo_root: Path, skip_confirm: bool, keep_branch: bool) -> int: - """Cleanup merged worktrees.""" - # Get main branch - _, head_out, _ = _run_git_command( - ["symbolic-ref", "refs/remotes/origin/HEAD"], cwd=repo_root - ) - main_branch = head_out.strip().replace("refs/remotes/origin/", "") or "main" - - print(f"{Colors.BLUE}=== Finding Merged Worktrees ==={Colors.NC}") - print() - - # Get merged branches - _, merged_out, _ = _run_git_command( - ["branch", "--merged", main_branch], cwd=repo_root - ) - merged_branches = [] - for line in merged_out.splitlines(): - branch = line.strip().lstrip("* ") - if branch and branch != main_branch: - merged_branches.append(branch) - - if not merged_branches: - log_info("No merged branches found") - return 0 - - # Get worktree list - _, worktree_list, _ = _run_git_command(["worktree", "list"], cwd=repo_root) - - worktree_branches = [] - for branch in merged_branches: - if f"[{branch}]" in worktree_list: - worktree_branches.append(branch) - print(f" - {branch}") - - if not worktree_branches: - log_info("No merged worktrees found") - return 0 - - print() - if not confirm("Remove these merged worktrees?", skip_confirm): - log_info("Aborted") - return 0 - - for branch in worktree_branches: - cleanup_worktree(branch, repo_root, True, keep_branch) - - return 0 - - -def cmd_all(repo_root: Path, skip_confirm: bool, keep_branch: bool) -> int: - """Cleanup all worktrees.""" - print(f"{Colors.BLUE}=== All Worktrees ==={Colors.NC}") - print() - - # Get worktree list - _, worktree_list, _ = _run_git_command( - ["worktree", "list", "--porcelain"], cwd=repo_root - ) - - worktrees = [] - main_worktree = str(repo_root.resolve()) - - for line in worktree_list.splitlines(): - if line.startswith("worktree "): - wt = line[9:] - if wt != main_worktree: - worktrees.append(wt) - - if not worktrees: - log_info("No worktrees to remove") - return 0 - - for wt in worktrees: - print(f" - {wt}") - - print() - print(f"{Colors.RED}WARNING: This will remove ALL worktrees!{Colors.NC}") - - if not confirm("Are you sure?", skip_confirm): - log_info("Aborted") - return 0 - - # Get branch for each worktree - for wt in worktrees: - # Find branch name from worktree list - _, wt_list, _ = _run_git_command(["worktree", "list"], cwd=repo_root) - for line in wt_list.splitlines(): - if wt in line: - # Extract branch from [branch] format - import re - - match = re.search(r"\[([^\]]+)\]", line) - if match: - branch = match.group(1) - cleanup_worktree(branch, repo_root, True, keep_branch) - break - - return 0 - - -# ============================================================================= -# Main -# ============================================================================= - - -def main() -> int: - """Main entry point.""" - parser = argparse.ArgumentParser( - description="Multi-Agent Pipeline: Cleanup Worktree" - ) - parser.add_argument("branch", nargs="?", help="Branch name to cleanup") - parser.add_argument("-y", "--yes", action="store_true", help="Skip confirmation") - parser.add_argument( - "--keep-branch", action="store_true", help="Don't delete git branch" - ) - parser.add_argument("--list", action="store_true", help="List all worktrees") - parser.add_argument("--merged", action="store_true", help="Remove merged worktrees") - parser.add_argument("--all", action="store_true", help="Remove all worktrees") - - args = parser.parse_args() - repo_root = get_repo_root() - - if args.list: - return cmd_list(repo_root) - elif args.merged: - return cmd_merged(repo_root, args.yes, args.keep_branch) - elif args.all: - return cmd_all(repo_root, args.yes, args.keep_branch) - elif args.branch: - return cleanup_worktree(args.branch, repo_root, args.yes, args.keep_branch) - else: - print("""Usage: - python3 cleanup.py <branch-name> Remove specific worktree - python3 cleanup.py --list List all worktrees - python3 cleanup.py --merged Remove merged worktrees - python3 cleanup.py --all Remove all worktrees - -Options: - -y, --yes Skip confirmation - --keep-branch Don't delete git branch -""") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.trellis/scripts/multi_agent/create_pr.py b/.trellis/scripts/multi_agent/create_pr.py deleted file mode 100755 index 54df3db..0000000 --- a/.trellis/scripts/multi_agent/create_pr.py +++ /dev/null @@ -1,329 +0,0 @@ -#!/usr/bin/env python3 -""" -Multi-Agent Pipeline: Create PR. - -Usage: - python3 create_pr.py [task-dir] [--dry-run] - -This script: -1. Stages and commits all changes (excluding workspace/) -2. Pushes to origin -3. Creates a Draft PR using `gh pr create` -4. Updates task.json with status="completed", pr_url, and current_phase - -Note: This is the only action that performs git commit, as it's the final -step after all implementation and checks are complete. -""" - -from __future__ import annotations - -import argparse -import json -import subprocess -import sys -from pathlib import Path - -# Add parent directory to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from common.git_context import _run_git_command -from common.paths import ( - DIR_WORKFLOW, - FILE_TASK_JSON, - get_current_task, - get_repo_root, -) -from common.phase import get_phase_for_action - -# ============================================================================= -# Colors -# ============================================================================= - - -class Colors: - RED = "\033[0;31m" - GREEN = "\033[0;32m" - YELLOW = "\033[1;33m" - BLUE = "\033[0;34m" - NC = "\033[0m" - - -# ============================================================================= -# Helper Functions -# ============================================================================= - - -def _read_json_file(path: Path) -> dict | None: - """Read and parse a JSON file.""" - try: - return json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return None - - -def _write_json_file(path: Path, data: dict) -> bool: - """Write dict to JSON file.""" - try: - path.write_text( - json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8" - ) - return True - except (OSError, IOError): - return False - - -# ============================================================================= -# Main -# ============================================================================= - - -def main() -> int: - """Main entry point.""" - parser = argparse.ArgumentParser(description="Multi-Agent Pipeline: Create PR") - parser.add_argument("dir", nargs="?", help="Task directory") - parser.add_argument( - "--dry-run", action="store_true", help="Show what would be done" - ) - - args = parser.parse_args() - repo_root = get_repo_root() - - # ============================================================================= - # Get Task Directory - # ============================================================================= - target_dir = args.dir - if not target_dir: - # Try to get from .current-task - current_task = get_current_task(repo_root) - if current_task: - target_dir = current_task - - if not target_dir: - print( - f"{Colors.RED}Error: No task directory specified and no current task set{Colors.NC}" - ) - print("Usage: python3 create_pr.py [task-dir] [--dry-run]") - return 1 - - # Support relative paths - if not target_dir.startswith("/"): - target_dir_path = repo_root / target_dir - else: - target_dir_path = Path(target_dir) - - task_json = target_dir_path / FILE_TASK_JSON - if not task_json.is_file(): - print(f"{Colors.RED}Error: task.json not found at {target_dir_path}{Colors.NC}") - return 1 - - # ============================================================================= - # Main - # ============================================================================= - print(f"{Colors.BLUE}=== Create PR ==={Colors.NC}") - if args.dry_run: - print( - f"{Colors.YELLOW}[DRY-RUN MODE] No actual changes will be made{Colors.NC}" - ) - print() - - # Read task config - task_data = _read_json_file(task_json) - if not task_data: - print(f"{Colors.RED}Error: Failed to read task.json{Colors.NC}") - return 1 - - task_name = task_data.get("name", "") - base_branch = task_data.get("base_branch", "main") - scope = task_data.get("scope", "core") - dev_type = task_data.get("dev_type", "feature") - - # Map dev_type to commit prefix - prefix_map = { - "feature": "feat", - "frontend": "feat", - "backend": "feat", - "fullstack": "feat", - "bugfix": "fix", - "fix": "fix", - "refactor": "refactor", - "docs": "docs", - "test": "test", - } - commit_prefix = prefix_map.get(dev_type, "feat") - - print(f"Task: {task_name}") - print(f"Base branch: {base_branch}") - print(f"Scope: {scope}") - print(f"Commit prefix: {commit_prefix}") - print() - - # Get current branch - _, branch_out, _ = _run_git_command(["branch", "--show-current"]) - current_branch = branch_out.strip() - print(f"Current branch: {current_branch}") - - # Check for changes - print(f"{Colors.YELLOW}Checking for changes...{Colors.NC}") - - # Stage changes - _run_git_command(["add", "-A"]) - - # Exclude workspace and temp files - _run_git_command(["reset", f"{DIR_WORKFLOW}/workspace/"]) - _run_git_command(["reset", ".agent-log", ".session-id"]) - - # Check if there are staged changes - ret, _, _ = _run_git_command(["diff", "--cached", "--quiet"]) - has_staged_changes = ret != 0 - - if not has_staged_changes: - print(f"{Colors.YELLOW}No staged changes to commit{Colors.NC}") - - # Check for unpushed commits - ret, log_out, _ = _run_git_command( - ["log", f"origin/{current_branch}..HEAD", "--oneline"] - ) - unpushed = len([line for line in log_out.splitlines() if line.strip()]) - - if unpushed == 0: - if args.dry_run: - _run_git_command(["reset", "HEAD"]) - print(f"{Colors.RED}No changes to create PR{Colors.NC}") - return 1 - - print(f"Found {unpushed} unpushed commit(s)") - else: - # Commit changes - print(f"{Colors.YELLOW}Committing changes...{Colors.NC}") - commit_msg = f"{commit_prefix}({scope}): {task_name}" - - if args.dry_run: - print(f"[DRY-RUN] Would commit with message: {commit_msg}") - print("[DRY-RUN] Staged files:") - _, staged_out, _ = _run_git_command(["diff", "--cached", "--name-only"]) - for line in staged_out.splitlines(): - print(f" - {line}") - else: - _run_git_command(["commit", "-m", commit_msg]) - print(f"{Colors.GREEN}Committed: {commit_msg}{Colors.NC}") - - # Push to remote - print(f"{Colors.YELLOW}Pushing to remote...{Colors.NC}") - if args.dry_run: - print(f"[DRY-RUN] Would push to: origin/{current_branch}") - else: - ret, _, err = _run_git_command(["push", "-u", "origin", current_branch]) - if ret != 0: - print(f"{Colors.RED}Failed to push: {err}{Colors.NC}") - return 1 - print(f"{Colors.GREEN}Pushed to origin/{current_branch}{Colors.NC}") - - # Create PR - print(f"{Colors.YELLOW}Creating PR...{Colors.NC}") - pr_title = f"{commit_prefix}({scope}): {task_name}" - pr_url = "" - - if args.dry_run: - print("[DRY-RUN] Would create PR:") - print(f" Title: {pr_title}") - print(f" Base: {base_branch}") - print(f" Head: {current_branch}") - prd_file = target_dir_path / "prd.md" - if prd_file.is_file(): - print(" Body: (from prd.md)") - pr_url = "https://github.com/example/repo/pull/DRY-RUN" - else: - # Check if PR already exists - result = subprocess.run( - [ - "gh", - "pr", - "list", - "--head", - current_branch, - "--base", - base_branch, - "--json", - "url", - "--jq", - ".[0].url", - ], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - existing_pr = result.stdout.strip() - - if existing_pr: - print(f"{Colors.YELLOW}PR already exists: {existing_pr}{Colors.NC}") - pr_url = existing_pr - else: - # Read PRD as PR body - pr_body = "" - prd_file = target_dir_path / "prd.md" - if prd_file.is_file(): - pr_body = prd_file.read_text(encoding="utf-8") - - # Create PR - result = subprocess.run( - [ - "gh", - "pr", - "create", - "--draft", - "--base", - base_branch, - "--title", - pr_title, - "--body", - pr_body, - ], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - - if result.returncode != 0: - print(f"{Colors.RED}Failed to create PR: {result.stderr}{Colors.NC}") - return 1 - - pr_url = result.stdout.strip() - print(f"{Colors.GREEN}PR created: {pr_url}{Colors.NC}") - - # Update task.json - print(f"{Colors.YELLOW}Updating task status...{Colors.NC}") - if args.dry_run: - print("[DRY-RUN] Would update task.json:") - print(" status: completed") - print(f" pr_url: {pr_url}") - print(" current_phase: (set to create-pr phase)") - else: - # Get the phase number for create-pr action - create_pr_phase = get_phase_for_action(task_json, "create-pr") - if not create_pr_phase: - create_pr_phase = 4 # Default fallback - - task_data["status"] = "completed" - task_data["pr_url"] = pr_url - task_data["current_phase"] = create_pr_phase - - _write_json_file(task_json, task_data) - print( - f"{Colors.GREEN}Task status updated to 'completed', phase {create_pr_phase}{Colors.NC}" - ) - - # In dry-run, reset the staging area - if args.dry_run: - _run_git_command(["reset", "HEAD"]) - - print() - print(f"{Colors.GREEN}=== PR Created Successfully ==={Colors.NC}") - print(f"PR URL: {pr_url}") - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.trellis/scripts/multi_agent/plan.py b/.trellis/scripts/multi_agent/plan.py deleted file mode 100755 index 7ce5e6f..0000000 --- a/.trellis/scripts/multi_agent/plan.py +++ /dev/null @@ -1,236 +0,0 @@ -#!/usr/bin/env python3 -""" -Multi-Agent Pipeline: Plan Agent Launcher. - -Usage: python3 plan.py --name <task-name> --type <dev-type> --requirement "<requirement>" - -This script: -1. Creates task directory -2. Starts Plan Agent in background -3. Plan Agent produces fully configured task directory - -After completion, use start.py to launch the Dispatch Agent. - -Prerequisites: - - agents/plan.md must exist (in .claude/, .cursor/, .iflow/, or .opencode/) - - Developer must be initialized -""" - -from __future__ import annotations - -import argparse -import os -import subprocess -import sys -from pathlib import Path - -# Add parent directory to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from common.cli_adapter import get_cli_adapter -from common.paths import get_repo_root -from common.developer import ensure_developer - - -# ============================================================================= -# Colors -# ============================================================================= - -class Colors: - RED = "\033[0;31m" - GREEN = "\033[0;32m" - YELLOW = "\033[1;33m" - BLUE = "\033[0;34m" - NC = "\033[0m" - - -def log_info(msg: str) -> None: - print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}") - - -def log_success(msg: str) -> None: - print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}") - - -def log_error(msg: str) -> None: - print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}") - - -# ============================================================================= -# Constants -# ============================================================================= - -DEFAULT_PLATFORM = "claude" - - -# ============================================================================= -# Main -# ============================================================================= - -def main() -> int: - """Main entry point.""" - parser = argparse.ArgumentParser( - description="Multi-Agent Pipeline: Plan Agent Launcher" - ) - parser.add_argument("--name", "-n", required=True, help="Task name (e.g., user-auth)") - parser.add_argument("--type", "-t", required=True, help="Dev type: backend|frontend|fullstack") - parser.add_argument("--requirement", "-r", required=True, help="Requirement description") - parser.add_argument( - "--platform", "-p", - choices=["claude", "cursor", "iflow", "opencode", "qoder"], - default=DEFAULT_PLATFORM, - help="Platform to use (default: claude)" - ) - - args = parser.parse_args() - - task_name = args.name - dev_type = args.type - requirement = args.requirement - platform = args.platform - - # Initialize CLI adapter - adapter = get_cli_adapter(platform) - - # Validate dev type - if dev_type not in ("backend", "frontend", "fullstack"): - log_error(f"Invalid dev type: {dev_type} (must be: backend, frontend, fullstack)") - return 1 - - project_root = get_repo_root() - - # Check plan agent exists (path varies by platform) - plan_md = adapter.get_agent_path("plan", project_root) - if not plan_md.is_file(): - log_error(f"plan agent not found at {plan_md}") - log_info(f"Platform: {platform}") - return 1 - - ensure_developer(project_root) - - # ============================================================================= - # Step 1: Create Task Directory - # ============================================================================= - print() - print(f"{Colors.BLUE}=== Multi-Agent Pipeline: Plan ==={Colors.NC}") - log_info(f"Task: {task_name}") - log_info(f"Type: {dev_type}") - log_info(f"Requirement: {requirement}") - print() - - log_info("Step 1: Creating task directory...") - - # Import task module to create task - from task import cmd_create - import argparse as ap - - # Create task using task.py's create command - create_args = ap.Namespace( - title=requirement, - slug=task_name, - assignee=None, - priority="P2", - description="" - ) - - # Capture stdout to get task dir - import io - from contextlib import redirect_stdout - - stdout_capture = io.StringIO() - with redirect_stdout(stdout_capture): - ret = cmd_create(create_args) - - if ret != 0: - log_error("Failed to create task directory") - return 1 - - task_dir = stdout_capture.getvalue().strip().split("\n")[-1] - task_dir_abs = project_root / task_dir - - log_success(f"Task directory: {task_dir}") - - # ============================================================================= - # Step 2: Prepare and Start Plan Agent - # ============================================================================= - log_info("Step 2: Starting Plan Agent in background...") - - log_file = task_dir_abs / ".plan-log" - log_file.touch() - - # Get proxy environment variables - https_proxy = os.environ.get("https_proxy", "") - http_proxy = os.environ.get("http_proxy", "") - all_proxy = os.environ.get("all_proxy", "") - - # Start agent in background (cross-platform, no shell script needed) - env = os.environ.copy() - env["PLAN_TASK_NAME"] = task_name - env["PLAN_DEV_TYPE"] = dev_type - env["PLAN_TASK_DIR"] = task_dir - env["PLAN_REQUIREMENT"] = requirement - env["https_proxy"] = https_proxy - env["http_proxy"] = http_proxy - env["all_proxy"] = all_proxy - - # Clear nested-session detection so the new CLI process can start - env.pop("CLAUDECODE", None) - - # Set non-interactive env var based on platform - env.update(adapter.get_non_interactive_env()) - - # Build CLI command using adapter - cli_cmd = adapter.build_run_command( - agent="plan", # Will be mapped to "trellis-plan" for OpenCode - prompt=f"Start planning for task: {task_name}", - skip_permissions=True, - verbose=True, - json_output=True, - ) - - with log_file.open("w") as log_f: - # Use shell=False for cross-platform compatibility - # creationflags for Windows, start_new_session for Unix - popen_kwargs = { - "stdout": log_f, - "stderr": subprocess.STDOUT, - "cwd": str(project_root), - "env": env, - } - if sys.platform == "win32": - popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP - else: - popen_kwargs["start_new_session"] = True - - process = subprocess.Popen(cli_cmd, **popen_kwargs) - agent_pid = process.pid - - log_success(f"Plan Agent started (PID: {agent_pid})") - - # ============================================================================= - # Summary - # ============================================================================= - print() - print(f"{Colors.GREEN}=== Plan Agent Running ==={Colors.NC}") - print() - print(f" Task: {task_name}") - print(f" Type: {dev_type}") - print(f" Dir: {task_dir}") - print(f" Log: {log_file}") - print(f" PID: {agent_pid}") - print() - print(f"{Colors.YELLOW}To monitor:{Colors.NC}") - print(f" tail -f {log_file}") - print() - print(f"{Colors.YELLOW}To check status:{Colors.NC}") - print(f" ps -p {agent_pid}") - print(f" ls -la {task_dir}") - print() - print(f"{Colors.YELLOW}After completion, run:{Colors.NC}") - print(f" python3 ./.trellis/scripts/multi_agent/start.py {task_dir}") - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.trellis/scripts/multi_agent/start.py b/.trellis/scripts/multi_agent/start.py deleted file mode 100755 index 40c2747..0000000 --- a/.trellis/scripts/multi_agent/start.py +++ /dev/null @@ -1,465 +0,0 @@ -#!/usr/bin/env python3 -""" -Multi-Agent Pipeline: Start Worktree Agent. - -Usage: python3 start.py <task-dir> -Example: python3 start.py .trellis/tasks/01-21-my-task - -This script: -1. Creates worktree (if not exists) with dependency install -2. Copies environment files (from worktree.yaml config) -3. Sets .current-task in worktree -4. Starts claude agent in background -5. Registers agent to registry.json - -Prerequisites: - - task.json must exist with 'branch' field - - agents/dispatch.md must exist (in .claude/, .cursor/, .iflow/, or .opencode/) - -Configuration: .trellis/worktree.yaml -""" - -from __future__ import annotations - -import json -import os -import shutil -import subprocess -import sys -import uuid -from pathlib import Path - -# Add parent directory to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from common.cli_adapter import CLIAdapter, get_cli_adapter -from common.git_context import _run_git_command -from common.paths import ( - DIR_WORKFLOW, - FILE_CURRENT_TASK, - FILE_TASK_JSON, - get_repo_root, -) -from common.registry import ( - registry_add_agent, - registry_get_file, -) -from common.worktree import ( - get_worktree_base_dir, - get_worktree_config, - get_worktree_copy_files, - get_worktree_post_create_hooks, -) - -# ============================================================================= -# Colors -# ============================================================================= - - -class Colors: - RED = "\033[0;31m" - GREEN = "\033[0;32m" - YELLOW = "\033[1;33m" - BLUE = "\033[0;34m" - NC = "\033[0m" - - -def log_info(msg: str) -> None: - print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}") - - -def log_success(msg: str) -> None: - print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}") - - -def log_warn(msg: str) -> None: - print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}") - - -def log_error(msg: str) -> None: - print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}") - - -# ============================================================================= -# Helper Functions -# ============================================================================= - - -def _read_json_file(path: Path) -> dict | None: - """Read and parse a JSON file.""" - try: - return json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return None - - -def _write_json_file(path: Path, data: dict) -> bool: - """Write dict to JSON file.""" - try: - path.write_text( - json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8" - ) - return True - except (OSError, IOError): - return False - - -# ============================================================================= -# Constants -# ============================================================================= - -DEFAULT_PLATFORM = "claude" - - -# ============================================================================= -# Main -# ============================================================================= - - -def main() -> int: - """Main entry point.""" - import argparse - - parser = argparse.ArgumentParser(description="Multi-Agent Pipeline: Start Worktree Agent") - parser.add_argument("task_dir", help="Task directory path") - parser.add_argument( - "--platform", "-p", - choices=["claude", "cursor", "iflow", "opencode", "qoder"], - default=DEFAULT_PLATFORM, - help="Platform to use (default: claude)" - ) - - args = parser.parse_args() - task_dir_arg = args.task_dir - platform = args.platform - - # Initialize CLI adapter - adapter = get_cli_adapter(platform) - - project_root = get_repo_root() - - # Normalize paths - if task_dir_arg.startswith("/"): - task_dir_relative = task_dir_arg[len(str(project_root)) + 1 :] - task_dir_abs = Path(task_dir_arg) - else: - task_dir_relative = task_dir_arg - task_dir_abs = project_root / task_dir_arg - - task_json_path = task_dir_abs / FILE_TASK_JSON - - # ============================================================================= - # Validation - # ============================================================================= - if not task_json_path.is_file(): - log_error(f"task.json not found at {task_json_path}") - return 1 - - dispatch_md = adapter.get_agent_path("dispatch", project_root) - if not dispatch_md.is_file(): - log_error(f"dispatch.md not found at {dispatch_md}") - log_info(f"Platform: {platform}") - return 1 - - config_file = get_worktree_config(project_root) - if not config_file.is_file(): - log_error(f"worktree.yaml not found at {config_file}") - return 1 - - # ============================================================================= - # Read Task Config - # ============================================================================= - print() - print(f"{Colors.BLUE}=== Multi-Agent Pipeline: Start ==={Colors.NC}") - log_info(f"Task: {task_dir_abs}") - - task_data = _read_json_file(task_json_path) - if not task_data: - log_error("Failed to read task.json") - return 1 - - branch = task_data.get("branch") - task_name = task_data.get("name") - task_status = task_data.get("status") - worktree_path = task_data.get("worktree_path") - - # Check if task was rejected - if task_status == "rejected": - log_error("Task was rejected by Plan Agent") - rejected_file = task_dir_abs / "REJECTED.md" - if rejected_file.is_file(): - print() - print(f"{Colors.YELLOW}Rejection reason:{Colors.NC}") - print(rejected_file.read_text(encoding="utf-8")) - print() - log_info( - "To retry, delete this directory and run plan.py again with revised requirements" - ) - return 1 - - # Check if prd.md exists (plan completed successfully) - prd_file = task_dir_abs / "prd.md" - if not prd_file.is_file(): - log_error("prd.md not found - Plan Agent may not have completed") - log_info(f"Check plan log: {task_dir_abs}/.plan-log") - return 1 - - if not branch: - log_error("branch field not set in task.json") - log_info("Please set branch field first, e.g.:") - log_info( - " jq '.branch = \"task/my-task\"' task.json > tmp && mv tmp task.json" - ) - return 1 - - log_info(f"Branch: {branch}") - log_info(f"Name: {task_name}") - - # ============================================================================= - # Step 1: Create Worktree (if not exists) - # ============================================================================= - if not worktree_path or not Path(worktree_path).is_dir(): - log_info("Step 1: Creating worktree...") - - # Record current branch as base_branch (PR target) - _, base_branch_out, _ = _run_git_command( - ["branch", "--show-current"], cwd=project_root - ) - base_branch = base_branch_out.strip() - log_info(f"Base branch (PR target): {base_branch}") - - # Calculate worktree path - worktree_base = get_worktree_base_dir(project_root) - worktree_base.mkdir(parents=True, exist_ok=True) - worktree_base = worktree_base.resolve() - worktree_path_obj = worktree_base / branch - worktree_path = str(worktree_path_obj) - - # Create parent directory - worktree_path_obj.parent.mkdir(parents=True, exist_ok=True) - - # Create branch if not exists - ret, _, _ = _run_git_command( - ["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"], - cwd=project_root, - ) - if ret == 0: - log_info("Branch exists, checking out...") - ret, _, err = _run_git_command( - ["worktree", "add", worktree_path, branch], cwd=project_root - ) - else: - log_info(f"Creating new branch: {branch}") - ret, _, err = _run_git_command( - ["worktree", "add", "-b", branch, worktree_path], cwd=project_root - ) - - if ret != 0: - log_error(f"Failed to create worktree: {err}") - return 1 - - log_success(f"Worktree created: {worktree_path}") - - # Update task.json with worktree_path and base_branch - task_data["worktree_path"] = worktree_path - task_data["base_branch"] = base_branch - _write_json_file(task_json_path, task_data) - - # ----- Copy environment files ----- - log_info("Copying environment files...") - copy_list = get_worktree_copy_files(project_root) - copy_count = 0 - - for item in copy_list: - if not item: - continue - - source = project_root / item - target = Path(worktree_path) / item - - if source.is_file(): - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(str(source), str(target)) - copy_count += 1 - - if copy_count > 0: - log_success(f"Copied {copy_count} file(s)") - - # ----- Copy task directory (may not be committed yet) ----- - log_info("Copying task directory...") - task_target_dir = Path(worktree_path) / task_dir_relative - task_target_dir.parent.mkdir(parents=True, exist_ok=True) - if task_target_dir.exists(): - shutil.rmtree(str(task_target_dir)) - shutil.copytree(str(task_dir_abs), str(task_target_dir)) - log_success("Task directory copied to worktree") - - # ----- Run post_create hooks ----- - log_info("Running post_create hooks...") - post_create = get_worktree_post_create_hooks(project_root) - hook_count = 0 - - for cmd in post_create: - if not cmd: - continue - - log_info(f" Running: {cmd}") - ret = subprocess.run(cmd, shell=True, cwd=worktree_path) - if ret.returncode != 0: - log_error(f"Hook failed: {cmd}") - return 1 - hook_count += 1 - - if hook_count > 0: - log_success(f"Ran {hook_count} hook(s)") - else: - log_info(f"Step 1: Using existing worktree: {worktree_path}") - - # ============================================================================= - # Step 2: Set .current-task in Worktree - # ============================================================================= - log_info("Step 2: Setting current task in worktree...") - - worktree_workflow_dir = Path(worktree_path) / DIR_WORKFLOW - worktree_workflow_dir.mkdir(parents=True, exist_ok=True) - - current_task_file = worktree_workflow_dir / FILE_CURRENT_TASK - current_task_file.write_text(task_dir_relative, encoding="utf-8") - log_success(f"Current task set: {task_dir_relative}") - - # ============================================================================= - # Step 3: Prepare and Start Claude Agent - # ============================================================================= - log_info(f"Step 3: Starting {adapter.cli_name} agent...") - - # Update task status - task_data["status"] = "in_progress" - _write_json_file(task_json_path, task_data) - - log_file = Path(worktree_path) / ".agent-log" - session_id_file = Path(worktree_path) / ".session-id" - - log_file.touch() - - # Generate session ID for resume support (Claude Code only) - # OpenCode generates its own session ID, we'll extract it from logs later - if adapter.supports_session_id_on_create: - session_id = str(uuid.uuid4()).lower() - session_id_file.write_text(session_id, encoding="utf-8") - log_info(f"Session ID: {session_id}") - else: - session_id = None # Will be extracted from logs after startup - log_info("Session ID will be extracted from logs after startup") - - # Get proxy environment variables - https_proxy = os.environ.get("https_proxy", "") - http_proxy = os.environ.get("http_proxy", "") - all_proxy = os.environ.get("all_proxy", "") - - # Start agent in background (cross-platform, no shell script needed) - env = os.environ.copy() - env["https_proxy"] = https_proxy - env["http_proxy"] = http_proxy - env["all_proxy"] = all_proxy - - # Clear nested-session detection so the new CLI process can start - # (when this script runs inside a Claude Code session, CLAUDECODE=1 is inherited) - env.pop("CLAUDECODE", None) - - # Set non-interactive env var based on platform - env.update(adapter.get_non_interactive_env()) - - # Build CLI command using adapter - # Note: Use explicit prompt to avoid confusion with CI/CD pipelines - # Also remind the model to follow its agent definition for better cross-model compatibility - cli_cmd = adapter.build_run_command( - agent="dispatch", - prompt="Follow your agent instructions to execute the task workflow. Start by reading .trellis/.current-task to get the task directory, then execute each action in task.json next_action array in order.", - session_id=session_id if adapter.supports_session_id_on_create else None, - skip_permissions=True, - verbose=True, - json_output=True, - ) - - with log_file.open("w") as log_f: - # Use shell=False for cross-platform compatibility - # creationflags for Windows, start_new_session for Unix - popen_kwargs = { - "stdout": log_f, - "stderr": subprocess.STDOUT, - "cwd": worktree_path, - "env": env, - } - if sys.platform == "win32": - popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP - else: - popen_kwargs["start_new_session"] = True - - process = subprocess.Popen(cli_cmd, **popen_kwargs) - agent_pid = process.pid - - log_success(f"Agent started with PID: {agent_pid}") - - # For OpenCode: extract session ID from logs after startup - if not adapter.supports_session_id_on_create: - import time - log_info("Waiting for session ID from logs...") - # Wait a bit for the log to have session ID - for _ in range(10): # Try for up to 5 seconds - time.sleep(0.5) - try: - log_content = log_file.read_text(encoding="utf-8", errors="replace") - session_id = adapter.extract_session_id_from_log(log_content) - if session_id: - session_id_file.write_text(session_id, encoding="utf-8") - log_success(f"Session ID extracted: {session_id}") - break - except Exception: - pass - else: - log_warn("Could not extract session ID from logs") - session_id = "unknown" - - # ============================================================================= - # Step 4: Register to Registry (in main repo, not worktree) - # ============================================================================= - log_info("Step 4: Registering agent to registry...") - - # Generate agent ID - task_id = task_data.get("id") - if not task_id: - task_id = branch.replace("/", "-") - - registry_add_agent( - task_id, worktree_path, agent_pid, task_dir_relative, project_root, platform - ) - - log_success(f"Agent registered: {task_id}") - - # ============================================================================= - # Summary - # ============================================================================= - print() - print(f"{Colors.GREEN}=== Agent Started ==={Colors.NC}") - print() - print(f" ID: {task_id}") - print(f" PID: {agent_pid}") - print(f" Session: {session_id}") - print(f" Worktree: {worktree_path}") - print(f" Task: {task_dir_relative}") - print(f" Log: {log_file}") - print(f" Registry: {registry_get_file(project_root)}") - print() - print(f"{Colors.YELLOW}To monitor:{Colors.NC} tail -f {log_file}") - print(f"{Colors.YELLOW}To stop:{Colors.NC} kill {agent_pid}") - if session_id and session_id != "unknown": - resume_cmd = adapter.get_resume_command_str(session_id, cwd=worktree_path) - print(f"{Colors.YELLOW}To resume:{Colors.NC} {resume_cmd}") - else: - print(f"{Colors.YELLOW}To resume:{Colors.NC} (session ID not available)") - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.trellis/scripts/multi_agent/status.py b/.trellis/scripts/multi_agent/status.py deleted file mode 100755 index e83ac60..0000000 --- a/.trellis/scripts/multi_agent/status.py +++ /dev/null @@ -1,817 +0,0 @@ -#!/usr/bin/env python3 -""" -Multi-Agent Pipeline: Status Monitor. - -Usage: - python3 status.py Show summary of all tasks (default) - python3 status.py -a <assignee> Filter tasks by assignee - python3 status.py --list List all worktrees and agents - python3 status.py --detail <task> Detailed task status - python3 status.py --watch <task> Watch agent log in real-time - python3 status.py --log <task> Show recent log entries - python3 status.py --registry Show agent registry -""" - -from __future__ import annotations - -import argparse -import json -import os -import subprocess -import sys -import time -from datetime import datetime -from pathlib import Path - -# Add parent directory to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from common.cli_adapter import get_cli_adapter -from common.developer import ensure_developer -from common.paths import ( - FILE_TASK_JSON, - get_repo_root, - get_tasks_dir, -) -from common.phase import get_phase_info -from common.task_queue import format_task_stats, get_task_stats -from common.worktree import get_agents_dir - -# ============================================================================= -# Colors -# ============================================================================= - - -class Colors: - RED = "\033[0;31m" - GREEN = "\033[0;32m" - YELLOW = "\033[1;33m" - BLUE = "\033[0;34m" - CYAN = "\033[0;36m" - DIM = "\033[2m" - NC = "\033[0m" - - -# ============================================================================= -# Helper Functions -# ============================================================================= - - -def _read_json_file(path: Path) -> dict | None: - """Read and parse a JSON file.""" - try: - return json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return None - - -def is_running(pid: int | str | None) -> bool: - """Check if PID is running.""" - if not pid: - return False - try: - pid_int = int(pid) - os.kill(pid_int, 0) - return True - except (ProcessLookupError, ValueError, PermissionError, TypeError): - return False - - -def status_color(status: str) -> str: - """Get status color.""" - colors = { - "completed": Colors.GREEN, - "in_progress": Colors.BLUE, - "planning": Colors.YELLOW, - } - return colors.get(status, Colors.DIM) - - -def get_registry_file(repo_root: Path) -> Path | None: - """Get registry file path.""" - agents_dir = get_agents_dir(repo_root) - if agents_dir: - return agents_dir / "registry.json" - return None - - -def find_agent(search: str, repo_root: Path) -> dict | None: - """Find agent by task name or ID.""" - registry_file = get_registry_file(repo_root) - if not registry_file or not registry_file.is_file(): - return None - - data = _read_json_file(registry_file) - if not data: - return None - - for agent in data.get("agents", []): - # Exact ID match - if agent.get("id") == search: - return agent - # Partial match on task_dir - task_dir = agent.get("task_dir", "") - if search in task_dir: - return agent - - return None - - -def calc_elapsed(started: str | None) -> str: - """Calculate elapsed time from ISO timestamp.""" - if not started: - return "N/A" - - try: - # Parse ISO format - if "+" in started: - started = started.split("+")[0] - if "T" in started: - start_dt = datetime.fromisoformat(started) - else: - return "N/A" - - now = datetime.now() - elapsed = (now - start_dt).total_seconds() - - if elapsed < 60: - return f"{int(elapsed)}s" - elif elapsed < 3600: - mins = int(elapsed // 60) - secs = int(elapsed % 60) - return f"{mins}m {secs}s" - else: - hours = int(elapsed // 3600) - mins = int((elapsed % 3600) // 60) - return f"{hours}h {mins}m" - except (ValueError, TypeError): - return "N/A" - - -def count_modified_files(worktree: str) -> int: - """Count modified files in worktree.""" - if not Path(worktree).is_dir(): - return 0 - - try: - result = subprocess.run( - ["git", "status", "--short"], - cwd=worktree, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - return len([line for line in result.stdout.splitlines() if line.strip()]) - except Exception: - return 0 - - -def tail_follow(file_path: Path) -> None: - """Follow a file like 'tail -f', cross-platform compatible.""" - with open(file_path, "r", encoding="utf-8", errors="replace") as f: - # Seek to end of file - f.seek(0, 2) - - while True: - line = f.readline() - if line: - print(line, end="", flush=True) - else: - time.sleep(0.1) - - -def get_last_tool(log_file: Path, platform: str = "claude") -> str | None: - """Get the last tool call from agent log. - - Supports both Claude Code and OpenCode log formats. - - Claude Code format: - {"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Read"}]}} - - OpenCode format: - {"type": "tool_use", "tool": "bash", "state": {"status": "completed"}} - """ - if not log_file.is_file(): - return None - - try: - lines = log_file.read_text(encoding="utf-8").splitlines() - for line in reversed(lines[-100:]): - try: - data = json.loads(line) - - if platform == "opencode": - # OpenCode format: {"type": "tool_use", "tool": "bash", ...} - if data.get("type") == "tool_use": - return data.get("tool") - else: - # Claude Code format: {"type": "assistant", "message": {"content": [...]}} - if data.get("type") == "assistant": - content = data.get("message", {}).get("content", []) - for item in content: - if item.get("type") == "tool_use": - return item.get("name") - except json.JSONDecodeError: - continue - except Exception: - pass - return None - - -def get_last_message(log_file: Path, max_len: int = 100, platform: str = "claude") -> str | None: - """Get the last assistant text from agent log. - - Supports both Claude Code and OpenCode log formats. - - Claude Code format: - {"type": "assistant", "message": {"content": [{"type": "text", "text": "..."}]}} - - OpenCode format: - {"type": "text", "text": "..."} - """ - if not log_file.is_file(): - return None - - try: - lines = log_file.read_text(encoding="utf-8").splitlines() - for line in reversed(lines[-100:]): - try: - data = json.loads(line) - - if platform == "opencode": - # OpenCode format: {"type": "text", "text": "..."} - if data.get("type") == "text": - text = data.get("text", "") - if text: - return text[:max_len] - else: - # Claude Code format: {"type": "assistant", "message": {"content": [...]}} - if data.get("type") == "assistant": - content = data.get("message", {}).get("content", []) - for item in content: - if item.get("type") == "text": - text = item.get("text", "") - if text: - return text[:max_len] - except json.JSONDecodeError: - continue - except Exception: - pass - return None - - -# ============================================================================= -# Commands -# ============================================================================= - - -def cmd_help() -> int: - """Show help.""" - print("""Multi-Agent Pipeline: Status Monitor - -Usage: - python3 status.py Show summary of all tasks - python3 status.py -a <assignee> Filter tasks by assignee - python3 status.py --list List all worktrees and agents - python3 status.py --detail <task> Detailed task status - python3 status.py --progress <task> Quick progress view with recent activity - python3 status.py --watch <task> Watch agent log in real-time - python3 status.py --log <task> Show recent log entries - python3 status.py --registry Show agent registry - -Examples: - python3 status.py -a taosu - python3 status.py --detail my-task - python3 status.py --progress my-task - python3 status.py --watch 01-16-worktree-support - python3 status.py --log worktree-support -""") - return 0 - - -def cmd_list(repo_root: Path) -> int: - """List worktrees and agents.""" - print(f"{Colors.BLUE}=== Git Worktrees ==={Colors.NC}") - print() - - subprocess.run(["git", "worktree", "list"], cwd=repo_root) - print() - - print(f"{Colors.BLUE}=== Registered Agents ==={Colors.NC}") - print() - - registry_file = get_registry_file(repo_root) - if not registry_file or not registry_file.is_file(): - print(" (no registry found)") - return 0 - - data = _read_json_file(registry_file) - if not data or not data.get("agents"): - print(" (no agents registered)") - return 0 - - for agent in data["agents"]: - agent_id = agent.get("id", "?") - pid = agent.get("pid") - wt = agent.get("worktree_path", "?") - started = agent.get("started_at", "?") - - if is_running(pid): - status_icon = f"{Colors.GREEN}●{Colors.NC}" - else: - status_icon = f"{Colors.RED}○{Colors.NC}" - - 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.""" - ensure_developer(repo_root) - - tasks_dir = get_tasks_dir(repo_root) - if not tasks_dir.is_dir(): - print("No tasks directory found") - return 0 - - registry_file = get_registry_file(repo_root) - - # Count running agents - running_count = 0 - total_agents = 0 - - if registry_file and registry_file.is_file(): - data = _read_json_file(registry_file) - if data: - agents = data.get("agents", []) - total_agents = len(agents) - for agent in agents: - if is_running(agent.get("pid")): - running_count += 1 - - # Task queue stats - task_stats = get_task_stats(repo_root) - - print(f"{Colors.BLUE}=== Multi-Agent Status ==={Colors.NC}") - print( - f" Agents: {Colors.GREEN}{running_count}{Colors.NC} running / {total_agents} registered" - ) - print(f" Tasks: {format_task_stats(task_stats)}") - print() - - # Process tasks - running_tasks = [] - stopped_tasks = [] - regular_tasks = [] - - registry_data = ( - _read_json_file(registry_file) - if registry_file and registry_file.is_file() - else None - ) - - for d in sorted(tasks_dir.iterdir()): - if not d.is_dir() or d.name == "archive": - continue - - name = d.name - task_json = d / FILE_TASK_JSON - status = "unknown" - assignee = "unassigned" - priority = "P2" - - if task_json.is_file(): - data = _read_json_file(task_json) - if data: - status = data.get("status", "unknown") - assignee = data.get("assignee", "unassigned") - priority = data.get("priority", "P2") - - # Filter by assignee - if filter_assignee and assignee != filter_assignee: - continue - - # Check agent status - agent_info = None - if registry_data: - for agent in registry_data.get("agents", []): - if name in agent.get("task_dir", ""): - agent_info = agent - break - - if agent_info: - pid = agent_info.get("pid") - worktree = agent_info.get("worktree_path", "") - started = agent_info.get("started_at") - agent_platform = agent_info.get("platform", "claude") - - if is_running(pid): - # Running agent - task_dir_rel = agent_info.get("task_dir", "") - worktree_task_json = Path(worktree) / task_dir_rel / "task.json" - phase_source = task_json - if worktree_task_json.is_file(): - phase_source = worktree_task_json - - phase_info_str = get_phase_info(phase_source) - elapsed = calc_elapsed(started) - modified = count_modified_files(worktree) - - worktree_data = _read_json_file(phase_source) - branch = worktree_data.get("branch", "N/A") if worktree_data else "N/A" - - log_file = Path(worktree) / ".agent-log" - last_tool = get_last_tool(log_file, platform=agent_platform) - - running_tasks.append( - { - "name": name, - "priority": priority, - "assignee": assignee, - "phase_info": phase_info_str, - "elapsed": elapsed, - "branch": branch, - "modified": modified, - "last_tool": last_tool, - "pid": pid, - } - ) - else: - # Stopped agent - task_dir_rel = agent_info.get("task_dir", "") - worktree_task_json = Path(worktree) / task_dir_rel / "task.json" - worktree_status = "unknown" - - if worktree_task_json.is_file(): - wt_data = _read_json_file(worktree_task_json) - if wt_data: - worktree_status = wt_data.get("status", "unknown") - - session_id_file = Path(worktree) / ".session-id" - log_file = Path(worktree) / ".agent-log" - - stopped_tasks.append( - { - "name": name, - "worktree": worktree, - "status": worktree_status, - "session_id_file": session_id_file, - "log_file": log_file, - "platform": agent_info.get("platform", "claude"), - } - ) - else: - # Regular task - regular_tasks.append( - { - "name": name, - "status": status, - "priority": priority, - "assignee": assignee, - } - ) - - # Output running agents - if running_tasks: - print(f"{Colors.CYAN}Running Agents:{Colors.NC}") - for t in running_tasks: - priority_color = ( - Colors.RED - if t["priority"] == "P0" - else (Colors.YELLOW if t["priority"] == "P1" else Colors.BLUE) - ) - print( - f"{Colors.GREEN}▶{Colors.NC} {Colors.CYAN}{t['name']}{Colors.NC} {Colors.GREEN}[running]{Colors.NC} {priority_color}[{t['priority']}]{Colors.NC} @{t['assignee']}" - ) - print(f" Phase: {t['phase_info']}") - print(f" Elapsed: {t['elapsed']}") - print(f" Branch: {Colors.DIM}{t['branch']}{Colors.NC}") - print(f" Modified: {t['modified']} file(s)") - if t["last_tool"]: - print(f" Activity: {Colors.YELLOW}{t['last_tool']}{Colors.NC}") - print(f" PID: {Colors.DIM}{t['pid']}{Colors.NC}") - print() - - # Output stopped agents - if stopped_tasks: - print(f"{Colors.RED}Stopped Agents:{Colors.NC}") - for t in stopped_tasks: - if t["status"] == "completed": - print( - f"{Colors.GREEN}✓{Colors.NC} {t['name']} {Colors.GREEN}[completed]{Colors.NC}" - ) - else: - if t["session_id_file"].is_file(): - session_id = ( - t["session_id_file"].read_text(encoding="utf-8").strip() - ) - last_msg = get_last_message(t["log_file"], 150, platform=t.get("platform", "claude")) - print( - f"{Colors.RED}○{Colors.NC} {t['name']} {Colors.RED}[stopped]{Colors.NC}" - ) - if last_msg: - print(f'{Colors.DIM}"{last_msg}"{Colors.NC}') - # Use CLI adapter for platform-specific resume command - adapter = get_cli_adapter(t.get("platform", "claude")) - resume_cmd = adapter.get_resume_command_str(session_id, cwd=t["worktree"]) - print(f"{Colors.YELLOW}{resume_cmd}{Colors.NC}") - else: - print( - f"{Colors.RED}○{Colors.NC} {t['name']} {Colors.RED}[stopped]{Colors.NC} {Colors.DIM}(no session-id){Colors.NC}" - ) - print() - - # Separator - if (running_tasks or stopped_tasks) and regular_tasks: - print(f"{Colors.DIM}───────────────────────────────────────{Colors.NC}") - print() - - # Output regular tasks grouped by assignee - if regular_tasks: - # Sort by assignee, priority, status - regular_tasks.sort( - key=lambda x: ( - x["assignee"], - {"P0": 0, "P1": 1, "P2": 2, "P3": 3}.get(x["priority"], 2), - {"in_progress": 0, "planning": 1, "completed": 2}.get(x["status"], 1), - ) - ) - - current_assignee = None - for t in regular_tasks: - if t["assignee"] != current_assignee: - if current_assignee is not None: - print() - print(f"{Colors.CYAN}@{t['assignee']}:{Colors.NC}") - current_assignee = t["assignee"] - - color = status_color(t["status"]) - priority_color = ( - Colors.RED - if t["priority"] == "P0" - else (Colors.YELLOW if t["priority"] == "P1" else Colors.BLUE) - ) - print( - f" {color}●{Colors.NC} {t['name']} ({t['status']}) {priority_color}[{t['priority']}]{Colors.NC}" - ) - - if running_tasks: - print() - print(f"{Colors.DIM}─────────────────────────────────────{Colors.NC}") - print(f"{Colors.DIM}Use --progress <name> for quick activity view{Colors.NC}") - print(f"{Colors.DIM}Use --detail <name> for more info{Colors.NC}") - - print() - return 0 - - -def cmd_detail(target: str, repo_root: Path) -> int: - """Show detailed task status.""" - agent = find_agent(target, repo_root) - if not agent: - print(f"Agent not found: {target}") - return 1 - - agent_id = agent.get("id", "?") - pid = agent.get("pid") - worktree = agent.get("worktree_path", "?") - task_dir = agent.get("task_dir", "?") - started = agent.get("started_at", "?") - platform = agent.get("platform", "claude") - - # Check for session-id - session_id = "" - session_id_file = Path(worktree) / ".session-id" - if session_id_file.is_file(): - session_id = session_id_file.read_text(encoding="utf-8").strip() - - print(f"{Colors.BLUE}=== Agent Detail: {agent_id} ==={Colors.NC}") - print() - print(f" ID: {agent_id}") - print(f" PID: {pid}") - print(f" Session: {session_id or 'N/A'}") - print(f" Worktree: {worktree}") - print(f" Task Dir: {task_dir}") - print(f" Started: {started}") - print() - - # Status - if is_running(pid): - print(f" Status: {Colors.GREEN}Running{Colors.NC}") - else: - print(f" Status: {Colors.RED}Stopped{Colors.NC}") - if session_id: - print() - # Use CLI adapter for platform-specific resume command - adapter = get_cli_adapter(platform) - resume_cmd = adapter.get_resume_command_str(session_id, cwd=worktree) - print(f" {Colors.YELLOW}Resume:{Colors.NC} {resume_cmd}") - - # Task info - task_json = repo_root / task_dir / "task.json" - if task_json.is_file(): - print() - print(f"{Colors.BLUE}=== Task Info ==={Colors.NC}") - print() - data = _read_json_file(task_json) - if data: - print(f" Status: {data.get('status', 'unknown')}") - print(f" Branch: {data.get('branch', 'N/A')}") - print(f" Base Branch: {data.get('base_branch', 'N/A')}") - - # Git changes - if Path(worktree).is_dir(): - print() - print(f"{Colors.BLUE}=== Git Changes ==={Colors.NC}") - print() - - result = subprocess.run( - ["git", "status", "--short"], - cwd=worktree, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - changes = result.stdout.strip() - if changes: - for line in changes.splitlines()[:10]: - print(f" {line}") - total = len(changes.splitlines()) - if total > 10: - print(f" ... and {total - 10} more") - else: - print(" (no changes)") - - print() - return 0 - - -def cmd_watch(target: str, repo_root: Path) -> int: - """Watch agent log in real-time.""" - agent = find_agent(target, repo_root) - if not agent: - print(f"Agent not found: {target}") - return 1 - - worktree = agent.get("worktree_path", "") - log_file = Path(worktree) / ".agent-log" - - if not log_file.is_file(): - print(f"Log file not found: {log_file}") - return 1 - - print(f"{Colors.BLUE}Watching:{Colors.NC} {log_file}") - print(f"{Colors.DIM}Press Ctrl+C to stop{Colors.NC}") - print() - - try: - tail_follow(log_file) - except KeyboardInterrupt: - print() # Clean newline after Ctrl+C - return 0 - - -def cmd_log(target: str, repo_root: Path) -> int: - """Show recent log entries.""" - agent = find_agent(target, repo_root) - if not agent: - print(f"Agent not found: {target}") - return 1 - - worktree = agent.get("worktree_path", "") - platform = agent.get("platform", "claude") - log_file = Path(worktree) / ".agent-log" - - if not log_file.is_file(): - print(f"Log file not found: {log_file}") - return 1 - - print(f"{Colors.BLUE}=== Recent Log: {target} ==={Colors.NC}") - print(f"{Colors.DIM}Platform: {platform}{Colors.NC}") - print() - - lines = log_file.read_text(encoding="utf-8").splitlines() - for line in lines[-50:]: - try: - data = json.loads(line) - msg_type = data.get("type", "") - - if platform == "opencode": - # OpenCode format - if msg_type == "text": - text = data.get("text", "") - if text: - display = text[:300] - if len(text) > 300: - display += "..." - print(f"{Colors.BLUE}[TEXT]{Colors.NC} {display}") - elif msg_type == "tool_use": - tool_name = data.get("tool", "unknown") - status = data.get("state", {}).get("status", "") - print(f"{Colors.YELLOW}[TOOL]{Colors.NC} {tool_name} ({status})") - elif msg_type == "step_start": - print(f"{Colors.CYAN}[STEP]{Colors.NC} Start") - elif msg_type == "step_finish": - reason = data.get("reason", "") - print(f"{Colors.CYAN}[STEP]{Colors.NC} Finish ({reason})") - elif msg_type == "error": - error_msg = data.get("message", "") - print(f"{Colors.RED}[ERROR]{Colors.NC} {error_msg}") - else: - # Claude Code format - if msg_type == "system": - subtype = data.get("subtype", "") - print(f"{Colors.CYAN}[SYSTEM]{Colors.NC} {subtype}") - elif msg_type == "user": - content = data.get("message", {}).get("content", "") - if content: - print(f"{Colors.GREEN}[USER]{Colors.NC} {content[:200]}") - elif msg_type == "assistant": - content = data.get("message", {}).get("content", []) - if content: - item = content[0] - text = item.get("text") - tool = item.get("name") - if text: - display = text[:300] - if len(text) > 300: - display += "..." - print(f"{Colors.BLUE}[ASSISTANT]{Colors.NC} {display}") - elif tool: - print(f"{Colors.YELLOW}[TOOL]{Colors.NC} {tool}") - elif msg_type == "result": - tool_name = data.get("tool", "unknown") - print(f"{Colors.DIM}[RESULT]{Colors.NC} {tool_name} completed") - except json.JSONDecodeError: - continue - - return 0 - - -def cmd_registry(repo_root: Path) -> int: - """Show agent registry.""" - registry_file = get_registry_file(repo_root) - - print(f"{Colors.BLUE}=== Agent Registry ==={Colors.NC}") - print() - print(f"File: {registry_file}") - print() - - if registry_file and registry_file.is_file(): - data = _read_json_file(registry_file) - if data: - print(json.dumps(data, indent=2)) - else: - print("(registry not found)") - - return 0 - - -# ============================================================================= -# Main -# ============================================================================= - - -def main() -> int: - """Main entry point.""" - parser = argparse.ArgumentParser(description="Multi-Agent Pipeline: Status Monitor") - parser.add_argument("-a", "--assignee", help="Filter by assignee") - parser.add_argument( - "--list", action="store_true", help="List all worktrees and agents" - ) - parser.add_argument("--detail", metavar="TASK", help="Detailed task status") - parser.add_argument("--progress", metavar="TASK", help="Quick progress view") - parser.add_argument("--watch", metavar="TASK", help="Watch agent log") - parser.add_argument("--log", metavar="TASK", help="Show recent log entries") - parser.add_argument("--registry", action="store_true", help="Show agent registry") - parser.add_argument("target", nargs="?", help="Target task") - - args = parser.parse_args() - repo_root = get_repo_root() - - if args.list: - return cmd_list(repo_root) - elif args.detail: - return cmd_detail(args.detail, repo_root) - elif args.progress: - return cmd_detail(args.progress, repo_root) # Similar to detail - elif args.watch: - return cmd_watch(args.watch, repo_root) - elif args.log: - return cmd_log(args.log, repo_root) - elif args.registry: - return cmd_registry(repo_root) - elif args.target: - return cmd_detail(args.target, repo_root) - else: - return cmd_summary(repo_root, args.assignee) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.trellis/scripts/task.py b/.trellis/scripts/task.py index 29f614c..6052ac9 100755 --- a/.trellis/scripts/task.py +++ b/.trellis/scripts/task.py @@ -1,21 +1,20 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Task Management Script for Multi-Agent Pipeline. +Task Management Script. Usage: - python3 task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] - python3 task.py init-context <dir> <type> # Initialize jsonl files + python3 task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] [--package <pkg>] python3 task.py add-context <dir> <file> <path> [reason] # Add jsonl entry python3 task.py validate <dir> # Validate jsonl files python3 task.py list-context <dir> # List jsonl entries - python3 task.py start <dir> # Set as current task - python3 task.py finish # Clear current task + python3 task.py start <dir> # Set active task + python3 task.py current [--source] # Show active task + python3 task.py finish # Clear active task python3 task.py set-branch <dir> <branch> # Set git branch python3 task.py set-base-branch <dir> <branch> # Set PR target branch python3 task.py set-scope <dir> <scope> # Set scope for PR title - python3 task.py create-pr [dir] [--dry-run] # Create PR from task - python3 task.py archive <task-name> # Archive completed task + python3 task.py archive <task-dir> # Archive completed task python3 task.py list # List active tasks python3 task.py list-archive [month] # List archived tasks python3 task.py add-subtask <parent-dir> <child-dir> # Link child to parent @@ -24,617 +23,44 @@ Usage: from __future__ import annotations -import sys - -# IMPORTANT: Force stdout to use UTF-8 on Windows -# This fixes UnicodeEncodeError when outputting non-ASCII characters -if sys.platform == "win32": - import io as _io - if hasattr(sys.stdout, "reconfigure"): - sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] - elif hasattr(sys.stdout, "detach"): - sys.stdout = _io.TextIOWrapper(sys.stdout.detach(), encoding="utf-8", errors="replace") # type: ignore[union-attr] - import argparse -import json -import re import sys -from datetime import datetime -from pathlib import Path -from common.cli_adapter import get_cli_adapter_auto -from common.git_context import _run_git_command +from common.log import Colors, colored from common.paths import ( DIR_WORKFLOW, DIR_TASKS, - DIR_SPEC, - DIR_ARCHIVE, FILE_TASK_JSON, get_repo_root, get_developer, get_tasks_dir, get_current_task, - set_current_task, - clear_current_task, - generate_task_date_prefix, ) -from common.task_utils import ( - find_task_by_name, - archive_task_complete, +from common.active_task import ( + clear_active_task, + resolve_active_task, + resolve_context_key, + set_active_task, +) +from common.io import read_json, write_json +from common.task_utils import resolve_task_dir, run_task_hooks +from common.tasks import iter_active_tasks, children_progress + +# Import command handlers from split modules (also re-exports for plan.py compatibility) +from common.task_store import ( + cmd_create, + cmd_archive, + cmd_set_branch, + cmd_set_base_branch, + cmd_set_scope, + cmd_add_subtask, + cmd_remove_subtask, +) +from common.task_context import ( + cmd_add_context, + cmd_validate, + cmd_list_context, ) -from common.config import get_hooks - - -# ============================================================================= -# Colors -# ============================================================================= - -class Colors: - RED = "\033[0;31m" - GREEN = "\033[0;32m" - YELLOW = "\033[1;33m" - BLUE = "\033[0;34m" - CYAN = "\033[0;36m" - NC = "\033[0m" - - -def colored(text: str, color: str) -> str: - """Apply color to text.""" - return f"{color}{text}{Colors.NC}" - - -# ============================================================================= -# Lifecycle Hooks -# ============================================================================= - -def _run_hooks(event: str, task_json_path: Path, repo_root: Path) -> None: - """Run lifecycle hooks for an event. - - Args: - event: Event name (e.g. "after_create"). - task_json_path: Absolute path to the task's task.json. - repo_root: Repository root for cwd and config lookup. - """ - import os - import subprocess - - commands = get_hooks(event, repo_root) - if not commands: - return - - env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)} - - for cmd in commands: - try: - result = subprocess.run( - cmd, - shell=True, - cwd=repo_root, - env=env, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - if result.returncode != 0: - print( - colored(f"[WARN] Hook failed ({event}): {cmd}", Colors.YELLOW), - file=sys.stderr, - ) - if result.stderr.strip(): - print(f" {result.stderr.strip()}", file=sys.stderr) - except Exception as e: - print( - colored(f"[WARN] Hook error ({event}): {cmd} — {e}", Colors.YELLOW), - file=sys.stderr, - ) - - -# ============================================================================= -# Helper Functions -# ============================================================================= - -def _read_json_file(path: Path) -> dict | None: - """Read and parse a JSON file.""" - try: - return json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return None - - -def _write_json_file(path: Path, data: dict) -> bool: - """Write dict to JSON file.""" - try: - path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") - return True - except (OSError, IOError): - return False - - -def _slugify(title: str) -> str: - """Convert title to slug (only works with ASCII).""" - result = title.lower() - result = re.sub(r"[^a-z0-9]", "-", result) - result = re.sub(r"-+", "-", result) - result = result.strip("-") - return result - - -def _resolve_task_dir(target_dir: str, repo_root: Path) -> Path: - """Resolve task directory to absolute path. - - Supports: - - Absolute path: /path/to/task - - Relative path: .trellis/tasks/01-31-my-task - - Task name: my-task (uses find_task_by_name for lookup) - """ - if not target_dir: - return Path() - - # Absolute path - if target_dir.startswith("/"): - return Path(target_dir) - - # Relative path (contains path separator or starts with .trellis) - if "/" in target_dir or target_dir.startswith(".trellis"): - return repo_root / target_dir - - # Task name - try to find in tasks directory - tasks_dir = get_tasks_dir(repo_root) - found = find_task_by_name(target_dir, tasks_dir) - if found: - return found - - # Fallback to treating as relative path - return repo_root / target_dir - - -# ============================================================================= -# JSONL Default Content Generators -# ============================================================================= - -def get_implement_base() -> list[dict]: - """Get base implement context entries.""" - return [ - {"file": f"{DIR_WORKFLOW}/workflow.md", "reason": "Project workflow and conventions"}, - ] - - -def get_implement_backend() -> list[dict]: - """Get backend implement context entries.""" - return [ - {"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/backend/index.md", "reason": "Backend development guide"}, - ] - - -def get_implement_frontend() -> list[dict]: - """Get frontend implement context entries.""" - return [ - {"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/frontend/index.md", "reason": "Frontend development guide"}, - ] - - -def get_check_context(dev_type: str, repo_root: Path) -> list[dict]: - """Get check context entries.""" - adapter = get_cli_adapter_auto(repo_root) - - entries = [ - {"file": adapter.get_trellis_command_path("finish-work"), "reason": "Finish work checklist"}, - ] - - if dev_type in ("backend", "fullstack"): - entries.append({"file": adapter.get_trellis_command_path("check-backend"), "reason": "Backend check spec"}) - if dev_type in ("frontend", "fullstack"): - entries.append({"file": adapter.get_trellis_command_path("check-frontend"), "reason": "Frontend check spec"}) - - return entries - - -def get_debug_context(dev_type: str, repo_root: Path) -> list[dict]: - """Get debug context entries.""" - adapter = get_cli_adapter_auto(repo_root) - - entries: list[dict] = [] - - if dev_type in ("backend", "fullstack"): - entries.append({"file": adapter.get_trellis_command_path("check-backend"), "reason": "Backend check spec"}) - if dev_type in ("frontend", "fullstack"): - entries.append({"file": adapter.get_trellis_command_path("check-frontend"), "reason": "Frontend check spec"}) - - return entries - - -def _write_jsonl(path: Path, entries: list[dict]) -> None: - """Write entries to JSONL file.""" - lines = [json.dumps(entry, ensure_ascii=False) for entry in entries] - path.write_text("\n".join(lines) + "\n", encoding="utf-8") - - -# ============================================================================= -# Task Operations -# ============================================================================= - -def ensure_tasks_dir(repo_root: Path) -> Path: - """Ensure tasks directory exists.""" - tasks_dir = get_tasks_dir(repo_root) - archive_dir = tasks_dir / "archive" - - if not tasks_dir.exists(): - tasks_dir.mkdir(parents=True) - print(colored(f"Created tasks directory: {tasks_dir}", Colors.GREEN), file=sys.stderr) - - if not archive_dir.exists(): - archive_dir.mkdir(parents=True) - - return tasks_dir - - -# ============================================================================= -# Command: create -# ============================================================================= - -def cmd_create(args: argparse.Namespace) -> int: - """Create a new task.""" - repo_root = get_repo_root() - - if not args.title: - print(colored("Error: title is required", Colors.RED), file=sys.stderr) - return 1 - - # Default assignee to current developer - assignee = args.assignee - if not assignee: - assignee = get_developer(repo_root) - if not assignee: - print(colored("Error: No developer set. Run init_developer.py first or use --assignee", Colors.RED), file=sys.stderr) - return 1 - - ensure_tasks_dir(repo_root) - - # Get current developer as creator - creator = get_developer(repo_root) or assignee - - # Generate slug if not provided - slug = args.slug or _slugify(args.title) - if not slug: - print(colored("Error: could not generate slug from title", Colors.RED), file=sys.stderr) - return 1 - - # Create task directory with MM-DD-slug format - tasks_dir = get_tasks_dir(repo_root) - date_prefix = generate_task_date_prefix() - dir_name = f"{date_prefix}-{slug}" - task_dir = tasks_dir / dir_name - task_json_path = task_dir / FILE_TASK_JSON - - if task_dir.exists(): - print(colored(f"Warning: Task directory already exists: {dir_name}", Colors.YELLOW), file=sys.stderr) - else: - task_dir.mkdir(parents=True) - - today = datetime.now().strftime("%Y-%m-%d") - - # Record current branch as base_branch (PR target) - _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root) - current_branch = branch_out.strip() or "main" - - task_data = { - "id": slug, - "name": slug, - "title": args.title, - "description": args.description or "", - "status": "planning", - "dev_type": None, - "scope": None, - "priority": args.priority, - "creator": creator, - "assignee": assignee, - "createdAt": today, - "completedAt": None, - "branch": None, - "base_branch": current_branch, - "worktree_path": None, - "current_phase": 0, - "next_action": [ - {"phase": 1, "action": "implement"}, - {"phase": 2, "action": "check"}, - {"phase": 3, "action": "finish"}, - {"phase": 4, "action": "create-pr"}, - ], - "commit": None, - "pr_url": None, - "subtasks": [], - "children": [], - "parent": None, - "relatedFiles": [], - "notes": "", - "meta": {}, - } - - _write_json_file(task_json_path, task_data) - - # Handle --parent: establish bidirectional link - if args.parent: - parent_dir = _resolve_task_dir(args.parent, repo_root) - parent_json_path = parent_dir / FILE_TASK_JSON - if not parent_json_path.is_file(): - print(colored(f"Warning: Parent task.json not found: {args.parent}", Colors.YELLOW), file=sys.stderr) - else: - parent_data = _read_json_file(parent_json_path) - if parent_data: - # Add child to parent's children list - parent_children = parent_data.get("children", []) - if dir_name not in parent_children: - parent_children.append(dir_name) - parent_data["children"] = parent_children - _write_json_file(parent_json_path, parent_data) - - # Set parent in child's task.json - task_data["parent"] = parent_dir.name - _write_json_file(task_json_path, task_data) - - print(colored(f"Linked as child of: {parent_dir.name}", Colors.GREEN), file=sys.stderr) - - print(colored(f"Created task: {dir_name}", Colors.GREEN), file=sys.stderr) - print("", file=sys.stderr) - print(colored("Next steps:", Colors.BLUE), file=sys.stderr) - print(" 1. Create prd.md with requirements", file=sys.stderr) - print(" 2. Run: python3 task.py init-context <dir> <dev_type>", file=sys.stderr) - print(" 3. Run: python3 task.py start <dir>", file=sys.stderr) - print("", file=sys.stderr) - - # Output relative path for script chaining - print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}") - - _run_hooks("after_create", task_json_path, repo_root) - return 0 - - -# ============================================================================= -# Command: init-context -# ============================================================================= - -def cmd_init_context(args: argparse.Namespace) -> int: - """Initialize JSONL context files for a task.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - dev_type = args.type - - if not dev_type: - print(colored("Error: Missing arguments", Colors.RED)) - print("Usage: python3 task.py init-context <task-dir> <dev_type>") - print(" dev_type: backend | frontend | fullstack | test | docs") - return 1 - - if not target_dir.is_dir(): - print(colored(f"Error: Directory not found: {target_dir}", Colors.RED)) - return 1 - - print(colored("=== Initializing Agent Context Files ===", Colors.BLUE)) - print(f"Target dir: {target_dir}") - print(f"Dev type: {dev_type}") - print() - - # implement.jsonl - print(colored("Creating implement.jsonl...", Colors.CYAN)) - implement_entries = get_implement_base() - if dev_type in ("backend", "test"): - implement_entries.extend(get_implement_backend()) - elif dev_type == "frontend": - implement_entries.extend(get_implement_frontend()) - elif dev_type == "fullstack": - implement_entries.extend(get_implement_backend()) - implement_entries.extend(get_implement_frontend()) - - implement_file = target_dir / "implement.jsonl" - _write_jsonl(implement_file, implement_entries) - print(f" {colored('✓', Colors.GREEN)} {len(implement_entries)} entries") - - # check.jsonl - print(colored("Creating check.jsonl...", Colors.CYAN)) - check_entries = get_check_context(dev_type, repo_root) - check_file = target_dir / "check.jsonl" - _write_jsonl(check_file, check_entries) - print(f" {colored('✓', Colors.GREEN)} {len(check_entries)} entries") - - # debug.jsonl - print(colored("Creating debug.jsonl...", Colors.CYAN)) - debug_entries = get_debug_context(dev_type, repo_root) - debug_file = target_dir / "debug.jsonl" - _write_jsonl(debug_file, debug_entries) - print(f" {colored('✓', Colors.GREEN)} {len(debug_entries)} entries") - - print() - print(colored("✓ All context files created", Colors.GREEN)) - print() - print(colored("Next steps:", Colors.BLUE)) - print(" 1. Add task-specific specs: python3 task.py add-context <dir> <jsonl> <path>") - print(" 2. Set as current: python3 task.py start <dir>") - - return 0 - - -# ============================================================================= -# Command: add-context -# ============================================================================= - -def cmd_add_context(args: argparse.Namespace) -> int: - """Add entry to JSONL context file.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - - jsonl_name = args.file - path = args.path - reason = args.reason or "Added manually" - - if not target_dir.is_dir(): - print(colored(f"Error: Directory not found: {target_dir}", Colors.RED)) - return 1 - - # Support shorthand - if not jsonl_name.endswith(".jsonl"): - jsonl_name = f"{jsonl_name}.jsonl" - - jsonl_file = target_dir / jsonl_name - full_path = repo_root / path - - entry_type = "file" - if full_path.is_dir(): - entry_type = "directory" - if not path.endswith("/"): - path = f"{path}/" - elif not full_path.is_file(): - print(colored(f"Error: Path not found: {path}", Colors.RED)) - return 1 - - # Check if already exists - if jsonl_file.is_file(): - content = jsonl_file.read_text(encoding="utf-8") - if f'"{path}"' in content: - print(colored(f"Warning: Entry already exists for {path}", Colors.YELLOW)) - return 0 - - # Add entry - entry: dict - if entry_type == "directory": - entry = {"file": path, "type": "directory", "reason": reason} - else: - entry = {"file": path, "reason": reason} - - with jsonl_file.open("a", encoding="utf-8") as f: - f.write(json.dumps(entry, ensure_ascii=False) + "\n") - - print(colored(f"Added {entry_type}: {path}", Colors.GREEN)) - return 0 - - -# ============================================================================= -# Command: validate -# ============================================================================= - -def cmd_validate(args: argparse.Namespace) -> int: - """Validate JSONL context files.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - - if not target_dir.is_dir(): - print(colored("Error: task directory required", Colors.RED)) - return 1 - - print(colored("=== Validating Context Files ===", Colors.BLUE)) - print(f"Target dir: {target_dir}") - print() - - total_errors = 0 - for jsonl_name in ["implement.jsonl", "check.jsonl", "debug.jsonl"]: - jsonl_file = target_dir / jsonl_name - errors = _validate_jsonl(jsonl_file, repo_root) - total_errors += errors - - print() - if total_errors == 0: - print(colored("✓ All validations passed", Colors.GREEN)) - return 0 - else: - print(colored(f"✗ Validation failed ({total_errors} errors)", Colors.RED)) - return 1 - - -def _validate_jsonl(jsonl_file: Path, repo_root: Path) -> int: - """Validate a single JSONL file.""" - file_name = jsonl_file.name - errors = 0 - - if not jsonl_file.is_file(): - print(f" {colored(f'{file_name}: not found (skipped)', Colors.YELLOW)}") - return 0 - - line_num = 0 - for line in jsonl_file.read_text(encoding="utf-8").splitlines(): - line_num += 1 - if not line.strip(): - continue - - try: - data = json.loads(line) - except json.JSONDecodeError: - print(f" {colored(f'{file_name}:{line_num}: Invalid JSON', Colors.RED)}") - errors += 1 - continue - - file_path = data.get("file") - entry_type = data.get("type", "file") - - if not file_path: - print(f" {colored(f'{file_name}:{line_num}: Missing file field', Colors.RED)}") - errors += 1 - continue - - full_path = repo_root / file_path - if entry_type == "directory": - if not full_path.is_dir(): - print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}") - errors += 1 - else: - if not full_path.is_file(): - print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}") - errors += 1 - - if errors == 0: - print(f" {colored(f'{file_name}: ✓ ({line_num} entries)', Colors.GREEN)}") - else: - print(f" {colored(f'{file_name}: ✗ ({errors} errors)', Colors.RED)}") - - return errors - - -# ============================================================================= -# Command: list-context -# ============================================================================= - -def cmd_list_context(args: argparse.Namespace) -> int: - """List JSONL context entries.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - - if not target_dir.is_dir(): - print(colored("Error: task directory required", Colors.RED)) - return 1 - - print(colored("=== Context Files ===", Colors.BLUE)) - print() - - for jsonl_name in ["implement.jsonl", "check.jsonl", "debug.jsonl"]: - jsonl_file = target_dir / jsonl_name - if not jsonl_file.is_file(): - continue - - print(colored(f"[{jsonl_name}]", Colors.CYAN)) - - count = 0 - for line in jsonl_file.read_text(encoding="utf-8").splitlines(): - if not line.strip(): - continue - - try: - data = json.loads(line) - except json.JSONDecodeError: - continue - - count += 1 - file_path = data.get("file", "?") - entry_type = data.get("type", "file") - reason = data.get("reason", "-") - - if entry_type == "directory": - print(f" {colored(f'{count}.', Colors.GREEN)} [DIR] {file_path}") - else: - print(f" {colored(f'{count}.', Colors.GREEN)} {file_path}") - print(f" {colored('→', Colors.YELLOW)} {reason}") - - print() - - return 0 # ============================================================================= @@ -642,7 +68,7 @@ def cmd_list_context(args: argparse.Namespace) -> int: # ============================================================================= def cmd_start(args: argparse.Namespace) -> int: - """Set current task.""" + """Set active task.""" repo_root = get_repo_root() task_input = args.dir @@ -651,7 +77,7 @@ def cmd_start(args: argparse.Namespace) -> int: return 1 # Resolve task directory (supports task name, relative path, or absolute path) - full_path = _resolve_task_dir(task_input, repo_root) + full_path = resolve_task_dir(task_input, repo_root) if not full_path.is_dir(): print(colored(f"Error: Task not found: {task_input}", Colors.RED)) @@ -660,17 +86,35 @@ def cmd_start(args: argparse.Namespace) -> int: # Convert to relative path for storage try: - task_dir = str(full_path.relative_to(repo_root)) + task_dir = full_path.relative_to(repo_root).as_posix() except ValueError: task_dir = str(full_path) - if set_current_task(task_dir, repo_root): + if not resolve_context_key(): + print(colored("Error: Cannot set active task without a session identity.", Colors.RED)) + print( + "Hint: run inside an AI IDE/session that exposes session identity, " + "or set TRELLIS_CONTEXT_ID before running task.py start." + ) + return 1 + + active = set_active_task(task_dir, repo_root) + if active: print(colored(f"✓ Current task set to: {task_dir}", Colors.GREEN)) + print(f"Source: {active.source}") + + task_json_path = full_path / FILE_TASK_JSON + if task_json_path.is_file(): + data = read_json(task_json_path) + if data and data.get("status") == "planning": + data["status"] = "in_progress" + if write_json(task_json_path, data): + print(colored("✓ Status: planning → in_progress", Colors.GREEN)) + print() print(colored("The hook will now inject context from this task's jsonl files.", Colors.BLUE)) - task_json_path = full_path / FILE_TASK_JSON - _run_hooks("after_start", task_json_path, repo_root) + run_task_hooks("after_start", task_json_path, repo_root) return 0 else: print(colored("Error: Failed to set current task", Colors.RED)) @@ -678,9 +122,10 @@ def cmd_start(args: argparse.Namespace) -> int: def cmd_finish(args: argparse.Namespace) -> int: - """Clear current task.""" + """Clear active task.""" repo_root = get_repo_root() - current = get_current_task(repo_root) + active = clear_active_task(repo_root) + current = active.task_path if not current: print(colored("No current task set", Colors.YELLOW)) @@ -689,250 +134,37 @@ def cmd_finish(args: argparse.Namespace) -> int: # Resolve task.json path before clearing task_json_path = repo_root / current / FILE_TASK_JSON - clear_current_task(repo_root) print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN)) + print(f"Source: {active.source}") if task_json_path.is_file(): - _run_hooks("after_finish", task_json_path, repo_root) + run_task_hooks("after_finish", task_json_path, repo_root) return 0 -# ============================================================================= -# Command: archive -# ============================================================================= - -def cmd_archive(args: argparse.Namespace) -> int: - """Archive completed task.""" +def cmd_current(args: argparse.Namespace) -> int: + """Show active task.""" repo_root = get_repo_root() - task_name = args.name + active = resolve_active_task(repo_root) - if not task_name: - print(colored("Error: Task name is required", Colors.RED), file=sys.stderr) - return 1 + if args.source: + print(f"Current task: {active.task_path or '(none)'}") + print(f"Source: {active.source}") + if active.stale: + print("State: stale") + return 0 if active.task_path else 1 - tasks_dir = get_tasks_dir(repo_root) - - # Find task directory - task_dir = find_task_by_name(task_name, tasks_dir) - - if not task_dir or not task_dir.is_dir(): - print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr) - print("Active tasks:", file=sys.stderr) - cmd_list(argparse.Namespace(mine=False, status=None)) - return 1 - - dir_name = task_dir.name - task_json_path = task_dir / FILE_TASK_JSON - - # Update status before archiving - today = datetime.now().strftime("%Y-%m-%d") - if task_json_path.is_file(): - data = _read_json_file(task_json_path) - if data: - data["status"] = "completed" - data["completedAt"] = today - _write_json_file(task_json_path, data) - - # Handle subtask relationships on archive - task_parent = data.get("parent") - task_children = data.get("children", []) - - # If this is a child, remove from parent's children list - if task_parent: - parent_dir = find_task_by_name(task_parent, tasks_dir) - if parent_dir: - parent_json = parent_dir / FILE_TASK_JSON - if parent_json.is_file(): - parent_data = _read_json_file(parent_json) - if parent_data: - parent_children = parent_data.get("children", []) - if dir_name in parent_children: - parent_children.remove(dir_name) - parent_data["children"] = parent_children - _write_json_file(parent_json, parent_data) - - # If this is a parent, clear parent field in all children - if task_children: - for child_name in task_children: - child_dir_path = find_task_by_name(child_name, tasks_dir) - if child_dir_path: - child_json = child_dir_path / FILE_TASK_JSON - if child_json.is_file(): - child_data = _read_json_file(child_json) - if child_data: - child_data["parent"] = None - _write_json_file(child_json, child_data) - - # Clear if current task - current = get_current_task(repo_root) - if current and dir_name in current: - clear_current_task(repo_root) - - # Archive - result = archive_task_complete(task_dir, repo_root) - if "archived_to" in result: - archive_dest = Path(result["archived_to"]) - year_month = archive_dest.parent.name - print(colored(f"Archived: {dir_name} -> archive/{year_month}/", Colors.GREEN), file=sys.stderr) - - # Auto-commit unless --no-commit - if not getattr(args, "no_commit", False): - _auto_commit_archive(dir_name, repo_root) - - # Return the archive path - print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}") - - # Run hooks with the archived path - archived_json = archive_dest / FILE_TASK_JSON - _run_hooks("after_archive", archived_json, repo_root) + if active.task_path: + print(active.task_path) return 0 return 1 -def _auto_commit_archive(task_name: str, repo_root: Path) -> None: - """Stage .trellis/tasks/ changes and commit after archive.""" - tasks_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}" - _run_git_command(["add", "-A", tasks_rel], cwd=repo_root) - - # Check if there are staged changes - rc, _, _ = _run_git_command( - ["diff", "--cached", "--quiet", "--", tasks_rel], cwd=repo_root - ) - if rc == 0: - print("[OK] No task changes to commit.", file=sys.stderr) - return - - commit_msg = f"chore(task): archive {task_name}" - rc, _, err = _run_git_command(["commit", "-m", commit_msg], cwd=repo_root) - if rc == 0: - print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr) - else: - print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr) - - -# ============================================================================= -# Command: add-subtask -# ============================================================================= - -def cmd_add_subtask(args: argparse.Namespace) -> int: - """Link a child task to a parent task.""" - repo_root = get_repo_root() - - parent_dir = _resolve_task_dir(args.parent_dir, repo_root) - child_dir = _resolve_task_dir(args.child_dir, repo_root) - - parent_json_path = parent_dir / FILE_TASK_JSON - child_json_path = child_dir / FILE_TASK_JSON - - if not parent_json_path.is_file(): - print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr) - return 1 - - if not child_json_path.is_file(): - print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) - return 1 - - parent_data = _read_json_file(parent_json_path) - child_data = _read_json_file(child_json_path) - - if not parent_data or not child_data: - print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr) - return 1 - - # Check if child already has a parent - existing_parent = child_data.get("parent") - if existing_parent: - print(colored(f"Error: Child task already has a parent: {existing_parent}", Colors.RED), file=sys.stderr) - return 1 - - # Add child to parent's children list - parent_children = parent_data.get("children", []) - child_dir_name = child_dir.name - if child_dir_name not in parent_children: - parent_children.append(child_dir_name) - parent_data["children"] = parent_children - - # Set parent in child's task.json - child_data["parent"] = parent_dir.name - - # Write both - _write_json_file(parent_json_path, parent_data) - _write_json_file(child_json_path, child_data) - - print(colored(f"Linked: {child_dir.name} -> {parent_dir.name}", Colors.GREEN), file=sys.stderr) - return 0 - - -# ============================================================================= -# Command: remove-subtask -# ============================================================================= - -def cmd_remove_subtask(args: argparse.Namespace) -> int: - """Unlink a child task from a parent task.""" - repo_root = get_repo_root() - - parent_dir = _resolve_task_dir(args.parent_dir, repo_root) - child_dir = _resolve_task_dir(args.child_dir, repo_root) - - parent_json_path = parent_dir / FILE_TASK_JSON - child_json_path = child_dir / FILE_TASK_JSON - - if not parent_json_path.is_file(): - print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr) - return 1 - - if not child_json_path.is_file(): - print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) - return 1 - - parent_data = _read_json_file(parent_json_path) - child_data = _read_json_file(child_json_path) - - if not parent_data or not child_data: - print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr) - return 1 - - # Remove child from parent's children list - parent_children = parent_data.get("children", []) - child_dir_name = child_dir.name - if child_dir_name in parent_children: - parent_children.remove(child_dir_name) - parent_data["children"] = parent_children - - # Clear parent in child's task.json - child_data["parent"] = None - - # Write both - _write_json_file(parent_json_path, parent_data) - _write_json_file(child_json_path, child_data) - - print(colored(f"Unlinked: {child_dir.name} from {parent_dir.name}", Colors.GREEN), file=sys.stderr) - return 0 - - # ============================================================================= # Command: list # ============================================================================= -def _get_children_progress(children: list[str], tasks_dir: Path) -> str: - """Get children progress summary like '[2/3 done]'.""" - if not children: - return "" - done_count = 0 - total = len(children) - for child_name in children: - child_dir = tasks_dir / child_name - child_json = child_dir / FILE_TASK_JSON - if child_json.is_file(): - data = _read_json_file(child_json) - if data: - status = data.get("status", "") - if status in ("completed", "done"): - done_count += 1 - return f" [{done_count}/{total} done]" - - def cmd_list(args: argparse.Namespace) -> int: """List active tasks.""" repo_root = get_repo_root() @@ -951,51 +183,23 @@ def cmd_list(args: argparse.Namespace) -> int: print(colored("All active tasks:", Colors.BLUE)) print() - # First pass: collect all task data and identify parent/child relationships - all_tasks: dict[str, dict] = {} - if tasks_dir.is_dir(): - for d in sorted(tasks_dir.iterdir()): - if not d.is_dir() or d.name == "archive": - continue + # Single pass: collect all tasks via shared iterator + all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)} + all_statuses = {name: t.status for name, t in all_tasks.items()} - dir_name = d.name - task_json = d / FILE_TASK_JSON - status = "unknown" - assignee = "-" - children: list[str] = [] - parent: str | None = None - - if task_json.is_file(): - data = _read_json_file(task_json) - if data: - status = data.get("status", "unknown") - assignee = data.get("assignee", "-") - children = data.get("children", []) - parent = data.get("parent") - - all_tasks[dir_name] = { - "status": status, - "assignee": assignee, - "children": children, - "parent": parent, - } - - # Second pass: display tasks hierarchically + # Display tasks hierarchically count = 0 def _print_task(dir_name: str, indent: int = 0) -> None: nonlocal count - info = all_tasks[dir_name] - status = info["status"] - assignee = info["assignee"] - children = info["children"] + t = all_tasks[dir_name] # Apply --mine filter - if filter_mine and assignee != developer: + if filter_mine and (t.assignee or "-") != developer: return # Apply --status filter - if filter_status and status != filter_status: + if filter_status and t.status != filter_status: return relative_path = f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}" @@ -1004,25 +208,27 @@ def cmd_list(args: argparse.Namespace) -> int: marker = f" {colored('<- current', Colors.GREEN)}" # Children progress - progress = _get_children_progress(children, tasks_dir) if children else "" + progress = children_progress(t.children, all_statuses) + + # Package tag + pkg_tag = f" @{t.package}" if t.package else "" prefix = " " * indent + " - " if filter_mine: - print(f"{prefix}{dir_name}/ ({status}){progress}{marker}") + print(f"{prefix}{dir_name}/ ({t.status}){pkg_tag}{progress}{marker}") else: - print(f"{prefix}{dir_name}/ ({status}){progress} [{colored(assignee, Colors.CYAN)}]{marker}") + print(f"{prefix}{dir_name}/ ({t.status}){pkg_tag}{progress} [{colored(t.assignee or '-', Colors.CYAN)}]{marker}") count += 1 # Print children indented - for child_name in children: + for child_name in t.children: if child_name in all_tasks: _print_task(child_name, indent + 1) # Display only top-level tasks (those without a parent) for dir_name in sorted(all_tasks.keys()): - info = all_tasks[dir_name] - if not info["parent"]: + if not all_tasks[dir_name].parent: _print_task(dir_name) if count == 0: @@ -1070,154 +276,35 @@ def cmd_list_archive(args: argparse.Namespace) -> int: return 0 -# ============================================================================= -# Command: set-branch -# ============================================================================= - -def cmd_set_branch(args: argparse.Namespace) -> int: - """Set git branch for task.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - branch = args.branch - - if not branch: - print(colored("Error: Missing arguments", Colors.RED)) - print("Usage: python3 task.py set-branch <task-dir> <branch-name>") - return 1 - - task_json = target_dir / FILE_TASK_JSON - if not task_json.is_file(): - print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) - return 1 - - data = _read_json_file(task_json) - if not data: - return 1 - - data["branch"] = branch - _write_json_file(task_json, data) - - print(colored(f"✓ Branch set to: {branch}", Colors.GREEN)) - print() - print(colored("Now you can start the multi-agent pipeline:", Colors.BLUE)) - print(f" python3 ./.trellis/scripts/multi_agent/start.py {args.dir}") - return 0 - - -# ============================================================================= -# Command: set-base-branch -# ============================================================================= - -def cmd_set_base_branch(args: argparse.Namespace) -> int: - """Set the base branch (PR target) for task.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - base_branch = args.base_branch - - if not base_branch: - print(colored("Error: Missing arguments", Colors.RED)) - print("Usage: python3 task.py set-base-branch <task-dir> <base-branch>") - print("Example: python3 task.py set-base-branch <dir> develop") - print() - print("This sets the target branch for PR (the branch your feature will merge into).") - return 1 - - task_json = target_dir / FILE_TASK_JSON - if not task_json.is_file(): - print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) - return 1 - - data = _read_json_file(task_json) - if not data: - return 1 - - data["base_branch"] = base_branch - _write_json_file(task_json, data) - - print(colored(f"✓ Base branch set to: {base_branch}", Colors.GREEN)) - print(f" PR will target: {base_branch}") - return 0 - - -# ============================================================================= -# Command: set-scope -# ============================================================================= - -def cmd_set_scope(args: argparse.Namespace) -> int: - """Set scope for PR title.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - scope = args.scope - - if not scope: - print(colored("Error: Missing arguments", Colors.RED)) - print("Usage: python3 task.py set-scope <task-dir> <scope>") - return 1 - - task_json = target_dir / FILE_TASK_JSON - if not task_json.is_file(): - print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) - return 1 - - data = _read_json_file(task_json) - if not data: - return 1 - - data["scope"] = scope - _write_json_file(task_json, data) - - print(colored(f"✓ Scope set to: {scope}", Colors.GREEN)) - return 0 - - -# ============================================================================= -# Command: create-pr (delegates to multi-agent script) -# ============================================================================= - -def cmd_create_pr(args: argparse.Namespace) -> int: - """Create PR from task - delegates to multi_agent/create_pr.py.""" - import subprocess - script_dir = Path(__file__).parent - create_pr_script = script_dir / "multi_agent" / "create_pr.py" - - cmd = [sys.executable, str(create_pr_script)] - if args.dir: - cmd.append(args.dir) - if args.dry_run: - cmd.append("--dry-run") - - result = subprocess.run(cmd) - return result.returncode - - # ============================================================================= # Help # ============================================================================= def show_usage() -> None: """Show usage help.""" - print("""Task Management Script for Multi-Agent Pipeline + print("""Task Management Script Usage: python3 task.py create <title> Create new task directory + python3 task.py create <title> --package <pkg> Create task for a specific package python3 task.py create <title> --parent <dir> Create task as child of parent - python3 task.py init-context <dir> <dev_type> Initialize jsonl files python3 task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl python3 task.py validate <dir> Validate jsonl files python3 task.py list-context <dir> List jsonl entries - python3 task.py start <dir> Set as current task - python3 task.py finish Clear current task - python3 task.py set-branch <dir> <branch> Set git branch for multi-agent + python3 task.py start <dir> Set active task + python3 task.py current [--source] Show active task + python3 task.py finish Clear active task + python3 task.py set-branch <dir> <branch> Set git branch + python3 task.py set-base-branch <dir> <branch> Set PR target branch python3 task.py set-scope <dir> <scope> Set scope for PR title - python3 task.py create-pr [dir] [--dry-run] Create PR from task - python3 task.py archive <task-name> Archive completed task + python3 task.py archive <task-dir> Archive completed task python3 task.py add-subtask <parent> <child> Link child task to parent python3 task.py remove-subtask <parent> <child> Unlink child from parent python3 task.py list [--mine] [--status <status>] List tasks python3 task.py list-archive [YYYY-MM] List archived tasks -Arguments: - dev_type: backend | frontend | fullstack | test | docs +Monorepo options: + --package <pkg> Package name (validated against config.yaml packages) List options: --mine, -m Show only tasks assigned to current developer @@ -1225,13 +312,12 @@ List options: Examples: python3 task.py create "Add login feature" --slug add-login + python3 task.py create "Add login feature" --slug add-login --package cli python3 task.py create "Child task" --slug child --parent .trellis/tasks/01-21-parent - python3 task.py init-context .trellis/tasks/01-21-add-login backend - python3 task.py add-context <dir> implement .trellis/spec/backend/auth.md "Auth guidelines" + python3 task.py add-context <dir> implement .trellis/spec/cli/backend/auth.md "Auth guidelines" python3 task.py set-branch <dir> task/add-login python3 task.py start .trellis/tasks/01-21-add-login - python3 task.py create-pr # Uses current task - python3 task.py create-pr <dir> --dry-run # Preview without changes + python3 task.py current --source python3 task.py finish python3 task.py archive add-login python3 task.py add-subtask parent-task child-task # Link existing tasks @@ -1248,8 +334,38 @@ Examples: def main() -> int: """CLI entry point.""" + # Deprecation guard: `init-context` was removed in v0.5.0-beta.12. + # Detect early so argparse doesn't mask the real reason with a generic + # "invalid choice" error. + if len(sys.argv) >= 2 and sys.argv[1] == "init-context": + print( + colored( + "Error: `task.py init-context` was removed in v0.5.0-beta.12.", + Colors.RED, + ), + file=sys.stderr, + ) + print( + "implement.jsonl / check.jsonl are now seeded on `task.py create` for", + file=sys.stderr, + ) + print( + "sub-agent-capable platforms and curated by the AI during Phase 1.3.", + file=sys.stderr, + ) + print("See .trellis/workflow.md Phase 1.3 or run:", file=sys.stderr) + print( + " python3 ./.trellis/scripts/get_context.py --mode phase --step 1.3", + file=sys.stderr, + ) + print( + "Use `task.py add-context <dir> implement|check <path> <reason>` to append entries.", + file=sys.stderr, + ) + return 2 + parser = argparse.ArgumentParser( - description="Task Management Script for Multi-Agent Pipeline", + description="Task Management Script", formatter_class=argparse.RawDescriptionHelpFormatter, ) subparsers = parser.add_subparsers(dest="command", help="Commands") @@ -1262,16 +378,12 @@ def main() -> int: p_create.add_argument("--priority", "-p", default="P2", help="Priority (P0-P3)") p_create.add_argument("--description", "-d", help="Task description") p_create.add_argument("--parent", help="Parent task directory (establishes subtask link)") - - # init-context - p_init = subparsers.add_parser("init-context", help="Initialize context files") - p_init.add_argument("dir", help="Task directory") - p_init.add_argument("type", help="Dev type: backend|frontend|fullstack|test|docs") + p_create.add_argument("--package", help="Package name for monorepo projects") # add-context p_add = subparsers.add_parser("add-context", help="Add context entry") p_add.add_argument("dir", help="Task directory") - p_add.add_argument("file", help="JSONL file (implement|check|debug)") + p_add.add_argument("file", help="JSONL file (implement|check)") p_add.add_argument("path", help="File path to add") p_add.add_argument("reason", nargs="?", help="Reason for adding") @@ -1284,11 +396,16 @@ def main() -> int: p_listctx.add_argument("dir", help="Task directory") # start - p_start = subparsers.add_parser("start", help="Set current task") + p_start = subparsers.add_parser("start", help="Set active task") p_start.add_argument("dir", help="Task directory") + # current + p_current = subparsers.add_parser("current", help="Show active task") + p_current.add_argument("--source", action="store_true", + help="Show active task source") + # finish - subparsers.add_parser("finish", help="Clear current task") + subparsers.add_parser("finish", help="Clear active task") # set-branch p_branch = subparsers.add_parser("set-branch", help="Set git branch") @@ -1305,14 +422,9 @@ def main() -> int: p_scope.add_argument("dir", help="Task directory") p_scope.add_argument("scope", help="Scope name") - # create-pr - p_pr = subparsers.add_parser("create-pr", help="Create PR") - p_pr.add_argument("dir", nargs="?", help="Task directory") - p_pr.add_argument("--dry-run", action="store_true", help="Dry run mode") - # archive p_archive = subparsers.add_parser("archive", help="Archive task") - p_archive.add_argument("name", help="Task name") + p_archive.add_argument("name", help="Task directory or name") p_archive.add_argument("--no-commit", action="store_true", help="Skip auto git commit after archive") # list @@ -1342,16 +454,15 @@ def main() -> int: commands = { "create": cmd_create, - "init-context": cmd_init_context, "add-context": cmd_add_context, "validate": cmd_validate, "list-context": cmd_list_context, "start": cmd_start, + "current": cmd_current, "finish": cmd_finish, "set-branch": cmd_set_branch, "set-base-branch": cmd_set_base_branch, "set-scope": cmd_set_scope, - "create-pr": cmd_create_pr, "archive": cmd_archive, "add-subtask": cmd_add_subtask, "remove-subtask": cmd_remove_subtask, diff --git a/.trellis/tasks/archive/2026-05/00-join-zl-q/prd.md b/.trellis/tasks/archive/2026-05/00-join-zl-q/prd.md new file mode 100644 index 0000000..5997523 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/00-join-zl-q/prd.md @@ -0,0 +1,104 @@ +# Joiner Onboarding Task + +**You (the AI) are running this task. The developer does not read this file.** + +`zl-q` just ran `trellis init` on a fresh clone, saw "Developer +initialized", and will now start asking you questions in chat. This joiner task +exists under `.trellis/tasks/`; when they want to work on it, they should +start it from a session that provides Trellis session identity. + +Your job is to orient them to Trellis. Don't dump all of this at them — open +with a short greeting, ask where they want to start, and fill in the rest as +they engage. + +--- + +## Topics to cover (adapt order to their questions) + +### 1. What Trellis is + the workflow + +Trellis is a workflow layer over Claude Code / Cursor / etc. that keeps AI +agents consistent with project-specific conventions instead of writing generic +code every session. + +- **Three phases**: Plan (brainstorm → `prd.md`) → Execute (code + check) → + Finish (capture + wrap). Full reference: `.trellis/workflow.md`. +- **Task lifecycle**: planning → in_progress → done → archive, under + `.trellis/tasks/`. +- **Core slash commands**: + - `/trellis:continue` — resume the current session's active task + - `/trellis:finish-work` — wrap up a finished task + - `/trellis:start` — session boot from scratch (not needed here; the + SessionStart hook does its job automatically) + +### 2. Runtime mechanics (explain when they ask "how does it know what to do") + +- **SessionStart hook** runs `get_context.py` and injects identity, git + status, session active task, active tasks, and workflow phase into the AI + conversation at every session start. +- **`<workflow-state>` tag** is auto-injected with every user message, + carrying the current task + phase hint. +- **`/trellis:continue`** loads the Phase Index, reads `prd.md` + recent + activity, and routes to the right skill (`trellis-brainstorm` for planning, + `trellis-implement` for coding, `trellis-check` for verification). +- **`trellis-implement` sub-agent** is spawned when code needs to be written. + The platform hook reads `{TASK_DIR}/implement.jsonl` and auto-injects those + spec files + `prd.md` into the sub-agent's prompt so it codes per project + conventions. +- **`trellis-check` sub-agent** follows the same pattern with `check.jsonl` + — reviews changes against specs, auto-fixes issues, runs lint/typecheck. + +File layout (mention when they ask "where does what live"): +- `.trellis/.runtime/sessions/<session>.json` — session active-task state, gitignored +- `.trellis/tasks/<task>/{implement,check}.jsonl` — per-task context manifests +- `.trellis/spec/` — project-wide conventions (source of truth) +- `.trellis/workspace/zl-q/journal-*.md` — their session log, + rotated at ~2000 lines + +### 3. This project's actual conventions + +- Summarize `.trellis/spec/` for them — what coding conventions this + specific team enforces. +- Point at the last 5 entries in `.trellis/tasks/archive/` as a rhythm + example of how people actually work here. **If archive is empty** (the + project just started), skip this — don't invent examples. +- Not your job in this onboarding to teach them the business code itself — + the README and their teammates handle that. + +### 4. Their assigned work + +- Check if `.trellis/workspace/zl-q/` already exists — if yes, it's + their journal from another machine and worth mentioning. +- Run `python3 ./.trellis/scripts/task.py list --assignee zl-q` to + show tasks assigned to them. (Quote the name if it contains spaces.) +- Remind them that the "My Tasks" section appears in the SessionStart context + on every new session. + +--- + +## Optional: walk through a small task end-to-end + +If they want to practice before touching real work, offer to pick a tiny +P3 task or a typo fix and run the full cycle together: `/trellis:continue` +→ you implement via sub-agents → `/trellis:finish-work`. + +--- + +## Completion + +When they feel oriented (or after you've covered the four topics with +reasonable back-and-forth), guide them to run: + +```bash +python3 ./.trellis/scripts/task.py finish +python3 ./.trellis/scripts/task.py archive 00-join-zl-q +``` + +--- + +## Suggested opening line + +"Welcome! Your `trellis init` set me up to onboard you to this project. I +can walk you through the workflow, show you the runtime mechanics under the +hood, summarize the team's spec, or jump to what you're already curious about +— which would you prefer?" diff --git a/.trellis/tasks/archive/2026-05/00-join-zl-q/task.json b/.trellis/tasks/archive/2026-05/00-join-zl-q/task.json new file mode 100644 index 0000000..08ad552 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/00-join-zl-q/task.json @@ -0,0 +1,26 @@ +{ + "id": "00-join-zl-q", + "name": "00-join-zl-q", + "title": "Joining: Onboard to this Trellis project (zl-q)", + "description": "Onboard a new developer to an existing Trellis project: learn the workflow, conventions, and find assigned work", + "status": "completed", + "dev_type": "docs", + "scope": null, + "package": null, + "priority": "P1", + "creator": "zl-q", + "assignee": "zl-q", + "createdAt": "2026-05-06", + "completedAt": "2026-05-06", + "branch": null, + "base_branch": null, + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "Generated by trellis init for a new developer joining an existing Trellis project", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-05/05-06-migrate-to-0.5.0-rc.6/prd.md b/.trellis/tasks/archive/2026-05/05-06-migrate-to-0.5.0-rc.6/prd.md new file mode 100644 index 0000000..8857826 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-06-migrate-to-0.5.0-rc.6/prd.md @@ -0,0 +1,201 @@ +# Migration Task: Upgrade to v0.5.0-rc.6 + +**Created**: 2026-05-06 +**From Version**: 0.3.10 +**To Version**: 0.5.0-rc.6 +**Assignee**: zl-q + +## 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`). + +--- + +## v0.5.0-beta.0 Migration Guide + +## 0.4.x → 0.5.x: What This Release Actually Changes + +0.5.0-beta.0 is a **breaking** release. Pre-existing 0.4.x projects need `trellis update --migrate` to sync. The update runs **206 migration entries** (renames + hash-verified safe-file-deletes); the patch is non-destructive but large, so expect a handful of confirm prompts. + +### 1. Skills got renamed: `skills/<name>/` → `skills/trellis-<name>/` + +All Trellis skill directories gained a `trellis-` prefix. 60+ rename migrations cover every platform (`.claude/`, `.cursor/`, `.agents/`, `.kiro/`, `.qoder/`, etc.). + +- **Unmodified skills**: renamed silently. +- **Skills you customized**: confirm prompt. Pressing Enter (default = backup-rename) is always safe — your edits land at the new `trellis-<name>/` path intact. + +### 2. Six commands + three sub-agents retired + +| Old (removed) | Replacement | +|---|---| +| `/record-session` | merged into `/trellis:finish-work` Step 3 | +| `/check-cross-layer` | merged into `/trellis:check` | +| `/parallel` | use your CLI's native worktree/parallel support | +| `/onboard` | superseded by auto-generated onboarding tasks | +| `/create-command` | low-usage, unshipped | +| `/integrate-skill` | low-usage, unshipped | +| `dispatch` / `debug` / `plan` sub-agents | replaced by skill routing (`trellis-brainstorm`, `trellis-check`, `trellis-break-loop`) | + +If any of these you relied on: replace the invocation with the right column. `/record-session` → `/trellis:finish-work` is the most common fix. + +### 3. Multi-Agent Pipeline gone + +`.trellis/scripts/multi_agent/`, `worktree.yaml`, and the Ralph Loop hook have been removed (138-entry safe-file-delete). Native worktree support in Claude / Cursor / etc. covers this space now. If you built automation around these, you'll need to port it to the platform's native primitives. + +### 4. iFlow dropped + +The `.iflow/` platform adapter was unmaintained upstream and has been removed. Existing `.iflow/` installs keep working on 0.4.x; switch to a supported platform (`--claude` / `--cursor` / `--codex` / etc.) to stay on 0.5.x. + +### 5. task.py schema cleanup + +`task.py create` stops writing legacy `current_phase` / `next_action` fields. Existing task.json files keep working (Python readers treat missing fields as null). New tasks from 0.5.x produce the canonical 24-field shape. + +### 6. New runtime: per-turn workflow breadcrumb + sub-agent context injection + +No user action needed — these are internal flow improvements. But if you hand-edit `.claude/settings.json` / `.codex/hooks.json` / etc., `trellis update` will want to overwrite the hooks block; your custom hooks land on the `Modified by you` prompt so you can review the merge. + +## How to upgrade + +```bash +trellis update --migrate +``` + +- `--migrate` is REQUIRED for breaking releases — without it, `trellis update` exits 1 telling you to add it. +- Hash-verified migrations mean your local customizations stay; unmodified Trellis templates get updated silently. +- The confirm prompts inline a `reason` explaining each prompt; Enter (default) is safe. +- Codex users: enable `features.codex_hooks = true` in `~/.codex/config.toml` to activate SessionStart + UserPromptSubmit hook injection (feature-flagged by Codex itself). + +### AI Assistant Instructions + +When helping with this migration: + +When helping a user migrate from 0.4.x to 0.5.x: + +1. **Check for retired commands first**. Grep the user's codebase / shell history / aliases for `/record-session`, `/check-cross-layer`, `/parallel`, `/onboard`, `/create-command`, `/integrate-skill`. For each match, help them replace per the table in the guide — most common case is `/record-session` → `/trellis:finish-work`. + +2. **Run `trellis update --migrate`**. Walk them through the prompts if any fire — 0.4 didn't hash-track certain SKILL.md files, so pristine copies may show as modified. The per-prompt `reason` explains this; pressing Enter (backup-rename) is always safe. + +3. **Don't try to preserve Multi-Agent Pipeline state**. If the user has `.trellis/scripts/multi_agent/`, `worktree.yaml`, or Ralph Loop hooks in local edits, explicitly tell them the feature has no replacement in 0.5 beyond the platform's native worktree support. Help them identify which of their automation still needs porting. + +4. **If they're on `.iflow/`**: the platform is gone. Help them pick a supported platform (`--claude` / `--cursor` / `--codex` / etc.) and run `trellis init --<platform>` on the project to add it. `.iflow/` can be manually deleted after. + +5. **After migrate**, run `trellis update` a second time and confirm it says "Already up to date!" — any remaining diff indicates an incomplete migration (usually a skill that the user customized and that needs a manual decision). + +6. **Skill paths in existing task jsonl files**: if the user has active tasks with `implement.jsonl` / `check.jsonl` referencing old paths (e.g. `.kiro/skills/check/SKILL.md` without `trellis-`), those paths now 404. Re-run `task.py init-context <task-dir> <type> --platform <platform>` to regenerate with correct paths. + +7. **Codex feature flag**: remind them to set `features.codex_hooks = true` in `~/.codex/config.toml`, otherwise SessionStart injection is silently skipped and they'll wonder why workflow breadcrumbs don't show up. + +--- + +## v0.5.0-beta.5 Migration Guide + +## Sub-Agent Rename: `implement` / `check` / `research` → `trellis-*` + +beta.5 renames the three core sub-agents to add a `trellis-` prefix across all 10 agent-capable platforms (Claude, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid). The generic names (`implement`, `check`, `research`) were colliding with user-defined agents in some setups. + +### What changes + +| Old name | New name | +|---|---| +| `implement` | `trellis-implement` | +| `check` | `trellis-check` | +| `research` | `trellis-research` | + +Paths per platform: + +- **Claude / OpenCode / Cursor / etc.**: `.<platform>/agents/implement.md` → `.<platform>/agents/trellis-implement.md` +- **Codex**: `.codex/agents/implement.toml` → `.codex/agents/trellis-implement.toml` +- **Kiro**: `.kiro/agents/implement.json` → `.kiro/agents/trellis-implement.json` + +### Side-effect: removed `model: opus` from agent templates + +Agent YAML frontmatter no longer hardcodes a model. Falls back to whatever the platform default is. If you pinned opus explicitly for a reason (budget, performance), re-add `model: opus` to your local agent files after migrate. + +### How to upgrade + +```bash +trellis update --migrate +``` + +30 rename entries run. Unmodified agent files are renamed silently; customized ones (e.g. you tweaked the agent prompt) land on the standard `Modified by you` confirm prompt — pressing Enter (backup-rename) is safe. + +### What to check after migrate + +1. **Any code that spawns agents by name** — shell scripts, CI jobs, or other automation calling `Task(subagent_type="implement")` must update to `Task(subagent_type="trellis-implement")`. +2. **Aliases / hot keys** — if you had a keyboard shortcut or prompt alias that referenced `implement` / `check` / `research`, update it. +3. **Model pin** — if you customized `model: opus` in agent frontmatter, re-add it. Trellis no longer pins a model by default. + +### AI Assistant Instructions + +When helping with this migration: + +When helping a user migrate to 0.5.0-beta.5: + +1. **Grep for bare agent names in user code**: look for `Task(subagent_type="implement"|"check"|"research")` in any .md / .ts / .py / shell files. Rename each to the `trellis-` prefixed version. + +2. **Check agent customizations**: diff the user's `.<platform>/agents/{implement,check,research}.*` against the new `trellis-*` templates. If they had custom content, help merge it into the new file. + +3. **Model pinning**: if their agent frontmatter had `model: opus` and they still want it pinned, re-add it after migrate. beta.5 removes the default pin in favor of platform-default. + +4. **Run migrate**: `trellis update --migrate`. Hash-verified renames — pristine files renamed silently, customized files land on the confirm prompt (Enter = backup-rename is safe). + +5. **Verify clean second run**: after migrate, running `trellis update` again should report "Already up to date!". Any diff indicates a rename that didn't complete (user chose skip on a modified file). + diff --git a/.trellis/tasks/archive/2026-05/05-06-migrate-to-0.5.0-rc.6/task.json b/.trellis/tasks/archive/2026-05/05-06-migrate-to-0.5.0-rc.6/task.json new file mode 100644 index 0000000..52eee3e --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-06-migrate-to-0.5.0-rc.6/task.json @@ -0,0 +1,26 @@ +{ + "id": "migrate-to-0.5.0-rc.6", + "name": "migrate-to-0.5.0-rc.6", + "title": "Migrate to v0.5.0-rc.6", + "description": "Breaking change migration from v0.3.10 to v0.5.0-rc.6", + "status": "completed", + "dev_type": null, + "scope": "migration", + "package": null, + "priority": "P1", + "creator": "trellis-update", + "assignee": "zl-q", + "createdAt": "2026-05-06", + "completedAt": "2026-05-06", + "branch": null, + "base_branch": null, + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/workflow.md b/.trellis/workflow.md index fd01e35..f45c6b1 100644 --- a/.trellis/workflow.md +++ b/.trellis/workflow.md @@ -442,3 +442,33 @@ Following this workflow ensures: - [OK] Transparent team collaboration **Core Philosophy**: Read before write, follow standards, record promptly, capture learnings + +[workflow-state:no_task] +No active task. **A Direct answer** — pure Q&A / explanation / lookup / chat; no file writes + one-line answer + repo reads ≤ 2 files → AI judges, no override needed. +**B Create a task** — any implementation / code change / build / refactor work. Entry sequence: (1) `python3 ./.trellis/scripts/task.py create "<title>"` to create the task (status=planning, breadcrumb switches to [workflow-state:planning] for brainstorm + jsonl phase guidance) → (2) load `trellis-brainstorm` skill to discuss requirements with the user and iterate on prd.md → (3) once prd is done and jsonl is curated, run `task.py start <task-dir>` to enter [workflow-state:in_progress] for the implementation skeleton. For research-heavy work, dispatch `trellis-research` sub-agents — main agent must NOT do 3+ inline WebFetch / WebSearch / `gh api` calls. **"It looks small" is NOT grounds for downgrading B to A or C**. +**C Inline change** (per-turn only, escape hatch for B) — the user's CURRENT message MUST contain one of: "skip trellis" / "no task" / "just do it" / "don't create a task" / "跳过 trellis" / "别走流程" / "小修一下" / "直接改" / "先别建任务" → briefly acknowledge ("ok, skipping trellis flow this turn"), then inline. **Without seeing one of these phrases you must NOT inline on your own**; do not invent an override the user never said. +[/workflow-state:no_task] + +[workflow-state:planning] +Load the `trellis-brainstorm` skill and iterate on prd.md with the user. +Phase 1.3 (required, once): before `task.py start`, you MUST curate `implement.jsonl` and `check.jsonl` — list the spec / research files sub-agents need so they get the right context injected. You may skip only if the jsonl already has agent-curated entries (the seed `_example` row alone doesn't count). +Then run `task.py start <task-dir>` to flip status to in_progress. +Research output **must** land in `{task_dir}/research/*.md`, written by `trellis-research` sub-agents. The main agent should not inline WebFetch / WebSearch — the PRD only links to research files. +[/workflow-state:planning] + +[workflow-state:in_progress] +**Flow**: trellis-implement → trellis-check → trellis-update-spec → commit (Phase 3.4) → `/trellis:finish-work`. +**Default (no override)**: dispatch the `trellis-implement` / `trellis-check` sub-agents — the main agent does NOT edit code by default. Phase 3.4 commit (required, once): after trellis-update-spec, or whenever implementation is verifiably complete, the main agent **drives the commit** — state the commit plan in user-facing text, then run `git commit` — BEFORE suggesting `/trellis:finish-work`. `/finish-work` refuses to run on a dirty working tree (paths outside `.trellis/workspace/` and `.trellis/tasks/`). +**Sub-agent dispatch protocol (class-2 platforms: codex / copilot / gemini / qoder)**: When you spawn `trellis-implement` / `trellis-check` / `trellis-research`, your dispatch prompt **MUST** start with one line: `Active task: <task path from \`task.py current\`>`. No exceptions. These platforms have no hook to inject task context into sub-agents, so the sub-agent depends on this line; without it the sub-agent cannot find the task and will block to ask the user. +**Inline override** (per-turn only, escape hatch for sub-agent dispatch): the user's CURRENT message MUST explicitly contain one of: "do it inline" / "no sub-agent" / "你直接改" / "别派 sub-agent" / "main session 写就行" / "不用 sub-agent". **Without seeing one of these phrases you must NOT inline on your own**; do not invent an override the user never said. +[/workflow-state:in_progress] + +[workflow-state:completed] +Code committed via Phase 3.4; run `/trellis:finish-work` to wrap up (archive the task + record session). +If you reach this state with uncommitted code, return to Phase 3.4 first — `/finish-work` refuses to run on a dirty working tree. +`task.py archive` deletes any runtime session files that still point at the archived task. +[/workflow-state:completed] + +[workflow-state:my-status] +your per-turn prompt text +[/workflow-state:my-status] diff --git a/.trellis/workspace/opencode/index.md b/.trellis/workspace/opencode/index.md deleted file mode 100644 index c4a1ce0..0000000 --- a/.trellis/workspace/opencode/index.md +++ /dev/null @@ -1,41 +0,0 @@ -# Workspace Index - opencode - -> Journal tracking for AI development sessions. - ---- - -## Current Status - -<!-- @@@auto:current-status --> -- **Active File**: `journal-1.md` -- **Total Sessions**: 1 -- **Last Active**: 2026-04-28 -<!-- @@@/auto:current-status --> - ---- - -## Active Documents - -<!-- @@@auto:active-documents --> -| File | Lines | Status | -|------|-------|--------| -| `journal-1.md` | ~61 | Active | -<!-- @@@/auto:active-documents --> - ---- - -## Session History - -<!-- @@@auto:session-history --> -| # | Date | Title | Commits | -|---|------|-------|---------| -| 1 | 2026-04-28 | 任务归档与代码模块化提交 | `dd48d1d`, `85023a6`, `295dbc0`, `a940f2e`, `b9617ae`, `940c67e`, `a83001d`, `14752cd`, `c7a75a6`, `8de0331`, `9bc24fa` | -<!-- @@@/auto:session-history --> - ---- - -## Notes - -- Sessions are appended to journal files -- New journal file created when current exceeds 2000 lines -- Use `add_session.py` to record sessions \ No newline at end of file diff --git a/.trellis/workspace/opencode/journal-1.md b/.trellis/workspace/opencode/journal-1.md deleted file mode 100644 index 49881b5..0000000 --- a/.trellis/workspace/opencode/journal-1.md +++ /dev/null @@ -1,61 +0,0 @@ -# Journal - opencode (Part 1) - -> AI development session journal -> Started: 2026-04-27 - ---- - - - -## Session 1: 任务归档与代码模块化提交 - -**Date**: 2026-04-28 -**Task**: 任务归档与代码模块化提交 - -### Summary - -(Add summary) - -### Main Changes - -| 任务 | 说明 | -|------|------| -| 归档 | 04-27-feat-ios-apple-pay, 04-28-feat-locale-timezone-bootstrap, 04-28-feat-points-ledger, 04-28-refactor-unify-language | -| feat(locale) | App 启动时语言和时区自动设置 | -| feat(points) | 积分流水列表功能 | -| refactor(settings) | 统一语言设置,合并 interface_language 和 ai_language | -| feat(notification) | 通知标题和正文支持多语言 | -| feat(payment) | 优化套餐配置和支付服务 | -| refactor(divination) | 优化占卜界面和组件 | -| chore | 更新主题、反馈页面和测试用例 | - -**提交策略**: 按功能模块分类提交,每个任务/功能独立 commit - - -### Git Commits - -| Hash | Message | -|------|---------| -| `dd48d1d` | (see git log) | -| `85023a6` | (see git log) | -| `295dbc0` | (see git log) | -| `a940f2e` | (see git log) | -| `b9617ae` | (see git log) | -| `940c67e` | (see git log) | -| `a83001d` | (see git log) | -| `14752cd` | (see git log) | -| `c7a75a6` | (see git log) | -| `8de0331` | (see git log) | -| `9bc24fa` | (see git log) | - -### Testing - -- [OK] (Add test results) - -### Status - -[OK] **Completed** - -### Next Steps - -- None - task complete diff --git a/.trellis/worktree.yaml b/.trellis/worktree.yaml deleted file mode 100644 index 2648560..0000000 --- a/.trellis/worktree.yaml +++ /dev/null @@ -1,47 +0,0 @@ -# Worktree Configuration for Multi-Agent Pipeline -# Used for worktree initialization in multi-agent workflows -# -# All paths are relative to project root - -#------------------------------------------------------------------------------- -# Paths -#------------------------------------------------------------------------------- - -# Worktree storage directory (relative to project root) -worktree_dir: ../trellis-worktrees - -#------------------------------------------------------------------------------- -# Files to Copy -#------------------------------------------------------------------------------- - -# Files to copy to each worktree (each worktree needs independent copy) -# These files contain sensitive info or need worktree-independent config -copy: - # Environment variables (uncomment and customize as needed) - # - .env - # - .env.local - # Workflow config - - .trellis/.developer - -#------------------------------------------------------------------------------- -# Post-Create Hooks -#------------------------------------------------------------------------------- - -# Commands to run after creating worktree -# Executed in worktree directory, in order, abort on failure -post_create: - # Install dependencies (uncomment based on your package manager) - # - npm install - # - pnpm install --frozen-lockfile - # - yarn install --frozen-lockfile - -#------------------------------------------------------------------------------- -# Check Agent Verification (Ralph Loop) -#------------------------------------------------------------------------------- - -# Commands to verify code quality before allowing check agent to finish -# If configured, Ralph Loop will run these commands - all must pass to allow completion -# If not configured or empty, trusts agent's completion markers -verify: - # - pnpm lint - # - pnpm typecheck diff --git a/AGENTS.md b/AGENTS.md index 6317047..a98f8ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,16 +59,30 @@ When reading images, check whether the model has native multimodal capability fi These instructions are for AI assistants working in this project. -Use the `/trellis:start` command when starting a new session to: -- Initialize your developer identity -- Understand current project context -- Read relevant guidelines +This project is managed by Trellis. The working knowledge you need lives under `.trellis/`: -Use `@/.trellis/` to learn: -- Development workflow (`workflow.md`) -- Project structure guidelines (`spec/`) -- Developer workspace (`workspace/`) +- `.trellis/workflow.md` — development phases, when to create tasks, skill routing +- `.trellis/spec/` — package- and layer-scoped coding guidelines (read before writing code in a given layer) +- `.trellis/workspace/` — per-developer journals and session traces +- `.trellis/tasks/` — active and archived tasks (PRDs, research, jsonl context) -Keep this managed block so 'trellis update' can refresh the instructions. +If a Trellis command is available on your platform (e.g. `/trellis:finish-work`, `/trellis:continue`), prefer it over manual steps. Not every platform exposes every command. + +If you're using Codex or another agent-capable tool, additional project-scoped helpers may live in: +- `.agents/skills/` — reusable Trellis skills +- `.codex/agents/` — optional custom subagents + +## Subagents + +- ALWAYS wait for every spawned subagent to reach a terminal status before yielding, acting on partial results, or spawning followups. + - On Codex, this means calling the `wait` tool with the subagent's thread id (requires `multi_agent_v2`). Do NOT infer completion from elapsed time. + - On Claude Code / OpenCode, this means awaiting the Task/agent tool result before continuing. +- NEVER cancel or re-spawn a subagent that hasn't finished. If a subagent appears stuck, raise the wait timeout (Codex default 30s, max 1h) before judging it broken. +- Spawn subagents automatically when: + - Parallelizable work (e.g., install + verify, npm test + typecheck, multiple tasks from plan) + - Long-running or blocking tasks where a worker can run independently + - Isolation for risky changes or checks + +Managed by Trellis. Edits outside this block are preserved; edits inside may be overwritten by a future `trellis update`. <!-- TRELLIS:END -->