diff --git a/.agents/skills/trellis-before-dev/SKILL.md b/.agents/skills/trellis-before-dev/SKILL.md new file mode 100644 index 0000000..5a4b852 --- /dev/null +++ b/.agents/skills/trellis-before-dev/SKILL.md @@ -0,0 +1,40 @@ +--- +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. **Read current task artifacts**: + - `prd.md` for requirements and acceptance criteria + - `design.md` if present for technical design + - `implement.md` if present for execution order and validation plan + +2. **Discover packages and their spec layers**: + ```bash + python3 ./.trellis/scripts/get_context.py --mode packages + ``` + +3. **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.) + - Any spec/research paths referenced by the task artifacts + +4. **Read the spec index** for each relevant module: + ```bash + cat .trellis/spec///index.md + ``` + Follow the **"Pre-Development Checklist"** section in the index. + +5. **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. + +6. **Always read shared guides**: + ```bash + cat .trellis/spec/guides/index.md + ``` + +7. Understand the coding standards and patterns you need to follow, then proceed with your development plan. + +This step is **mandatory** before writing any code. diff --git a/.agents/skills/trellis-brainstorm/SKILL.md b/.agents/skills/trellis-brainstorm/SKILL.md new file mode 100644 index 0000000..096eeda --- /dev/null +++ b/.agents/skills/trellis-brainstorm/SKILL.md @@ -0,0 +1,200 @@ +--- +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." +--- + +# Trellis Brainstorm + +## Non-Negotiable Planning Contract + +A request to build, implement, fix, refactor, or "go ahead" is not approval to leave planning. Task-creation consent is also not implementation approval. + +For every non-trivial task, the user must respond at least once after the initial request before implementation begins. If no clarification is needed, that response must approve the final planning summary described below. + +While any user-owned product, scope, UX, compatibility, risk, or acceptance decision remains unresolved, end the turn with exactly one highest-value question. Do not edit product code, dispatch implementation, or run `task.py start`. + +## Non-Negotiable Evidence Rule + +If a question can be answered by exploring the codebase, explore the codebase instead. + +This is mandatory. Before asking the user a question, first check whether the answer is already available in code, tests, configs, docs, existing specs, or task history. + +Do not ask the user to confirm facts that the repository can answer. Ask only for product intent, preference, scope, risk tolerance, acceptance behavior, or decisions that remain ambiguous after inspection. + +Repository evidence establishes current behavior and technical constraints. The user's intended behavior, feature scope boundaries, and UX preferences are never answerable by repository evidence alone, even when an existing pattern exists; existing patterns are options and recommendation evidence, not decisions. + +--- + +Use this skill during Phase 1 planning to turn the user's request into clear requirements and planning artifacts. + +## Preconditions + +Use this skill only after task-creation consent has been given and the user is ready to enter Trellis planning. + +If no task exists yet, create one: + +```bash +TASK_DIR=$(python3 ./.trellis/scripts/task.py create "" --slug ) +``` + +Use a concise title from the user's request. Use a slug without a date prefix. `task.py create` adds the `MM-DD-` directory prefix automatically. + +`task.py create` creates the default `prd.md`. Update that file with the current understanding before asking follow-up questions. + +## Planning Flow + +1. Capture the user's request and initial known facts in `prd.md`. +2. Inspect available evidence before asking questions: + - code, tests, fixtures, and configs + - README files, docs, existing specs, and domain notes + - related Trellis tasks, research files, and session history when present +3. Separate what you found into: + - confirmed facts + - product intent still needed from the user + - scope or risk decisions still needed from the user + - likely out-of-scope items +4. If a user-owned decision remains, ask the single highest-value question, include your recommendation and trade-off, then stop. Do not perform implementation work in the same turn. +5. After each user answer, update `prd.md`, recompute the decision inventory, and repeat from step 2. +6. When no user-owned decision remains, create or update `design.md` and `implement.md` for complex tasks. +7. Run the requirement convergence gate, then the PRD convergence pass. +8. Present the final planning summary and stop. Do not run `task.py start` or edit product code in the same turn. +9. Only a subsequent user message that explicitly approves the latest planning summary authorizes `task.py start` and implementation. If the artifacts change materially after approval, repeat the final review. + +Do not invent a project-specific product/spec hierarchy. If the repository already has product, domain, or spec docs, use them. If it does not, proceed with the evidence that exists. + +## Question Rules + +Ask only one question per message. + +Each question must include: + +- the decision needed +- why the answer matters +- your recommended answer +- the trade-off if the user chooses differently + +Do not ask process questions such as whether to search, inspect files, or continue brainstorming. Do the evidence work directly. Ask the user only when the remaining issue is a product decision, preference, scope boundary, or risk tolerance choice. + +Recommendations are not default selections. Never choose a recommended product decision on the user's behalf merely because the user asked for implementation. + +Do not manufacture clarification questions when the request and repository evidence already resolve every decision. In that case, proceed directly to the final planning summary, which still requires a subsequent explicit approval. + +The final review is a required phase-transition gate, not a prohibited process question. Task-creation consent, the initial implementation request, and approval given before the latest final summary do not satisfy this gate. + +## Thinking Framework: First Principles Analysis + +When requirements are vague, solutions feel over-engineered, or you're about to add complexity "because everyone does" — decompose to fundamental truths before reasoning upward. + +### Step 1: Restate the Problem + +Strip away implementation details to one sentence. + +> Bad: "We need to add Redis caching to the user profile endpoint" +> Good: "User profile data takes too long to load" + +### Step 2: List Fundamental Truths + +What is absolutely true (not opinion or convention)? + +| Category | Examples | +|----------|----------| +| **Physical constraints** | Network latency ≥ 0, disk I/O has limits | +| **Business rules** | "Users must see their own data" | +| **Technical invariants** | "Data must be consistent" | +| **User needs** | "The user wants X within Y seconds" | + +### Step 3: Challenge Assumptions + +For each component of the current plan: + +- **Fact or convention?** "We always use REST" — why? +- **What if we removed this?** If nothing breaks, it's unnecessary. +- **Solving the actual problem or a symptom?** Trace the causal chain. +- **Who benefits from this complexity?** If "nobody", simplify. + +### Step 4: Build Up from Truths + +1. Start with the minimum viable mechanism satisfying all truths +2. Add complexity only when a specific truth demands it +3. Each addition must answer: "Which truth requires this?" + +### Step 5: Validate + +- Does the solution solve the original problem? +- What assumptions need verification? +- What's the simplest experiment to test this? + +## Requirement Convergence Gate + +Before final review, verify all of the following: + +- the user outcome and product value are explicit +- in-scope and out-of-scope behavior are explicit +- acceptance criteria describe observable outcomes +- user-owned product, scope, UX, compatibility, and risk decisions are resolved +- blocking open questions are empty +- technical unknowns are researched or explicitly deferred without changing MVP behavior + +Lightweight tasks may omit `design.md` and `implement.md`; they may not skip evidence inspection, requirement convergence, final review, or fresh implementation approval. + +The final planning summary must show Goal, In Scope, Out of Scope, Acceptance Criteria, Key Decisions, relevant Risks or Deferred Items, and artifact status. + +## Artifact Rules + +`prd.md` records requirements and acceptance: + +- goal and user value +- confirmed facts +- requirements +- acceptance criteria +- out of scope +- open questions that still block planning + +`design.md` records technical design for complex tasks: + +- architecture and boundaries +- data flow and contracts +- compatibility and migration notes +- important trade-offs +- operational or rollback considerations + +`implement.md` records execution planning for complex tasks: + +- ordered implementation checklist +- validation commands +- risky files or rollback points +- follow-up checks before `task.py start` + +Lightweight tasks may have only `prd.md`. Complex tasks must have `prd.md`, `design.md`, and `implement.md` before `task.py start`. + +`implement.md` is not a replacement for `implement.jsonl`. On sub-agent-dispatch workflows, `implement.jsonl` and `check.jsonl` must each contain at least one real spec/research entry before `task.py start`; the seed `_example` row does not count. Inline workflows skip this JSONL gate because Phase 2 loads context through `trellis-before-dev`. + +## PRD Convergence Pass + +Before declaring planning ready or running `task.py start`, rewrite `prd.md` once against the final structure described in the artifact rules above. This is not optional cleanup; it is the final planning gate. + +The pass must be lossless: + +- Collapse repeated facts into one authoritative section. +- Fold temporary brainstorm sections such as `What I already know`, `Assumptions`, and resolved `Open Questions` into Goal, Background, Requirements, Technical Notes, or Acceptance Criteria. +- Remove resolved open questions instead of leaving empty or already-answered sections. +- Merge parallel bug and requirement lists when they describe the same work; keep each defect's severity, evidence, and file:line anchors on the owning requirement. +- Preserve every file:line anchor, decision, constraint, requirement ID, and acceptance-criteria mapping. +- Do not proceed to final review while any blocking open question remains. + +After the pass, read `prd.md` top to bottom and verify that no fact is repeated across sections unless the repetition adds new information. + +## Quality Bar + +Before declaring planning ready: + +- `prd.md` contains testable acceptance criteria. +- `prd.md` has passed the PRD convergence pass: no unresolved temporary brainstorm sections, no duplicate facts across sections, and no lost anchors, decisions, or acceptance mappings. +- Repository-answerable questions have already been answered through inspection. +- Blocking open questions are empty. +- Complex tasks have `design.md` and `implement.md`. +- Sub-agent-dispatch tasks have real curated entries in both `implement.jsonl` and `check.jsonl`; seed-only manifests are not ready. +- The latest final planning summary has been presented to the user. +- In a subsequent message, the user explicitly approved that summary for implementation. + +Do not start implementation merely because the user originally asked for implementation. diff --git a/.agents/skills/trellis-break-loop/SKILL.md b/.agents/skills/trellis-break-loop/SKILL.md new file mode 100644 index 0000000..1c8b397 --- /dev/null +++ b/.agents/skills/trellis-break-loop/SKILL.md @@ -0,0 +1,188 @@ +--- +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 for deep analysis to break the "fix bug -> forget -> repeat" cycle. + +--- + +## Analysis Framework + +Analyze the bug you just fixed from these 5 dimensions: + +### 1. Root Cause Category + +Which category does this bug belong to? + +| Category | Characteristics | Example | +|----------|-----------------|---------| +| **A. Missing Spec** | No documentation on how to do it | New feature without checklist | +| **B. Cross-Layer Contract** | Interface between layers unclear | API returns different format than expected | +| **C. Change Propagation Failure** | Changed one place, missed others | Changed function signature, missed call sites | +| **D. Test Coverage Gap** | Unit test passes, integration fails | Works alone, breaks when combined | +| **E. Implicit Assumption** | Code relies on undocumented assumption | Timestamp seconds vs milliseconds | + +### 2. Why Fixes Failed (if applicable) + +If you tried multiple fixes before succeeding, analyze each failure: + +- **Surface Fix**: Fixed symptom, not root cause +- **Incomplete Scope**: Found root cause, didn't cover all cases +- **Tool Limitation**: grep missed it, type check wasn't strict +- **Mental Model**: Kept looking in same layer, didn't think cross-layer + +### 3. Prevention Mechanisms + +What mechanisms would prevent this from happening again? + +| Type | Description | Example | +|------|-------------|---------| +| **Documentation** | Write it down so people know | Update thinking guide | +| **Architecture** | Make the error impossible structurally | Type-safe wrappers | +| **Compile-time** | 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?" | + +### 4. Systematic Expansion + +What broader problems does this bug reveal? + +- **Similar Issues**: Where else might this problem exist? +- **Design Flaw**: Is there a fundamental architecture issue? +- **Process Flaw**: Is there a development process improvement? +- **Knowledge Gap**: Is the team missing some understanding? + +### 5. Knowledge Capture + +Solidify insights into the system: + +- [ ] Update `.trellis/spec/guides/` thinking guides +- [ ] Update relevant `.trellis/spec/` docs +- [ ] Create issue record (if applicable) +- [ ] Create feature ticket for root fix +- [ ] Update check guidelines if needed + +--- + +## Output Format + +Please output analysis in this format: + +```markdown +## Bug Analysis: [Short Description] + +### 1. Root Cause Category +- **Category**: [A/B/C/D/E] - [Category Name] +- **Specific Cause**: [Detailed description] + +### 2. Why Fixes Failed (if applicable) +1. [First attempt]: [Why it failed] +2. [Second attempt]: [Why it failed] +... + +### 3. Prevention Mechanisms +| Priority | Mechanism | Specific Action | Status | +|----------|-----------|-----------------|--------| +| P0 | ... | ... | TODO/DONE | + +### 4. Systematic Expansion +- **Similar Issues**: [List places with similar problems] +- **Design Improvement**: [Architecture-level suggestions] +- **Process Improvement**: [Development process suggestions] + +### 5. Knowledge Capture +- [ ] [Documents to update / tickets to create] +``` + +--- + +## Core Philosophy + +> **The value of debugging is not in fixing the bug, but in making this class of bugs never happen again.** + +Three levels of insight: +1. **Tactical**: How to fix THIS bug +2. **Strategic**: How to prevent THIS CLASS of bugs +3. **Philosophical**: How to expand thinking patterns + +30 minutes of analysis saves 30 hours of future debugging. + +## Thinking Framework: Bayesian Reasoning + +When multiple root causes are plausible and evidence is incomplete, update your beliefs proportionally to new evidence rather than clinging to initial assumptions. + +### Step 1: Establish Priors + +Before investigating, state what you believe and why: + +| Hypothesis | Prior | Reasoning | +|------------|-------|-----------| +| H1: [cause A] | 40% | Most common for this pattern | +| H2: [cause B] | 30% | Plausible given environment | +| H3: [other] | 30% | Catch-all | + +Priors must sum to 100%. If you can't assign probabilities, investigate first. + +### Step 2: Observe Evidence + +Document what you found — be specific about reliability: + +- What exactly did you observe? +- How reliable? (test output > log message > user report > hunch) +- Could multiple hypotheses explain this? + +### Step 3: Update Beliefs + +For each hypothesis, ask: **How likely is this evidence if this hypothesis were true?** + +Direction of update matters more than calculation: +- Evidence strongly predicted by H1 → H1 probability increases +- Evidence contradicts H2 → H2 probability decreases +- Evidence equally likely under all → no update + +### Step 4: Seek Discriminating Evidence + +Don't gather more of the same. Find evidence that **differs strongly** between top hypotheses. + +> If H1 and H3 are close: "What would I see if H1 is true but not if H3 is true?" Then check for that. + +### Step 5: State Confidence + +| Confidence | Action | +|------------|--------| +| 90%+ | Proceed with fix, monitor | +| 70-90% | Proceed, add fallback check | +| 50-70% | Test hypothesis before committing | +| <50% | Need more evidence, don't guess | + +Never express binary certainty when evidence is incomplete. Use "most likely", "plausible but unlikely", "worth investigating". + +### Common Fallacies + +| Fallacy | Example | Correction | +|---------|---------|------------| +| **Base rate neglect** | "Test failed → code is broken" | How often do tests fail for other reasons? | +| **Confirmation bias** | "Must be a race condition, let me find race evidence" | Actively seek evidence AGAINST your top hypothesis | +| **Anchoring** | "Last time it was caching, probably caching again" | Establish priors from current context, not yesterday's bug | + +--- + +## After Analysis: Immediate Actions + +**IMPORTANT**: After completing the analysis above, you MUST immediately: + +1. **Update spec/guides** - Don't just list TODOs, actually update the relevant files: + - If it's a cross-platform issue → update `cross-platform-thinking-guide.md` + - If it's a cross-layer issue → update `cross-layer-thinking-guide.md` + - If it's a code reuse issue → update `code-reuse-thinking-guide.md` + - If it's domain-specific → update `backend/*.md` or `frontend/*.md` + +2. **Sync templates** - After updating `.trellis/spec/`, sync to `src/templates/markdown/spec/` + +3. **Commit the spec updates** - This is the primary output, not just the analysis text + +> **The analysis is worthless if it stays in chat. The value is in the updated specs.** diff --git a/.agents/skills/trellis-channel/SKILL.md b/.agents/skills/trellis-channel/SKILL.md new file mode 100644 index 0000000..511ee02 --- /dev/null +++ b/.agents/skills/trellis-channel/SKILL.md @@ -0,0 +1,67 @@ +--- +name: trellis-channel +description: Use Trellis channel for live multi-agent collaboration, spawned workers, cross-agent review, progress inspection, forum channels, and channel log debugging. +--- + +# trellis-channel + +`trellis channel` is the local multi-agent collaboration runtime. Reach for it when agents need to talk through a durable event log, when a worker should be spawned as a peer process, when an in-flight worker needs interrupt / debugging, or when feedback should be recorded on a durable `--type forum` channel. + +Typical user signals: "和 codex/claude 讨论", "brainstorm with another agent", "spawn an implement/check worker", "let agent review", "open an issue board / changelog forum", "look at this thread", "channel is stuck / no output", "progress was truncated", "how do I write that channel command". + +This skill is an index. Load only the reference file for the current job — do not preload all of them. + +## First Commands + +```bash +trellis --version +trellis channel --help +trellis channel list --all +trellis channel list --scope global --all +``` + +If the user names a channel or thread, inspect it before asking for background: + +```bash +trellis channel forum --scope global +trellis channel thread --scope global +trellis channel context list --scope global --thread +``` + +## Route By User Intent + +| User intent | Read | +|---|---| +| "和 codex/claude 讨论一下", "brainstorm with another agent" | `references/workflows.md` | +| "派一个 implement/check agent", "让 agent review", "spawn a worker" | `references/workflows.md`, then `references/workers.md` | +| "开 issue 区 / topic 群 / changelog / board", "make a forum" | `references/forum.md` | +| "看看这个 thread / linked context", "inspect a thread" | `references/forum.md` | +| "channel 卡住了 / 没输出 / progress 被截断", "worker stalled" | `references/progress-debugging.md` | +| "具体命令怎么写", "what flags does X take" | `references/command-reference.md` | + +## Core Rules + +- New forum channels use `--type forum`. A `thread` is one item inside a forum channel. +- Use `--context-file` / `--context-raw` and `trellis channel context add/delete/list`. `--linked-context-*` is deprecated terminology. +- Use `--stdin` or `--text-file` for long messages. Do not put long mixed Chinese/English text in the positional shell argument. +- Pretty `messages` output is an operator dashboard and may truncate progress. Use `--raw` for audit. +- `--as` is the speaker or worker handle, depending on the command. Use explicit, stable names when multiple agents or sessions are involved. +- `--scope project` (default) operates on the current cwd's project bucket; `--scope global` operates on the shared `__global__` bucket. Pick scope deliberately — a global board is invisible from project listings unless `--scope global` is passed. +- For brainstorm, do multiple pressure-test rounds. One answer plus one confirmation is review, not brainstorm. +- **Dispatcher wait pattern**: use `--kind done` / `--kind turn_finished` (trellis-emitted system events), NOT a user `--tag` as the completion signal. CLI help lists `phase_done` / `question` as `--tag` examples but only `interrupt` is a reserved tag with hardcoded trellis behavior; the others are opaque user labels. Relying on a worker to run `send --tag ` is unreliable — LLM workers commonly write the tag string into prose instead of running the actual CLI command. See `references/command-reference.md` "tag vs kind". +- Forum channels are event-sourced. Do not parse `events.jsonl` first; use `forum`, `thread`, `messages --thread`, and `context list`. +- `@mindfoldhq/trellis-core` owns reusable channel/thread state, event append, seq allocation, context/title projection, reducers, and task helpers. The CLI owns flags, terminal rendering, prompts, worker lifecycle, and process exits. + +## Reference Files + +- `references/workflows.md` — canonical collaboration patterns A–F (peer brainstorm, spawned review, dispatch-and-wait, forum issue capture, interrupt-and-redirect, one-shot run). +- `references/forum.md` — forum channels, context, title, rename, changelog forums, thread filtering. +- `references/workers.md` — spawn, agent cards, context injection (`--file` / `--jsonl`), interrupts, kill semantics. +- `references/progress-debugging.md` — progress/raw inspection, stalled worker diagnosis, OOM guard, exit codes. +- `references/command-reference.md` — current CLI command reference (every subcommand, every flag, output conventions, scope/type model). + +## Not For + +- One static review where a markdown file and prompt are enough. +- Replacing normal tool calls with self-logging. +- Long-term memory retrieval. Use durable forum channels for actionable issues, and `trellis mem` (the `trellis-session-insight` skill) for session/history search. diff --git a/.agents/skills/trellis-channel/references/command-reference.md b/.agents/skills/trellis-channel/references/command-reference.md new file mode 100644 index 0000000..75def26 --- /dev/null +++ b/.agents/skills/trellis-channel/references/command-reference.md @@ -0,0 +1,480 @@ +# Command Reference + +Authoritative current command reference for `trellis channel` subcommands, +validated against the source in `packages/cli/src/commands/channel/` +(`index.ts` Commander wiring and each subcommand handler). + +Every subcommand accepts `--scope ` unless noted; `project` +is the default and resolves against the current cwd's project bucket. + +## Top-level + +``` +trellis channel +``` + +> Multi-agent collaboration runtime — spawn / coordinate / interrupt worker +> agents through a shared event log. + +--- + +## Create / List + +### `create ` + +```bash +trellis channel create + [--scope project|global] # default: project + [--type chat|forum] # default: chat + [--task ] # associated Trellis task dir + [--project ] + [--labels a,b,c] + [--description ] # stable channel description + [--context-file ] ... # repeatable + [--context-raw ] ... # repeatable + [--linked-context-file ] # [deprecated alias] + [--linked-context-raw ] # [deprecated alias] + [--cwd ] # recorded in create event + [--by ] # default: main + [--force] # overwrite existing channel + [--ephemeral] # hide from default list, prunable +``` + +Behavior: +- Appends a `create` event; immutable `type` (cannot mutate forum↔chat after). +- `--ephemeral` channels are hidden from `channel list` by default and are + the sweep target for `channel prune --ephemeral`. +- `--linked-context-*` are folded into `--context-*`; emit a deprecation + notice when used. + +### `list` + +```bash +trellis channel list + [--scope project|global] + [--json] + [--project ] # substring match on task field + [--all] # include ephemeral (suffix '*') + [--all-projects] # scan every project bucket +``` + +Behavior: +- Default scope: current cwd's project. `--all-projects` scans every bucket. +- Pretty mode prints `NAME WORKERS EVENTS LAST KIND TYPE TASK`, sorted by + recency, with a footer noting hidden ephemeral count. +- `--json` switches to a JSON array. + +--- + +## Chat Messages + +### `send [text]` + +```bash +trellis channel send [text] + --as # REQUIRED — author + [--scope project|global] + [--to ] # default: broadcast + [--stdin | --text-file ] # body from stdin or file + [--delivery-mode appendOnly|requireKnownWorker|requireRunningWorker] +``` + +Behavior: +- Body precedence: positional `[text]` → `--stdin` → `--text-file`. +- `--to` with one entry stores a string; multiple stores an array; omitted + means broadcast. +- `--delivery-mode` selects targeted-delivery validation: + - `appendOnly` (default-ish — just record), + - `requireKnownWorker` (the named target must have a `spawned` event), + - `requireRunningWorker` (the worker must currently be live). +- Prints the appended event as one JSON line on stdout. + +> **Note:** `send` has **no** `--tag` and **no** `--kind` flag. See +> [`tag-vs-kind`](#tag-vs-kind--how-event-shape-is-actually-controlled) below. + +### `messages ` + +```bash +trellis channel messages + [--scope project|global] + [--raw] # one JSON event per line + [--follow] # stream new events + [--last ] # last N matching events + [--since ] # seq > N + [--kind ] # one of CHANNEL_EVENT_KINDS + [--from ] # author filter + [--to ] # routing target filter + [--thread ] # forum-only + [--action ] # forum-only + [--no-progress] # hide progress events +``` + +Behavior: +- Auto-detects forum channels: with no filters it renders the thread board + instead of the event stream. `--thread` / `--action` are forum-only and + error against chat channels. +- `--kind` is validated against `CHANNEL_EVENT_KINDS` (single value, not + CSV — that's the `wait` side). + +### `wait ` + +```bash +trellis channel wait + --as # REQUIRED — self for filter ctx + [--scope project|global] + [--timeout ] # parsed by parseDuration + [--from ] # author CSV + [--kind ] # CSV, OR semantics + [--thread ] # forum filter + [--action ] # forum filter + [--to ] # default: own agent (broadcast + me) + [--include-progress] # also wake on progress events + [--all] # require every --from to match +``` + +Behavior: +- Streams matching events as JSON, one per line. +- Default `--to` filter is the caller's own agent (broadcast events still + match — broadcast + explicit-to-me). +- `--all` requires `--from` and blocks until every listed agent has produced + a matching event. +- **Timeout exits 124** and prints `timeout: still waiting on ...` to stderr + when `--all` was in play. + +--- + +## tag-vs-kind — how event shape is actually controlled + +There is **no `--tag` flag** anywhere in the v0.6.0 channel CLI; `--kind` is +not a legacy alias for any `--tag` flag. + +Concrete model in the current source: + +- `--kind` is the only event-type filter, and it is constrained to the + trellis-emitted whitelist (`CHANNEL_EVENT_KINDS` in + `packages/core/src/channel/internal/store/events.ts`): + - `create`, `join`, `leave`, `message`, `thread`, `context`, `channel`, + `spawned`, `killed`, `respawned`, `progress`, `done`, `error`, + `waiting`, `awake`, `undeliverable`, `interrupt_requested`, + `turn_started`, `turn_finished`, `interrupted`, `supervisor_warning` + - Passing anything else throws + `Invalid --kind ''. Must be one of: …`. +- `--kind` lives on `wait` (CSV, OR semantics) and `messages` (single + value). `send` and `run` cannot emit a custom kind — every `send` writes + a `message` event. +- Mid-turn worker abort is **not** a tag. It is the dedicated + `channel interrupt` command, which appends an `interrupt_requested` / + `interrupted` pair and provider-level interrupts the worker. + +Practical rule for dispatchers waiting on workers: + +- Use `--kind done,turn_finished` for "worker finished a turn" — these are + system events that the supervisor fires automatically. Do not depend on + the worker LLM remembering to emit any custom signal. +- Use `trellis channel interrupt` (the command) only when you actually want + mid-turn abort behavior. +- Do **not** invent user-side tags as completion signals. There is no + `--tag` filter; a worker writing a custom string into its final message + is just text inside a `message` event and cannot be matched by `wait`. + +Long bodies always go through stdin or a file: + +```bash +trellis channel send T --as A --stdin < /tmp/message.md +trellis channel send T --as A --text-file /tmp/message.md +``` + +--- + +## Interrupt + +### `interrupt [text]` + +```bash +trellis channel interrupt [text] + --as # REQUIRED — caller + --to # REQUIRED — target worker + [--scope project|global] + [--stdin | --text-file ] +``` + +Behavior: +- Appends an `interrupt` event with `reason: "user"` and a replacement + instruction body; supervisor performs provider-level interrupt where + supported (Claude `/interrupt`, Codex turn cancel). +- Prints the appended event JSON on stdout. + +--- + +## Workers + +### `spawn ` + +```bash +trellis channel spawn + [--scope project|global] + [--agent ] # loads .trellis/agents/.md + [--provider claude|codex] # overrides agent file + [--as ] # default: agent name + [--cwd ] + [--model ] + [--resume ] # session/thread id resume + [--timeout ] # auto-kill after duration + [--warn-before ] # supervisor_warning lead time + # default 5m, 0ms disables + [--file ] ... # glob, repeatable; inject content + [--jsonl ] ... # Trellis manifest, repeatable + [--by ] # spawn-event author + # default: TRELLIS_CHANNEL_AS env or 'main' + [--inbox-policy explicitOnly|broadcastAndExplicit] + # default explicitOnly + [--idle-timeout ] # OOM-guard idle TTL + # default 5m, 0 disables + [--max-live-workers ] # spawn-time live-worker budget + # default 6, 0 disables +``` + +Behavior: +- Provider is validated against the adapter registry + (`packages/cli/src/commands/channel/adapters/`); current: `claude`, + `codex`. +- Worker stays inbox-idle until the first `send --to `. +- Records a `spawned` event with `pid`, `provider`, `agent`, `files`, + `manifests`. +- OOM-guard precedence: CLI flag → env var + (`TRELLIS_CHANNEL_WORKER_IDLE_TIMEOUT`, + `TRELLIS_CHANNEL_MAX_LIVE_WORKERS`) → + `.trellis/config.yaml#channel.worker_guard` → built-in defaults. + +### `run [name]` + +```bash +trellis channel run [name?] + [--agent ] + [--provider claude|codex] + [--as ] + [--cwd ] + [--model ] + [--file ] ... # repeatable, glob + [--jsonl ] ... # repeatable + [--message | --message-file | --stdin] + [--timeout ] # default 5m +``` + +Behavior: +- One-shot. Auto-generates `run-` if `name` omitted. +- Creates an ephemeral channel (`createMode=run`), spawns a single worker, + sends the prompt, waits for `done`, prints the final assistant text to + stdout, then removes the channel on success. On failure the channel is + kept for inspection and exit code is 1. + +> `run` has **no** `--tag` flag. Completion is detected via the `done` +> event the supervisor emits. + +### `kill ` + +```bash +trellis channel kill + --as # REQUIRED — worker agent name + [--scope project|global] + [--force] # SIGKILL immediately +``` + +Behavior: +- Default path: SIGTERM → 8 s grace → SIGKILL escalation; the CLI writes a + `killed` event when SIGKILL was needed so the log stays truthful. +- Cleans `pid`, `worker-pid`, `config`, `spawnlock` sidecar files; keeps + `log`, `session-id`, `thread-id` for forensics / resume. + +### `rm ` + +```bash +trellis channel rm + [--scope project|global] +``` + +Behavior: +- Kills any live workers, then deletes the entire channel directory. +- Prints `Removed channel ''`. + +### `prune` + +```bash +trellis channel prune + [--scope project|global] # omitted: scan every project + [--all | --empty | --idle | --ephemeral] # mutually exclusive + [--yes] # actually delete (default: dry-run) + [--dry-run] # default true; redundant with default + [--keep ] # exclusion list +``` + +Behavior: +- Filter flags are mutually exclusive — error otherwise. +- Default is dry-run; `--yes` flips to real delete. +- Without `--scope`, scans **every** project bucket (intentional, repo-wide + cleanup); with `--scope project|global`, limited to that bucket. +- Live-worker channels are always skipped regardless of filter. +- Output: per-candidate line `name last-ts (reason)` plus a final summary. + +--- + +## Forum Channels + +### `post ` + +```bash +trellis channel post + --as # REQUIRED + [--scope project|global] + [--thread ] # required except action=opened + [--title ] + [--text | --stdin | --text-file ] + [--description ] # stable thread description + [--status ] + [--labels a,b] # REPLACES thread labels + [--assignees a,b] # REPLACES assignees + [--summary ] + [--context-file ] ... + [--context-raw ] ... + [--linked-context-file ] # [deprecated alias] + [--linked-context-raw ] # [deprecated alias] +``` + +Behavior: +- `` is free-form on the CLI surface; conventional values include + `opened`, `comment`, `status`, `labels`, `assignees`, `summary`, + `processed`. +- `action=rename` is rejected — use `thread rename` instead. +- `--labels` / `--assignees` are replace-semantics, not append. +- Output: appended event JSON on stdout. + +### `forum ` + +```bash +trellis channel forum + [--scope project|global] + [--status ] + [--raw] +``` + +Behavior: +- Lists threads (reduced state). `--status` filters by current thread + status. `--raw` prints one JSON per thread. + +### `thread ` / `thread rename` + +```bash +trellis channel thread + [--scope project|global] + [--raw] + +trellis channel thread rename + --as # REQUIRED + [--scope project|global] +``` + +Behavior: +- `thread ` shows one thread's timeline: + header ` [] `, then description / labels / + assignees / summary / timeline lines. `--raw` switches to raw events. +- `thread rename` is the only mutation; `post --action rename` is rejected. + +--- + +## Context / Title + +### `context add` / `context delete` / `context list` + +```bash +trellis channel context add <name> + [--as <agent>] # default: main + [--scope project|global] + [--thread <key>] # thread-level instead of channel-level + [--file <abs-path>] ... # repeatable + [--raw <text>] ... # repeatable + # at least one of --file or --raw + +trellis channel context delete <name> + [--as <agent>] # default: main + [--scope project|global] + [--thread <key>] + [--file <abs-path>] ... + [--raw <text>] ... + +trellis channel context list <name> + [--scope project|global] + [--thread <key>] + [--raw] # one JSON entry per line +``` + +Behavior: +- `add` / `delete` append a `context` event and print the event JSON. +- `list` projects current context entries; pretty output is + `file <path>` / `raw <truncated text>` lines, `(no context)` when empty. + +### `title set <name>` / `title clear <name>` + +```bash +trellis channel title set <name> + --title <text> # REQUIRED + [--as <agent>] # default: main + [--scope project|global] + +trellis channel title clear <name> + [--as <agent>] # default: main + [--scope project|global] +``` + +Behavior: +- Appends a `title` event projecting a stable display title onto the + channel. Output: event JSON. + +--- + +## Hidden / Internal + +| Command | Purpose | +|---|---| +| `channel __supervisor <channel> <worker> <config>` | Forked entry point invoked by `spawn`. Do not invoke directly. | +| `channel __parse-trace <adapter> <file>` | Dev helper — replays a recorded stream-json / wire trace through the matching adapter and prints the resulting channel events. Adapter is validated against the provider registry. | + +--- + +## Event Model + +`CHANNEL_EVENT_KINDS` (whitelist enforced by `parseChannelKind`): + +`create`, `join`, `leave`, `message`, `thread`, `context`, `channel`, +`spawned`, `killed`, `respawned`, `progress`, `done`, `error`, `waiting`, +`awake`, `undeliverable`, `interrupt_requested`, `turn_started`, +`turn_finished`, `interrupted`, `supervisor_warning`. + +`MEANINGFUL_EVENT_KINDS` (default-visible subset used by `wait` / +`messages` when no explicit `--kind` is given): + +`create`, `join`, `leave`, `message`, `thread`, `context`, `channel`, +`spawned`, `killed`, `respawned`, `done`, `error`. + +Non-meaningful kinds (e.g. `progress`, `waiting`, `awake`, +`supervisor_warning`, the `turn_*` / `interrupt*` set) still flow through +the store; opt in via `--kind` or `--include-progress`. + +Forum channels are event-sourced; use the CLI reducers +(`forum`, `thread`, `context list`) for state projection. + +--- + +## Output Conventions + +- **Mutations** (`send`, `interrupt`, `post`, `context add/delete`, + `title set/clear`, `thread rename`) print the appended event as one JSON + line on **stdout**. +- **Streaming reads** (`wait`, `messages --follow`) print one JSON event + per line on stdout. +- **Pretty reads** (`list`, `messages`, `forum`, `thread`, `context list`) + print colored, padded tables / timelines. +- **`run`** prints only the final assistant text on stdout (so callers can + pipe); diagnostic notes go to stderr. +- **Errors** go through `chalk.red("Error:")` to stderr and `exit 1`. +- **`wait` timeout** specifically exits **124**. + diff --git a/.agents/skills/trellis-channel/references/forum.md b/.agents/skills/trellis-channel/references/forum.md new file mode 100644 index 0000000..06b7f36 --- /dev/null +++ b/.agents/skills/trellis-channel/references/forum.md @@ -0,0 +1,233 @@ +# Forum Channels + +Forum channels are durable, topic-style channels. They are created with +`--type forum` at channel-creation time and are immutable after that. They are +not normal chat streams: the default read path is +**forum summary -> one thread timeline -> current context**. + +## Forum vs Regular Channel + +A channel's type is set with `--type` on `channel create` and never changes: + +- `chat` (default) — flat message timeline. `channel messages` always renders + the event stream. Forum-only flags such as `--thread` and `--action` are + rejected here. +- `forum` — thread-oriented. `channel messages` without filters renders a + thread-board summary instead of raw events. The `post`, `forum`, `thread`, + and `thread rename` subcommands only apply to forum channels. + +Both types share the same scope model (`--scope project` is the default; +`--scope global` puts the channel in the cross-project bucket). + +## Create A Forum Channel + +```bash +trellis channel create design-feedback \ + --type forum \ + --scope global \ + --description "Cross-project design feedback board." \ + --context-raw "One thread per design topic; close when resolved." \ + --by main +``` + +Use `--scope project` for a board scoped to one repo, `--scope global` for a +cross-project board. + +## Threads: Open, Comment, Status, Summary + +Threads live inside a forum channel. Each thread is identified by a stable +`--thread <key>` (lowercase kebab-case is conventional). The first action on +a thread is `opened`; everything afterwards uses the same `--thread` key. + +```bash +trellis channel post design-feedback opened \ + --scope global \ + --as main \ + --thread login-empty-state \ + --title "Empty state on the login screen" \ + --description "Track design feedback for the new login empty state." \ + --labels design,login \ + --context-raw "Spotted during the 0.4 release review." \ + --text-file /tmp/thread-open.md + +trellis channel post design-feedback comment \ + --scope global \ + --as reviewer \ + --thread login-empty-state \ + --text-file /tmp/review.md + +trellis channel post design-feedback status \ + --scope global \ + --as main \ + --thread login-empty-state \ + --status closed + +trellis channel post design-feedback summary \ + --scope global \ + --as main \ + --thread login-empty-state \ + --summary "Adopted the option-B layout; ticket TRELLIS-123 owns the fix." +``` + +Key distinctions: + +- `--description` is the **durable** thread description (the answer to "what + is this thread about?"). It is set on `opened` and edited by re-running + `post` with `--description`. +- `--text` / `--stdin` / `--text-file` is the **event body** — the comment or + payload attached to this specific timeline entry. +- `--labels` and `--assignees` are CSV and **replace** the current value; they + do not append. +- `--summary` is the rolling thread summary. Setting it on `status closed` is + the standard way to mark a thread resolved with context. + +`--thread` is required for every action except `opened` (where it is also +required in practice — there is no anonymous thread). + +## Read A Forum + +```bash +trellis channel messages design-feedback --scope global +trellis channel forum design-feedback --scope global --status open +trellis channel thread design-feedback login-empty-state --scope global +trellis channel messages design-feedback --scope global --raw --thread login-empty-state +``` + +If a peer says "I commented on the forum", run `channel forum` first to see +which thread changed, then drill into that thread with `channel thread <name> +<thread>`. Do not jump straight to ad-hoc `events.jsonl` parsing. + +## Context + +Context entries are durable background that should always be in scope when +reading a channel or a thread. They are **not** timeline events; they are +projected separately and replayed for every reader. + +Use the `context` subcommands. The legacy `--linked-context-file` / +`--linked-context-raw` flags on `create` and `post` are deprecated aliases +that fold into the canonical `--context-file` / `--context-raw`. + +### Add Context + +```bash +# Channel-level context (whole forum) +trellis channel context add design-feedback \ + --scope global \ + --raw "Upstream feedback board; please link tasks before opening threads." + +# Thread-level context (one thread) +trellis channel context add design-feedback \ + --scope global \ + --thread login-empty-state \ + --file "$PWD/.trellis/tasks/05-13-login-redesign/design.md" +``` + +- `--thread <key>` switches between channel-level and thread-level context. +- `--file` paths **must be absolute**; relative paths are rejected. +- `--raw` is plain text inline content. +- Both flags are repeatable; at least one is required for `add` / `delete`. +- `--as <agent>` records authorship; defaults to `main`. + +### List Context + +```bash +trellis channel context list design-feedback --scope global +trellis channel context list design-feedback --scope global --thread login-empty-state --raw +``` + +`--raw` on `list` emits one JSON entry per line (useful for piping); without +it you get a human-readable `file <path>` / `raw <truncated text>` listing. +An empty store prints `(no context)`. + +### Delete Context + +```bash +trellis channel context delete design-feedback \ + --scope global \ + --thread login-empty-state \ + --raw "stale note" +``` + +You delete by **value**, not by id: pass the same `--file` or `--raw` value +that was added. Repeat the flag to delete multiple entries in one call. + +### Reading Order + +When reading a thread, work top-down: + +1. Thread `description` (the durable "what is this about"). +2. Context entries (channel-level + thread-level). +3. Timeline (`opened`, `comment`, `status`, `summary`). + +If a context file is missing or unreadable, state that explicitly and +continue with the remaining data — do not fabricate the content. + +## Title Projection + +`title` projects a stable display title onto the channel without renaming the +storage address. The channel `name` you pass to every command stays the same. + +```bash +trellis channel title set design-feedback \ + --scope global \ + --title "Design feedback board" + +trellis channel title clear design-feedback --scope global +``` + +- `title set` requires `--title`. +- `--as <agent>` records authorship; defaults to `main`. +- This is a presentation-layer change. Tooling and scripts keep using the + original channel name. + +## Thread Rename + +`thread rename` is the correction path when a thread was opened with the +wrong key (typo, wrong slug convention, etc.). Threads do not support hard +deletion — rename is the supported corrective action. + +```bash +trellis channel thread rename design-feedback old-key new-key \ + --scope global \ + --as main +``` + +- `--as <agent>` is **required**. +- `post <name> rename` is rejected — you must use `thread rename`. + +## Deletion Discipline + +Do not model single-comment deletion or hard thread deletion as normal +workflow. Forum threads are append-only collaboration history. To correct +state, use: + +- `post ... status` to mark a thread closed / blocked / etc. +- `post ... summary` to record the resolution. +- `post ... --labels` to re-label (replaces the set). +- `thread rename` to correct a bad thread key. + +## Internal Changelog Pattern + +A common use of a global forum channel is an internal release / runtime +changelog. One thread per notable change keeps history searchable: + +```bash +trellis channel create release-notes \ + --type forum \ + --scope global \ + --description "Internal release and runtime changelog." \ + --context-raw "One thread per notable change; close when shipped." \ + --by main + +trellis channel post release-notes opened \ + --scope global \ + --as main \ + --thread release-2026-q1 \ + --title "Channel threads and forum UX in 0.6" \ + --description "Forum channel UX shipped in the 0.6 line." \ + --labels channel,release \ + --text-file /tmp/release-notes.md +``` + +Use stable, descriptive thread keys (e.g. `release-2026-q1`, +`runtime-event-schema-change`) so later readers can find them by name. diff --git a/.agents/skills/trellis-channel/references/progress-debugging.md b/.agents/skills/trellis-channel/references/progress-debugging.md new file mode 100644 index 0000000..3ed40d6 --- /dev/null +++ b/.agents/skills/trellis-channel/references/progress-debugging.md @@ -0,0 +1,226 @@ +# Progress And Debugging + +Pretty output is for operators. Raw output is the audit log. Subcommands +(`forum`, `thread`, `messages`, `context`) are the audit *interface* — reach +for them before grepping `events.jsonl` by hand. + +## Pretty vs `--raw` + +`trellis channel messages <channel>` renders a compact, human-readable view: +timestamps, identities, kind, and a short body. It is meant for operators +scanning a channel, not for diagnostics. + +Pretty output can and will truncate: + +- long progress deltas (`text_delta`, partial tool args) +- tool names and command lines +- multi-line status fields and structured `detail` blobs +- forum thread titles past the column budget + +When something looks "off" — a worker appears stuck, a progress line ends +mid-word, an action field shows `...` — switch to `--raw`. Raw mode emits +one JSON event per line exactly as it lives in `events.jsonl`, so nothing +is dropped. + +```bash +# Pretty (operator view) +trellis channel messages <channel> --kind done --last 10 +trellis channel messages <channel> --kind error --last 10 + +# Raw (diagnostic view) — one JSON per line +trellis channel messages <channel> --raw --kind progress --last 20 +trellis channel messages <channel> --raw --last 50 +``` + +Rule of thumb: never diagnose a worker from a truncated progress line. + +### Rebuild Streaming Text + +To reconstruct what a model actually streamed during a turn, concatenate +`detail.text_delta` from progress events: + +```bash +trellis channel messages <channel> --raw --kind progress --last 80 \ + | python3 -c 'import json,sys; [print((json.loads(l).get("detail") or {}).get("text_delta",""), end="") for l in sys.stdin if l.strip()]' +``` + +## Stalled Worker Diagnosis + +Symptom: `trellis channel list` shows the worker as running, but no new +events appear in `messages` and `wait` keeps timing out. + +Triage order: + +1. **Locate the channel files.** Use `list --all --all-projects` if you are + not sure which bucket the channel lives in. + + ```bash + trellis channel list --all --all-projects + CHAN=~/.trellis/channels/<bucket>/<channel> + ``` + +2. **Confirm the supervisor and worker PIDs are alive.** + + ```bash + cat "$CHAN/<worker>.pid" # supervisor PID + cat "$CHAN/<worker>.worker-pid" # actual CLI subprocess PID + ps -p "$(cat "$CHAN/<worker>.pid")" + ps -p "$(cat "$CHAN/<worker>.worker-pid")" + ``` + + If the supervisor PID is gone but the channel still lists the worker, + you have a ghost entry — clean it with + `trellis channel kill <name> --as <worker> --force`. + +3. **Tail the worker log.** This is the canonical place to see provider / + MCP / tool startup output that never makes it onto the channel. + + ```bash + tail -f "$CHAN/<worker>.log" + ``` + +4. **Check the last raw events.** A worker that emitted `progress` but no + `message`/`done` is usually mid-stream or blocked on a tool call: + + ```bash + trellis channel messages <channel> --raw --last 50 + ``` + +Common "alive but silent" causes: + +- Provider cold start before the first token (long, but eventually moves). +- A blocking MCP server during startup — visible in the worker log. +- Worker is waiting for a tool result whose subprocess hung. +- Prompt is huge / model is rate-limited; check provider-side errors in the + worker log. + +## Progress Event Interpretation + +A `progress` event represents an in-flight piece of work. Its shape varies +by `action` field, but the load-bearing fields are always under `detail`: + +- `detail.text_delta` — incremental model output (concatenate across events + to rebuild the streamed reply). +- `detail.tool_name`, `detail.tool_input` — tool call about to run or + currently running. +- `detail.status` — short string used by long-running actions + (`starting`, `running`, `flushing`, `done`). +- `detail.action` — semantic label (e.g. `status` for thread heartbeats). + +Progress events are **noisy** by design. `wait` ignores them unless you +pass `--include-progress`. When you do want to see them, prefer: + +```bash +trellis channel messages <channel> --raw --kind progress --last 80 +``` + +A stream that emits progress at a steady cadence but never closes with +`done`/`error`/`message` is the classic shape of a hung tool call — +inspect the worker log for the subprocess. + +## Wait Semantics (Quick Reference) + +`channel wait` watches `events.jsonl` from EOF and wakes on: + +- `message` +- `done` +- `error` +- `killed` +- `progress` only with `--include-progress` + +Useful filters: + +```bash +trellis channel wait T --as main --from check --kind done --timeout 15m +trellis channel wait T --as main --from check,check-cx --kind done --all --timeout 15m +trellis channel wait T --as worker --tag interrupt --timeout 1h +trellis channel wait T --as main --thread release-note --action status --timeout 10m +``` + +Exit codes: `0` matched, `124` timeout, `1`/`2` errors. On `wait --all` +timeout, stderr names the workers still missing. + +## Auditing `events.jsonl` — Use Subcommands, Not `grep` + +Every channel persists its full history at `$CHAN/events.jsonl`. It is +tempting to `tail` / `grep` / `jq` this file directly during debugging. +Don't make it a habit, and **never** do it for forum channels. + +Why subcommands first: + +- `messages` already replays the file with filters (`--kind`, `--from`, + `--last`, `--tag`, `--thread`, `--action`) and gives you `--raw` for the + exact JSON. Anything you would write a one-liner for, `messages` already + does. +- `wait` consumes the same file with EOF semantics — re-implementing that + with `tail -f | jq` will drop events under load and misorder them under + rotation. +- `context` materializes a worker's inbox view, including cursor state. + Hand-rolled filters do not respect `<worker>.inbox-cursor`. + +### Forum channels: never parse `events.jsonl` directly + +Forum channels multiplex many logical threads onto a single `events.jsonl`. +Each event carries `thread`, `action`, and tag fields that the forum +subcommands know how to fold together. Parsing the file by hand will: + +- Mix threads together and make a thread look incoherent. +- Miss thread lifecycle events (open / status / close) that change how + later events should be interpreted. +- Ignore worker inbox cursors, so you will "see" events a worker has + already consumed and assume they are pending. + +Use the forum-aware views instead: + +```bash +# List logical threads inside the forum channel +trellis channel forum list <channel> + +# Inspect one thread end-to-end +trellis channel thread show <channel> <thread> + +# Replay messages for a thread (supports --raw, --kind, --last) +trellis channel messages <channel> --thread <thread> --raw --last 100 + +# What a specific worker still has pending +trellis channel context <channel> --as <worker> +``` + +Direct reads of `events.jsonl` are reserved for the case where the CLI +itself is suspect — e.g. confirming an event was actually persisted, or +diffing against `<worker>.inbox-cursor` while debugging the supervisor. + +## Common Failures + +| Symptom | Cause | Fix | +|---|---|---| +| `trellis: command not found` | CLI not installed globally | `npm install -g @mindfoldhq/trellis` | +| `wait` exits immediately | wrong filter or identity collision | use distinct `--as`, inspect raw messages | +| zsh errors on message text | shell interpreted punctuation | use `--stdin` or `--text-file` | +| progress line is cut off | pretty output truncation | use `messages --raw --kind progress` | +| worker never speaks | provider startup / prompt / MCP delay | inspect `<worker>.log`, `ps`, raw events | +| channel not found in another cwd | project bucket mismatch | `cd` to project, use `--scope global`, or `list --all-projects` | +| ghost worker in list | supervisor died without cleanup | `trellis channel kill <name> --as <worker> --force` | +| forum thread looks scrambled | parsed `events.jsonl` directly | use `forum`, `thread`, `messages --thread` | + +## Storage Layout + +```text +~/.trellis/channels/ +└── <bucket>/ + └── <channel-name>/ + ├── events.jsonl + ├── <channel>.lock + ├── <worker>.log + ├── <worker>.pid + ├── <worker>.worker-pid + ├── <worker>.config + ├── <worker>.session-id + ├── <worker>.thread-id + ├── <worker>.inbox-cursor + └── <worker>.spawnlock +``` + +Agents normally use the CLI, not direct file reads. Direct file reads are +for debugging when CLI views are insufficient — and even then, never on a +forum channel's `events.jsonl`. diff --git a/.agents/skills/trellis-channel/references/workers.md b/.agents/skills/trellis-channel/references/workers.md new file mode 100644 index 0000000..bcec98f --- /dev/null +++ b/.agents/skills/trellis-channel/references/workers.md @@ -0,0 +1,276 @@ +# Workers And Agent Cards + +Use workers when a peer agent should execute independently and report back +through the channel event log. A worker is a registered child process (claude +or codex) attached to a channel; the supervisor forwards inbox messages to it +and translates its output back into channel events. + +## Spawn + +```bash +trellis channel create impl-task --by dispatcher --cwd /path/to/repo +trellis channel spawn impl-task --provider codex --as codex-impl --timeout 30m + +echo "Implement the schema for table X per .trellis/.../prd.md" \ + | trellis channel send impl-task --as dispatcher --to codex-impl --stdin + +trellis channel wait impl-task --as dispatcher --from codex-impl --kind done --timeout 30m +``` + +`spawn` forks a `channel __supervisor` worker that emits `spawned`, streams +`progress`, and should end with `done`, `error`, or `killed`. Workers stay +inbox-idle until a `send --to <worker>` (or a broadcast when +`--inbox-policy broadcastAndExplicit` is set) wakes them. + +Key `spawn` flags: + +- `--agent <name>` — load `.trellis/agents/<name>.md` (provider/model/as/system prompt defaults). +- `--provider <claude|codex>` — overrides the agent card; validated against the adapter registry. +- `--as <name>` — channel worker handle; defaults to the agent name. +- `--cwd <path>` — worker working directory (also the jail root for `--file`/`--jsonl`). +- `--model <id>` — model override. +- `--resume <id>` — resume an existing claude session / codex thread. +- `--timeout <duration>` — auto-kill after `30s` / `2m` / `1h`. +- `--warn-before <duration>` — supervisor_warning lead time (default `5m`; `0ms` disables). +- `--file <path>` (repeatable, glob-supported) — inject file content into the system prompt. +- `--jsonl <path>` (repeatable) — Trellis jsonl manifest (`{file, reason}` per line). +- `--by <agent>` — author of the `spawned` event (defaults to `$TRELLIS_CHANNEL_AS` or `main`). +- `--inbox-policy <explicitOnly|broadcastAndExplicit>` — default `explicitOnly`. +- `--idle-timeout <duration>` — OOM guard idle TTL (default `5m`; `0` disables). +- `--max-live-workers <n>` — spawn-time live-worker budget (default `6`; `0` disables). + +The success event `spawned` records `pid`, `provider`, `agent`, the injected +`files`, and the resolved `manifests` so later spectators can audit context. + +## Agent Cards + +`--agent <name>` resolves to `.trellis/agents/<name>.md`. The card name must +match `[A-Za-z0-9._-]+`. The default Trellis install ships two cards: + +- `.trellis/agents/check.md` — code-quality reviewer. +- `.trellis/agents/implement.md` — coding worker for implementation runs. + +```yaml +--- +name: check +description: Code quality check expert. +provider: claude +--- +``` + +Frontmatter fields populate `spawn` defaults (provider, model, `as`); the +markdown body becomes the worker's system-prompt role. Cards do **not** +auto-attach task files — context must be injected explicitly per spawn (see +below). + +Always inspect project cards before spawning a named agent: + +```bash +ls .trellis/agents +sed -n '1,100p' .trellis/agents/check.md +``` + +## Context Injection + +Two flags inject content into the worker's system prompt under a +`# CONTEXT FILES` block, assembled by `context-loader`: + +- `--file <path>` — repeatable, glob-supported (`*`, `**`). Each match is + read and concatenated. +- `--jsonl <path>` — repeatable Trellis manifest where every line is + `{"file":"<path>","reason":"<why>"}`. The reason is preserved as a header + comment above each file's content. + +Limits enforced by the loader: + +- 1 MB hard cap per file (oversize → error). +- 200 KB per-file warning to stderr. +- 500 KB total assembled-context warning to stderr. +- Path-traversal jail: all resolved paths must stay under `--cwd`. + +Example spawning a check agent against a task directory: + +```bash +TASK=.trellis/tasks/05-13-example +trellis channel spawn cr-example --agent check --provider codex --as check-cx \ + --file "$TASK/prd.md" \ + --file "$TASK/design.md" \ + --file "$TASK/implement.md" \ + --jsonl "$TASK/check.jsonl" \ + --cwd "$PWD" --timeout 30m +``` + +The `spawned` event records both the literal `files` array and any `manifests` +expanded from `--jsonl`, so the audit trail captures whatever the worker was +actually shown. + +## Names And Routing + +`--as` has two meanings: + +- `send` / `wait` / `interrupt`: speaker identity (author of the resulting event). +- `spawn`: the worker handle that other agents address with `--to`. + +Use explicit names when multiple workers or providers participate in one +channel: + +```bash +trellis channel spawn cr-feature --agent check --as check-claude +trellis channel spawn cr-feature --agent check --provider codex --as check-cx + +trellis channel wait cr-feature --as main \ + --from check-claude,check-cx --kind done --all --timeout 15m +``` + +`--all` requires `--from` and blocks until every listed worker has produced a +matching event; timeout exits with code **124** and prints +`timeout: still waiting on ...` to stderr. + +## Soft Interrupt — `interrupt` + +`channel interrupt` is the cooperative redirect: it appends an `interrupt` +event (reason `"user"`) and, where the adapter supports it, issues a +provider-level turn interrupt with a replacement instruction. Use it when the +worker should drop its current turn and act on new input immediately, without +losing its session. + +```bash +echo "Stop refactoring the parser — switch to fixing the failing test in src/foo.ts" \ + | trellis channel interrupt impl-task --as dispatcher --to codex-impl --stdin +``` + +Flags: + +- `--as <agent>` **(required)** — caller identity. +- `--to <agent>` **(required)** — target worker. +- `--scope <project|global>` — channel scope. +- `--stdin` / `--text-file <path>` / `[text]` — replacement instruction body. + +The appended event has `kind: "interrupt"` — downstream `wait` / `messages` +filters can subscribe with `--kind interrupt` to react to redirections (e.g. +to log the rerouting, or to gate other workers behind a coordinator's +correction). + +For low-priority hints that should wait for the worker's next turn, send a +plain tagged message instead: + +```bash +echo "Check this when you reach the next turn." \ + | trellis channel send impl-task --as dispatcher --to codex-impl \ + --stdin --tag question +``` + +## Hard Interrupt — `kill` + `--resume` + +Use `kill` when the worker must stop **now** (e.g. runaway loop, bad +instructions already in flight, or `interrupt` is not honored by the +adapter). The supervisor escalates SIGTERM → 8 s grace → SIGKILL; the CLI +writes a `killed` event when SIGKILL is needed so the event log stays +truthful. + +```bash +trellis channel kill impl-task --as codex-impl +trellis channel spawn impl-task --as codex-impl --provider codex \ + --resume "$(cat ~/.trellis/channels/<bucket>/impl-task/worker.session-id)" + +echo "STOP — new instructions: ..." \ + | trellis channel send impl-task --as dispatcher --to codex-impl --stdin +``` + +`kill` flags: + +- `--as <agent>` **(required)** — names the worker (positional `<name>` is the channel). +- `--scope <project|global>`. +- `--force` — SIGKILL immediately (also kills the inner worker pid). + +Side effects: cleans `pid`, `worker-pid`, `config`, `spawnlock` sidecar +files; keeps `log`, `session-id`, `thread-id` for forensics and resume. + +When `interrupt` will not converge, kill + `--resume` is the guaranteed +redirection path. + +## Worker OOM Guard + +The OOM guard prevents orphaned/idle workers from accumulating and exhausting +host resources. It runs at every `spawn` and enforces two policies per +project bucket: + +- **Idle TTL** — sweep workers whose last activity is older than the + configured threshold (default `5m`; `0` disables). +- **Live-worker budget** — refuse the new spawn if more than N workers are + already alive in the same project bucket (default `6`; `0` disables). + +Precedence (highest first): + +1. CLI flags: `--idle-timeout`, `--max-live-workers` on `spawn`. +2. Environment variables: `TRELLIS_CHANNEL_WORKER_IDLE_TIMEOUT`, + `TRELLIS_CHANNEL_MAX_LIVE_WORKERS`. +3. `.trellis/config.yaml` under `channel.worker_guard`. +4. Built-in defaults (`5m`, `6`). + +Cleanup notices are written to stderr at spawn time so operators can see which +idle workers were swept and why a new spawn was rejected. The guard does not +touch ephemeral / `channel run` workers any differently — they are subject to +the same idle TTL and budget. + +To audit current state, list workers via `channel list` (the `WORKERS` +column) and inspect per-channel `pid` / `worker-pid` sidecar files under +`~/.trellis/channels/<bucket>/<channel>/`. + +## Worker Inbox APIs + +The inbox is the channel surface workers wake on. Routing is controlled by +two knobs: + +- **Inbox policy** (`spawn --inbox-policy`): + - `explicitOnly` (default) — worker only wakes on `send --to <worker>` or + `interrupt --to <worker>`. + - `broadcastAndExplicit` — also wakes on broadcasts (`send` with no `--to`). +- **Delivery mode** (`send --delivery-mode`): + - `appendOnly` — append the event regardless of worker state. + - `requireKnownWorker` — fail if no worker named in `--to` was ever spawned. + - `requireRunningWorker` — fail if the named worker is not currently alive. + +Stricter delivery modes prevent silent message loss when callers expect a +running peer. + +Inbox-relevant subcommands: + +- `send <channel> [text]` — append a `message` event. + - `--as <agent>` **(required)** — author. + - `--to <agents>` — CSV; one → string, many → array; broadcast if omitted. + - `--stdin` / `--text-file <path>` / `[text]` — body source. + - `--delivery-mode <appendOnly|requireKnownWorker|requireRunningWorker>`. +- `interrupt <channel> [text]` — soft-interrupt redirect (see above). +- `wait <channel>` — block until matching events arrive. + - `--as <agent>` **(required)** — `self` for filter context. + - `--from <agents>` — CSV authors. + - `--kind <kind[,kind...]>` — CSV (OR semantics); supports `interrupt`, + `done`, `progress`, etc. + - `--to <target>` — defaults to own agent (broadcast + explicit-to-me). + - `--include-progress` — also wake on progress events. + - `--all` — require every `--from` agent to match (timeout → exit **124**). + - `--timeout <duration>` — `30s` / `2m` / `1h` / `1000ms`. +- `messages <channel>` — view / filter / follow the event stream. + - `--follow` to tail, `--kind` / `--from` / `--to` to filter, `--raw` for + JSON-per-line, `--no-progress` to hide progress noise. + +A typical dispatcher loop: + +```bash +# 1. Wake the worker. +echo "Run the failing test and report." \ + | trellis channel send impl-task --as dispatcher --to codex-impl --stdin \ + --delivery-mode requireRunningWorker + +# 2. Block until it finishes. +trellis channel wait impl-task --as dispatcher \ + --from codex-impl --kind done,error --timeout 30m + +# 3. Read the final answer. +trellis channel messages impl-task --from codex-impl --last 1 --raw +``` + +All event-emitting subcommands (`send`, `interrupt`, `post`, `context add` / +`delete`, `title set` / `clear`, `thread rename`) print the appended event as +a single JSON line on stdout, making the inbox layer easy to script against. diff --git a/.agents/skills/trellis-channel/references/workflows.md b/.agents/skills/trellis-channel/references/workflows.md new file mode 100644 index 0000000..3319764 --- /dev/null +++ b/.agents/skills/trellis-channel/references/workflows.md @@ -0,0 +1,128 @@ +# Workflows + +Use these patterns by intent. Prefer durable channels for multi-round work and +`channel run` for one-shot questions. + +## Pattern A: Multi-round Brainstorm + +Use when the user says "和 codex/claude 讨论一下", "brainstorm", or "拉一个 agent +进来一起看". + +```bash +trellis channel create brainstorm-storage-layer --by main \ + --task .trellis/tasks/05-XX-storage-adapter + +trellis channel spawn brainstorm-storage-layer \ + --agent architect --provider codex \ + --file .trellis/tasks/05-XX-storage-adapter/prd.md \ + --file .trellis/tasks/05-XX-storage-adapter/design.md \ + --as cx-arch --timeout 30m + +trellis channel send brainstorm-storage-layer \ + --as main --to cx-arch --text-file /tmp/brainstorm-r1.md + +trellis channel wait brainstorm-storage-layer \ + --as main --kind done --from cx-arch --timeout 10m +``` + +Do not stop after one answer. Read the answer, identify vague areas, send a +new probe, and repeat until the result is executable. + +Minimum round structure: + +1. Direction split: should this live in an existing mechanism or a new one? +2. MVP boundary: v1, v2, and what would force v2 back into v1. +3. Data contract: events, schema, metadata, state source of truth, compatibility. +4. CLI / UX contract: command names, flags, errors, defaults, ambiguity. +5. Cross-layer risk and tests: shared helpers, drift points, release-blocking tests. + +Optional rounds: + +- Operations: logs, debugging, stuck workers, kill/restart, recovery. +- Migration/release: breaking status, manifest, changelog, docs-site. +- Opposition review: ask the peer agent to argue against the current plan. + +Every probe should request concrete file paths, commands, schema, rejected +alternatives, and release-blocking issues. Reject hedging when a decision is +needed. + +## Pattern B: Implement / Check Agent + +Use when the user asks to dispatch implementation or review work. + +```bash +TASK=.trellis/tasks/05-12-foo +trellis channel create cr-foo --task "$TASK" --by main + +trellis channel spawn cr-foo \ + --agent check \ + --jsonl "$TASK/check.jsonl" \ + --file "$TASK/prd.md" \ + --file "$TASK/design.md" \ + --file "$TASK/implement.md" \ + --cwd "$PWD" --timeout 15m + +trellis channel send cr-foo --as main --to check --text-file /tmp/cr-brief.md +trellis channel wait cr-foo --as main --kind done --from check --timeout 15m +trellis channel messages cr-foo --kind message --from check --tag final_answer +``` + +For implement work, use `--agent implement` and send an implementation brief. +For check work, include the exact diff scope, relevant specs, and validation +already run. + +## Pattern C: Parallel Reviewers + +Use one channel and distinct worker names. + +```bash +trellis channel create cr-feature --by main --ephemeral + +trellis channel spawn cr-feature --agent check \ + --jsonl "$TASK/check.jsonl" --file "$TASK/prd.md" --file "$TASK/design.md" \ + --timeout 15m + +trellis channel spawn cr-feature --agent check --provider codex --as check-cx \ + --jsonl "$TASK/check.jsonl" --file "$TASK/prd.md" --file "$TASK/design.md" \ + --timeout 15m + +trellis channel send cr-feature --as main --to check --text-file /tmp/cr-brief.md +trellis channel send cr-feature --as main --to check-cx --text-file /tmp/cr-brief.md +trellis channel wait cr-feature --as main --kind done --from check,check-cx --all --timeout 15m +``` + +`--all` means every listed worker must emit a matching event. + +## Pattern D: One-shot Worker + +```bash +trellis channel run --provider codex --message "say hi in 3 words" --timeout 1m +trellis channel run --agent plan --message-file /tmp/plan-question.md --timeout 10m +``` + +On success, `run` removes the ephemeral channel. On error/timeout/killed, it +keeps the channel and prints the path for inspection. + +## Pattern E: Forum Channel + +Use for issue forums, topic-style feedback, release todos, agent findings, and +internal changelogs. Read `forum.md` for the full model. + +## Pattern F: Take Over Existing Thread + +If the user gives a forum/thread name, restore context yourself: + +```bash +trellis channel forum <board> --scope global +trellis channel thread <board> <thread> --scope global --raw +trellis channel context list <board> --scope global --thread <thread> +trellis channel messages <board> --scope global --raw --thread <thread> +``` + +Output a constraint summary, not a transcript dump: + +- user-level problem +- context files that affect this repo +- current-version versus future-version requirements +- whether current code/design satisfies it +- next action or comment to append diff --git a/.agents/skills/trellis-check/SKILL.md b/.agents/skills/trellis-check/SKILL.md new file mode 100644 index 0000000..c695abd --- /dev/null +++ b/.agents/skills/trellis-check/SKILL.md @@ -0,0 +1,98 @@ +--- +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 Task Artifacts and Applicable Specs + +Read the current task artifacts in order: + +- `prd.md` +- `design.md` if present +- `implement.md` if present + +```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/<package>/<layer>/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/.agents/skills/trellis-continue/SKILL.md b/.agents/skills/trellis-continue/SKILL.md new file mode 100644 index 0000000..4aceae7 --- /dev/null +++ b/.agents/skills/trellis-continue/SKILL.md @@ -0,0 +1,61 @@ +--- +name: trellis-continue +description: "Resume work on the current task. Loads the workflow Phase Index, figures out which phase/step to pick up at, then pulls the step-level detail via get_context.py --mode phase. Use when coming back to an in-progress task and you need to know what to do next." +--- + +# 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. This command replaces the user needing to remember the Trellis flow; it does not itself approve implementation. + +- `status=planning` + no `prd.md` → **1.1** (load `trellis-brainstorm`) +- `status=planning` + `prd.md` only → decide whether the task is lightweight or complex. Lightweight can move to **1.4** review; complex returns to **1.1** to add `design.md` + `implement.md`. +- `status=planning` + complex artifacts complete + sub-agent jsonl not curated (only the seed `_example` row) → **1.3** +- `status=planning` + required artifacts complete + required jsonl curated or inline mode → **1.4** (ask for start review; only run `task.py start` after user confirms) +- `status=in_progress` + implementation not started → **2.1** +- `status=in_progress` + implementation done, not yet checked → **2.2** +- `status=in_progress` + check passed → **3.3** (spec update) → **3.4** (commit) +- `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 required output exists. `prd.md` alone can be enough only for lightweight tasks; complex tasks also need `design.md` and `implement.md`. +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 <X.X> --platform codex +``` + +Follow the loaded instructions. After each `[required]` step completes, move to the next. + +--- + +## Reference + +Full workflow and detailed phase steps live in `.trellis/workflow.md`. This command is only an entry point — the canonical guidance is there. diff --git a/.agents/skills/trellis-finish-work/SKILL.md b/.agents/skills/trellis-finish-work/SKILL.md new file mode 100644 index 0000000..5caebb5 --- /dev/null +++ b/.agents/skills/trellis-finish-work/SKILL.md @@ -0,0 +1,71 @@ +--- +name: trellis-finish-work +description: "Wrap up the current session: verify quality gate passed, remind user to commit, archive completed tasks, and record session progress to the developer journal. Use when done coding and ready to end the session." +--- + +# 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: `<list>`. Return to workflow Phase 3.4 to commit them before running ``finish-work` (Trellis command)`." + + 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: `<list>`." +- **Genuinely unsure** — ask the user once: "Are `<list>` 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 <task-name> +``` + +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: `<work commits from 3.4>` → `chore(task): archive ...` (one or more) → `chore: record journal`. diff --git a/.agents/skills/trellis-meta/SKILL.md b/.agents/skills/trellis-meta/SKILL.md new file mode 100644 index 0000000..0ffe593 --- /dev/null +++ b/.agents/skills/trellis-meta/SKILL.md @@ -0,0 +1,85 @@ +--- +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, workflows, the channel runtime (trellis channel), bundled runtime agents under .trellis/agents/, selectable workflow templates, registry-backed spec refresh, cross-session memory (trellis mem) generated by trellis init, or AI-facing bundled skills (trellis-channel, trellis-session-insight, trellis-spec-bootstrap) and bundled-skill auto-dispatch flow." +--- + +# 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. + +Trellis v0.6 adds three architectural surfaces on top of the pre-v0.6 workflow / persistence / platform model. First, a multi-agent collaboration runtime: `trellis channel` coordinates multiple AI worker processes through project-scoped JSONL event logs at `~/.trellis/channels/<project>/<channel>/events.jsonl`, with worker OOM guard, forum/thread channels, durable idempotency keys, and bundled `.trellis/agents/{check,implement}.md` runtime definitions. Second, cross-session memory: `trellis mem list | search | context | extract | projects` reads raw Claude Code, Codex, and Pi Agent JSONL already on disk, slices by `--phase brainstorm|implement|all`, and never uploads anything. Third, a dual-package npm release: `@mindfoldhq/trellis` (CLI) and `@mindfoldhq/trellis-core` (SDK with `/channel`, `/task`, `/mem`, `/testing` subpaths) ship in lockstep on one version. Treat these as first-class customization surfaces alongside the per-platform integration files. + +The default operating scope is local files in the user project: + +- `.trellis/`: workflow, config, tasks, spec, workspace, scripts, bundled runtime agents, and runtime state. +- Platform directories: `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, `.kiro/`, `.gemini/`, `.qoder/`, `.codebuddy/`, `.github/`, `.factory/`, `.pi/`, `.reasonix/`, `.kilocode/`, `.agent/`, `.devin/`, `.kimi-code/`, and similar directories. Pi additionally exposes a native `trellis_subagent` tool with `single` / `parallel` / `chain` dispatch modes, throttled progress cards, and `isTrellisAgent()` validation on top of the file layout. Reasonix stores both workflow skills and subagent skills as `.reasonix/skills/<name>/SKILL.md`; subagent skills carry `runAs: subagent` frontmatter. Kimi Code keeps workflow skills in the shared `.agents/skills/` layer and delivers commands plus agent prompts as `.kimi-code/skills/<name>/SKILL.md`. +- Shared skill layer: `.agents/skills/`. +- User-owned channel store outside the project tree: `~/.trellis/channels/<project>/<channel>/events.jsonl`. +- Raw platform conversation logs queryable via `trellis mem`: `~/.claude/projects/`, `~/.codex/sessions/`, and `~/.pi/agent/sessions/` (OpenCode adapter degraded for the v0.6 line). + +Do not assume the user has the Trellis source repository. Do not default to modifying the global npm install directory or `node_modules` — both `@mindfoldhq/trellis` and `@mindfoldhq/trellis-core` ship as published packages sharing one version and one git tag per release. + +## 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 request involves multi-agent dispatch or channel workers, read `references/local-architecture/multi-agent-channel.md` and the bundled `.trellis/agents/` files. +4. If the user wants to change behavior, read `references/customize-local/overview.md`, then open the specific customization topic. +5. 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 layered local Trellis architecture (workflow / persistence / platform / channel runtime) and customization principles. +- `references/local-architecture/generated-files.md`: Files generated by `trellis init` and their customization boundaries, including `.trellis/agents/`. +- `references/local-architecture/workflow.md`: Phases, routing, workflow-state blocks, and selectable workflow templates (`native`, `tdd`, `channel-driven-subagent-dispatch`, marketplace) in `.trellis/workflow.md`. +- `references/local-architecture/task-system.md`: Task directories, active task, JSONL context, parent/child task trees, and task runtime. +- `references/local-architecture/spec-system.md`: How `.trellis/spec/` is organized, injected, and refreshed from a `registry.spec` source. +- `references/local-architecture/workspace-memory.md`: `.trellis/workspace/` journals plus `trellis mem` cross-session recall and the `@mindfoldhq/trellis-core/mem` SDK. +- `references/local-architecture/context-injection.md`: Hooks, sub-agent preludes, and channel-runtime worker inbox routing. +- `references/local-architecture/multi-agent-channel.md`: `trellis channel` subcommands, project-scoped event store, forum/thread channels, worker OOM guard, durable idempotency, and bundled `.trellis/agents/` runtime agents. +- `references/local-architecture/bundled-skills.md`: Auto-dispatched bundled skills (`trellis-meta`, `trellis-spec-bootstrap`, `trellis-session-insight`) and how `getBundledSkillTemplates()` ships them to every platform skill root. + +### Platform Files + +- `references/platform-files/overview.md`: How shared `.trellis/` files relate to platform directories and the four platform integration modes (hook-driven, agent prelude, main-session workflow, channel runtime). +- `references/platform-files/platform-map.md`: Platform directories and paths for skills, agents, hooks, and extensions across all supported platforms including Reasonix and Pi's native `trellis_subagent` extension. +- `references/platform-files/hooks-and-settings.md`: How settings/config files, hooks, plugins, and extensions connect to Trellis; covers `channel.worker_guard.*` and `codex.dispatch_mode`. +- `references/platform-files/agents.md`: Per-platform `trellis-research` / `trellis-implement` / `trellis-check` sub-agent files plus bundled `.trellis/agents/{check,implement}.md` for the channel runtime. +- `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, workflow-state, and the selected workflow template. +- `references/customize-local/change-task-lifecycle.md`: Change task creation, status, archive behavior, parent/child links, archive slug collision handling, and lifecycle hooks. +- `references/customize-local/change-context-loading.md`: Change how tasks, specs, journals, hook context, channel inbox messages, and `trellis mem` recall are loaded. +- `references/customize-local/change-hooks.md`: Change platform hooks, settings, task lifecycle hooks (`hooks.after_*`), and shell session bridges. +- `references/customize-local/change-agents.md`: Change research, implement, and check agent behavior across platform sub-agents, bundled channel runtime agents, and the Codex `dispatch_mode` toggle. +- `references/customize-local/change-skills-or-commands.md`: Add or modify local skills, commands, prompts, and workflows; covers upstream bundled-skill auto-dispatch. +- `references/customize-local/change-spec-structure.md`: Adjust the project spec structure under `.trellis/spec/`, including registry-backed sources. +- `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; its initial content was selected from a workflow template (built-in `native`, `tdd`, `channel-driven-subagent-dispatch`, or a marketplace template) at `trellis init` time and can be re-selected via `trellis workflow --template <id>`. Missing `.trellis/agents/<name>.md` files referenced by the active template trigger a non-blocking stderr warning pointing at `trellis update`. +- `.trellis/config.yaml` is the project-level Trellis configuration entry point. It hosts task lifecycle hooks (`hooks.after_create` / `after_start` / `after_finish` / `after_archive`), journal shape (`session_commit_message` / `max_journal_lines` / `session_auto_commit`), channel worker guard (`channel.worker_guard.idle_timeout` / `max_live_workers`), Codex dispatch mode (`codex.dispatch_mode: inline | sub-agent`), and the spec registry block (`registry.spec.source` + `registry.spec.template`). +- `.trellis/spec/` stores the user's project-specific coding conventions and design constraints. When `registry.spec` is set, files are refreshed by `trellis update`; local edits surface as "modified by user" conflicts in `.trellis/.template-hashes.json`. +- `.trellis/tasks/` stores task PRDs, design notes, implement plans, research files, and JSONL context. Tasks form parent/child trees: `task.py create --parent <slug>`, `task.py add-subtask <parent> <child>`, `task.py remove-subtask <parent> <child>`, and `task.py list-context <task>`. `task.py create` rejects a slug already present in `.trellis/tasks/archive/**`. +- `.trellis/workspace/` stores **deliberately written** developer journals. Raw cross-session dialogue is **not** stored here — it lives on disk under `~/.claude/projects/`, `~/.codex/sessions/`, and `~/.pi/agent/sessions/` and is recovered via `trellis mem search|extract|context`. The bundled `trellis-session-insight` skill teaches when to reach for `mem`. +- `.trellis/agents/{check,implement}.md` are bundled, platform-agnostic channel runtime agent definitions loaded by `trellis channel spawn --agent <name>`. Editable; `trellis update` backfills missing ones. Editing the per-platform `trellis-implement.md` / `trellis-check.md` does **not** change channel-runtime worker behavior. +- `~/.trellis/channels/<project>/<channel>/events.jsonl` is the channel runtime event log per project per channel. User-owned, file-locked sequence numbering, durable `idempotencyKey` support; never under `.trellis/`. +- Bundled multi-file skills (`trellis-meta`, `trellis-spec-bootstrap`, `trellis-session-insight`, `trellis-channel`) are auto-dispatched to every platform skill root by `getBundledSkillTemplates()` in `packages/cli/src/templates/common/index.ts`. Dropping a new directory under `packages/cli/src/templates/common/bundled-skills/` (upstream) ships it to every platform on the next `trellis update`. +- Platform settings/config files decide which hooks, agents, skills, commands, prompts, and workflows actually run. Reasonix has no settings file — behavior is encoded inside skill frontmatter. +- `.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` or `node_modules/@mindfoldhq/trellis-core` to implement project needs; both packages ship in lockstep. +- Do not overwrite user-modified local files with default templates; check `.trellis/.template-hashes.json` first and prefer `.new` sidecar files over destructive overwrites. +- Do not put team-private project rules into any public bundled skill (`trellis-meta`, `trellis-spec-bootstrap`, `trellis-session-insight`, `trellis-channel`); put project rules in `.trellis/spec/`, a project-local skill, the current task, or the workspace journal — `trellis update` will overwrite anything inside a bundled skill directory. +- Do not hand-edit `~/.trellis/channels/<project>/<channel>/events.jsonl`; sequence numbers are assigned under a file lock and replay-safe writes go through the `trellis channel` CLI or the `@mindfoldhq/trellis-core/channel` SDK. +- Do not edit `.claude/agents/trellis-implement.md` (or any other per-platform sub-agent file) when the goal is to change channel runtime worker behavior — edit `.trellis/agents/<name>.md` instead. +- Do not describe removed or never-shipped mechanisms as current Trellis behavior; cross-check against the local `.trellis/config.yaml` and the installed CLI's `trellis --help` before claiming a knob exists. diff --git a/.agents/skills/trellis-meta/references/customize-local/add-project-local-conventions.md b/.agents/skills/trellis-meta/references/customize-local/add-project-local-conventions.md new file mode 100644 index 0000000..608aaa6 --- /dev/null +++ b/.agents/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/<layer>/` | +| Cross-layer thinking methods | `.trellis/spec/guides/` | +| AI capability for a project-specific flow | Platform-local skill | +| One-off task material | `.trellis/tasks/<task>/` | +| Session summary | `.trellis/workspace/<developer>/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 <task> implement ".trellis/spec/backend/error-handling.md" "Error handling conventions" +python3 ./.trellis/scripts/task.py add-context <task> 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/.agents/skills/trellis-meta/references/customize-local/change-agents.md b/.agents/skills/trellis-meta/references/customize-local/change-agents.md new file mode 100644 index 0000000..860c34c --- /dev/null +++ b/.agents/skills/trellis-meta/references/customize-local/change-agents.md @@ -0,0 +1,56 @@ +# 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` | +| Reasonix | `.reasonix/skills/trellis-*/SKILL.md` (subagent frontmatter) | +| ZCode | `.zcode/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/.agents/skills/trellis-meta/references/customize-local/change-context-loading.md b/.agents/skills/trellis-meta/references/customize-local/change-context-loading.md new file mode 100644 index 0000000..002a259 --- /dev/null +++ b/.agents/skills/trellis-meta/references/customize-local/change-context-loading.md @@ -0,0 +1,84 @@ +# 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/<task>/prd.md` | Current task requirements. | +| `.trellis/tasks/<task>/design.md` | Complex task technical design. | +| `.trellis/tasks/<task>/implement.md` | Complex task execution plan. | +| `.trellis/tasks/<task>/implement.jsonl` | Spec/research to read before implementation. | +| `.trellis/tasks/<task>/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. the corresponding JSONL +3. spec/research referenced by the JSONL +4. `prd.md` +5. `design.md` if present +6. `implement.md` if present + +## Troubleshooting Order + +```bash +python3 ./.trellis/scripts/task.py current --source +python3 ./.trellis/scripts/task.py list-context <task> +python3 ./.trellis/scripts/task.py validate <task> +python3 ./.trellis/scripts/get_context.py --mode packages +``` + +Confirm the task and JSONL are correct before editing hooks/agents. diff --git a/.agents/skills/trellis-meta/references/customize-local/change-hooks.md b/.agents/skills/trellis-meta/references/customize-local/change-hooks.md new file mode 100644 index 0000000..79aa5c5 --- /dev/null +++ b/.agents/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`, `.trae/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 <task> +``` + +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/.agents/skills/trellis-meta/references/customize-local/change-skills-or-commands.md b/.agents/skills/trellis-meta/references/customize-local/change-skills-or-commands.md new file mode 100644 index 0000000..9e25eff --- /dev/null +++ b/.agents/skills/trellis-meta/references/customize-local/change-skills-or-commands.md @@ -0,0 +1,123 @@ +# 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. + +Before editing, classify the skill you are about to touch: + +- **Bundled upstream skill** — `trellis-meta`, `trellis-spec-bootstrap`, `trellis-session-insight`, `trellis-channel`. Source of truth lives in the Trellis CLI repo under `packages/cli/src/templates/common/bundled-skills/<name>/`; auto-dispatched to every platform's skill root by `getBundledSkillTemplates()` on `trellis init` / `trellis update`. Local edits here are tracked by `.trellis/.template-hashes.json` and will be flagged on the next update. +- **Project-local skill** — anything else under `.{platform}/skills/`. Owned by the user; not refreshed by `trellis update`. + +The remainder of this file uses "skill" for the local file; the override and conflict rules differ between the two cases. + +## 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/` +5. `.trellis/.template-hashes.json` — confirms whether the skill you are about to edit is upstream-owned (entry present) or project-local (entry absent) + +## 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 — never a bundled skill directory. | +| Tweak a bundled skill (`trellis-meta` et al.) for the user's own project | Create a project-local sibling skill (different name) that overrides intent, or edit `.trellis/spec/`. Edits inside the bundled skill directory survive only until the next `trellis update` and will need a "keep" choice each time. | +| Contribute the change back upstream | Edit `packages/cli/src/templates/common/bundled-skills/<name>/` in the Trellis CLI repo, not the deployed copy. | +| Change Trellis flow semantics | Synchronize `.trellis/workflow.md`. | + +## Modify A Skill + +A skill is usually: + +```text +<skill-name>/ +├── 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. + +### Bundled vs. Project-Local + +The same directory shape is used by two very different ownership models: + +| Aspect | Bundled (`trellis-meta`, `trellis-spec-bootstrap`, `trellis-session-insight`, `trellis-channel`) | Project-local | +| --- | --- | --- | +| Source of truth | `packages/cli/src/templates/common/bundled-skills/<name>/` in Trellis CLI repo | Inside the user project itself | +| Dispatch | Auto-dispatched to every platform skill root by `getBundledSkillTemplates()` (`packages/cli/src/templates/common/index.ts`) on `trellis init` / `trellis update` | Created by the user (or another skill) and never moved | +| Hash tracking | Every file recorded in `.trellis/.template-hashes.json`; conflict prompt on update | Not tracked | +| Editing locally | Allowed but will be marked "modified by user" on next update | Free editing | +| The right way to customize | Add a *new* project-local skill with a *different* name that supplements (or supersedes) the bundled one | Edit the file directly | + +If the goal is "make my project's AI behave differently when discussing release notes," the answer is almost always a project-local skill, not surgery on `trellis-meta/`. + +## 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/` | +| Gemini CLI | `.agents/skills/`, `.gemini/commands/` | +| Kiro | `.kiro/skills/` | +| Qoder | `.qoder/skills/`, `.qoder/commands/` | +| CodeBuddy | `.codebuddy/skills/`, `.codebuddy/commands/` | +| GitHub Copilot | `.github/skills/`, `.github/prompts/` | +| Factory Droid | `.factory/skills/`, `.factory/commands/` | +| Pi Agent | `.agents/skills/` | +| Reasonix | `.reasonix/skills/` (no separate commands dir; slash commands built into the platform) | +| ZCode | `.zcode/skills/`, `.zcode/commands/` | +| Kilo / Antigravity / Devin | workflows + skills | + +Every directory above is a deploy target for the four bundled skills. Each platform receives a full copy on `trellis init` and refresh on `trellis update`; nothing has to be wired by hand. + +## Add A Project-Local Skill + +If the user wants to document team-private customizations, create a project-local skill — never put project-private content into a bundled skill directory, since `trellis update` will overwrite it. + +```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 (Codex, Gemini CLI). + +Pick a name that does **not** collide with the bundled set: + +- `trellis-meta` +- `trellis-spec-bootstrap` +- `trellis-session-insight` +- `trellis-channel` + +A reused name causes `getBundledSkillTemplates()` to overwrite the project-local copy on the next update. A common convention is to prefix the project name: `acme-trellis-deploy`, `acme-trellis-onboarding`. + +## 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/`. +- Do not hand-edit files inside `trellis-meta/`, `trellis-spec-bootstrap/`, `trellis-session-insight/`, or `trellis-channel/` under any `.{platform}/skills/` directory expecting the change to persist — they are bundled and refreshed by `trellis update`. Either contribute upstream or add a project-local skill that complements them. +- After `trellis update` reports a "modified by you" conflict on a bundled skill file, choose **keep** only if you accept maintaining the divergence by hand; otherwise accept the overwrite and re-apply the intent as a project-local skill. diff --git a/.agents/skills/trellis-meta/references/customize-local/change-spec-structure.md b/.agents/skills/trellis-meta/references/customize-local/change-spec-structure.md new file mode 100644 index 0000000..ee9a176 --- /dev/null +++ b/.agents/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` planning artifact guidance 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/<layer>/` or `.trellis/spec/<package>/<layer>/` | +| 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 <task> implement ".trellis/spec/webapp/security/index.md" "Security conventions" +python3 ./.trellis/scripts/task.py add-context <task> 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/.agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md b/.agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md new file mode 100644 index 0000000..a7a340f --- /dev/null +++ b/.agents/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>/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/<context_key>.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 <dir>` 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/.agents/skills/trellis-meta/references/customize-local/change-workflow.md b/.agents/skills/trellis-meta/references/customize-local/change-workflow.md new file mode 100644 index 0000000..337c985 --- /dev/null +++ b/.agents/skills/trellis-meta/references/customize-local/change-workflow.md @@ -0,0 +1,65 @@ +# 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` | lightweight task with `prd.md` complete | ask for start review, then run `task.py start` | +| `planning` | complex task missing `design.md` or `implement.md` | complete missing planning artifacts | +| `planning` | complex task has `prd.md`, `design.md`, and `implement.md` | ask for start review, then 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.3 (spec update) → 3.4 (commit) | +| `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/.agents/skills/trellis-meta/references/customize-local/overview.md b/.agents/skills/trellis-meta/references/customize-local/overview.md new file mode 100644 index 0000000..b75d208 --- /dev/null +++ b/.agents/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/`, `.zcode/`. +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/<task>/` | +| Project specs | `.trellis/spec/` | +| Runtime scripts | `.trellis/scripts/` | +| Platform integration | `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, `.zcode/`, 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/.agents/skills/trellis-meta/references/local-architecture/bundled-skills.md b/.agents/skills/trellis-meta/references/local-architecture/bundled-skills.md new file mode 100644 index 0000000..ae28870 --- /dev/null +++ b/.agents/skills/trellis-meta/references/local-architecture/bundled-skills.md @@ -0,0 +1,147 @@ +# Bundled Skills + +"Bundled skills" are multi-file built-in skills shipped inside the Trellis CLI npm package. Unlike marketplace skills (which a user installs separately into their own `.claude/skills/` or other platform skill root), bundled skills are written automatically into every supported platform's skill root by `trellis init` and kept in sync by `trellis update`. They are part of Trellis itself, not third-party content. + +A bundled skill is a directory under `packages/cli/src/templates/common/bundled-skills/<skill>/` that already contains its own `SKILL.md` (with YAML frontmatter) plus optional `references/`, assets, or other supporting files. Trellis copies the whole directory tree as-is into each platform's skill root, so references stay lazy-loadable instead of being flattened into one oversized `SKILL.md`. + +## What Counts As Bundled (vs. Adjacent Concepts) + +| Source path | Type | How it ships | +| --- | --- | --- | +| `templates/common/bundled-skills/<name>/` | Bundled skill (multi-file) | Whole directory copied to every platform skill root | +| `templates/common/skills/<name>.md` | Single-file workflow skill | Wrapped with frontmatter, written as `<root>/<name>/SKILL.md` | +| `templates/common/commands/<name>.md` | Slash command / prompt | Written to each platform's command directory (`.claude/commands/trellis/`, `.cursor/commands/trellis-*.md`, `.gemini/commands/trellis/*.toml`, etc.) | +| `templates/<platform>/skills/` | Platform-specific skill | Written only into that platform's directory (e.g. `.codex/skills/`) | +| User skills under `.claude/skills/<my-skill>/` etc. | Marketplace or user-authored | Not managed by Trellis at all | + +The Trellis CLI never touches anything that is not produced by one of its own template loaders. Anything a user drops into a platform skill root by hand is left alone. + +## Current Bundled Skills (v0.6.0) + +The set is discovered at runtime by listing directories under `templates/common/bundled-skills/`: + +| Skill | Purpose | +| --- | --- | +| `trellis-meta` | This skill. Explains the local Trellis architecture and customization entry points to an AI working inside a user project. | +| `trellis-session-insight` | Wraps the `trellis mem` CLI so an AI knows when and how to reach into past Claude Code / Codex / Pi Agent conversation logs. | +| `trellis-spec-bootstrap` | Platform-neutral workflow for creating or refreshing `.trellis/spec/` from the real codebase (with optional GitNexus / ABCoder integration). | +| `trellis-channel` | Capability skill teaching an AI when to reach for `trellis channel` for multi-agent collaboration, forum/thread persistent boards, and dispatcher-wait patterns. | + +The list is discovered at runtime, so adding a new directory under `bundled-skills/` is the only step required to register a new skill (see "Adding a New Bundled Skill" below). + +## Where Bundled Skills Land Per Platform + +Each platform configurator calls `writeSkills(<root>, <workflowSkills>, resolveBundledSkills(ctx))` during `trellis init`. `resolveBundledSkills` reads every directory under `templates/common/bundled-skills/`, resolves placeholders, and returns a flat list of `{relativePath, content}` entries. `writeSkills` then mirrors them under the platform's skill root. + +| Platform | Bundled skill root | Notes | +| --- | --- | --- | +| Claude Code | `.claude/skills/<skill>/` | `configureClaude` | +| Cursor | `.cursor/skills/<skill>/` | `configureCursor` | +| Codex | `.agents/skills/<skill>/` | `configureCodex` writes the shared `.agents/skills/` root, which Gemini CLI 0.40+ also reads | +| Gemini CLI | `.agents/skills/<skill>/` | Same shared root as Codex; the two configurators are required to produce byte-identical output | +| Kiro | `.kiro/skills/<skill>/` | `configureKiro` (skills-based platform — no commands) | +| Qoder | `.qoder/skills/<skill>/` | `configureQoder` | +| Codebuddy | `.codebuddy/skills/<skill>/` | `configureCodebuddy` | +| Copilot | `.github/skills/<skill>/` | `configureCopilot` | +| Droid | `.factory/skills/<skill>/` | `configureDroid` | +| Antigravity | `.agent/skills/<skill>/` | `configureAntigravity` | +| Devin | `.devin/skills/<skill>/` | `configureDevin` | +| Kilo | `.kilocode/skills/<skill>/` | `configureKilo` | +| ZCode | `.zcode/skills/<skill>/` | `configureZcode` | +| OpenCode | (handled by `collectOpenCodeTemplates`) | Uses the same `resolveBundledSkills(ctx)` output | +| Pi, Reasonix | (their own collectors) | Same `resolveBundledSkills(ctx)` output | + +Two paths exercise the same data: + +1. `configureX(cwd)` writes files during `trellis init`. +2. `collectPlatformTemplates(platformId)` (in `configurators/index.ts`) returns a `Map<filePath, content>` that `trellis update` uses to detect drift and to populate `.trellis/.template-hashes.json`. Both must produce byte-identical output, so they both call `resolveBundledSkills(ctx)` and `collectSkillTemplates(root, …, resolveBundledSkills(ctx))`. + +## Dispatch Wiring (Code Path) + +The mechanism that auto-dispatches bundled skills to platform skill roots lives in two files: + +1. `packages/cli/src/templates/common/index.ts` + - `listDirectories("bundled-skills")` enumerates the on-disk skills. + - `listBundledSkillFiles(skillDir)` walks each skill's directory recursively and returns `{relativePath, content}` for every file. + - `getBundledSkillTemplates()` returns the cached `CommonBundledSkill[]`. + +2. `packages/cli/src/configurators/shared.ts` + - `resolveBundledSkills(ctx)` flattens that list into `ResolvedSkillFile[]` with `<skill>/<relativePath>` paths and resolved placeholders. + - `writeSkills(skillsRoot, workflowSkills, bundledSkills)` writes both workflow skills and bundled skill files under `skillsRoot`. + - `collectSkillTemplates(skillsRoot, workflowSkills, bundledSkills)` returns the same shape as a `Map<filePath, content>` for the update / hash pipeline. + +Every platform configurator that supports skills imports both helpers (see `claude.ts`, `cursor.ts`, `codex.ts`, `gemini.ts`, `kiro.ts`, `qoder.ts`, `codebuddy.ts`, `copilot.ts`, `droid.ts`, `antigravity.ts`, `devin.ts`, `kilo.ts`). The `index.ts` `PLATFORM_FUNCTIONS` registry also calls `resolveBundledSkills(ctx)` inside each `collectTemplates` closure so `trellis update` tracking stays consistent. + +## Adding a New Bundled Skill + +The shape and dispatch wiring are already generic, so adding a skill requires only file changes plus distribution verification. + +1. **Create the directory tree.** + + ``` + packages/cli/src/templates/common/bundled-skills/<my-skill>/ + SKILL.md # YAML frontmatter + body + references/ # optional + <topic>.md + assets/ # optional (anything readable as utf-8) + ``` + +2. **Write a valid `SKILL.md` header.** The frontmatter must include at minimum: + + ```yaml + --- + name: <my-skill> + description: "When the AI should reach for this skill. Triggering phrases go here." + --- + ``` + + The `description` is what each platform's auto-trigger mechanism matches against, so it should describe the user-intent triggers, not the skill's internals. + +3. **Use placeholders where appropriate.** Bundled skill content runs through `resolvePlaceholders(file.content, ctx)`. Any `{{platform_name}}`, `{{python_cmd}}`, etc. token supported by `resolvePlaceholders` will be substituted per platform. + +4. **No dispatch wiring is required.** `listDirectories("bundled-skills")` discovers the new directory automatically, so all platforms receive it on the next `trellis init` or `trellis update`. + +5. **Verify the distribution path** before shipping. Skipping any of these steps has historically caused features to be documented as bundled while the published npm tarball was missing the files: + + - Source files exist on the branch being tagged. + - `pnpm --filter @mindfoldhq/trellis build` copies the asset into `dist/templates/common/bundled-skills/<skill>/`. + - `npm pack --dry-run --json` includes the expected `dist/**` paths. + - In a fresh temp project, `trellis init` writes `.claude/skills/<skill>/SKILL.md`, `.agents/skills/<skill>/SKILL.md`, `.zcode/skills/<skill>/SKILL.md`, etc. + - `.trellis/.template-hashes.json` lists the generated files. + - `trellis update --dry-run` in that temp project reports "Already up to date!". + +6. **Add a migration manifest entry** if the skill is added in a release that other projects will upgrade into. Without an explicit manifest entry the file will land via the standard "missing file" branch of `trellis update`, but a manifest makes the change visible in the changelog. + +## Overriding a Bundled Skill Locally + +There is no formal "project-local skill" mechanism (e.g. `.trellis/skills/`). Bundled skills are platform-rooted, so any override is platform-rooted too. + +The supported pattern relies on the existing template-hash diff in `trellis update`: + +1. Edit the local file directly. Example: `.claude/skills/trellis-meta/SKILL.md`. +2. The file's hash now diverges from the entry in `.trellis/.template-hashes.json`. +3. The next `trellis update` detects the user modification and leaves the file untouched (Trellis never overwrites user-modified files without an explicit `--force`). + +Caveats: + +- The override only applies to the one platform whose directory you edited. To override the same skill across, for example, Claude Code and Codex, you must edit both `.claude/skills/<name>/` and `.agents/skills/<name>/`. +- A future `trellis update --force` will overwrite local edits. Keep the override under version control so it can be reapplied if needed. +- Marketplace skills installed under the same platform skill root with a different folder name (e.g. `.claude/skills/my-custom-meta/`) are untouched by Trellis and are the cleaner option when the goal is to add behavior, not to mutate the bundled skill. +- Team-private conventions belong in `.trellis/spec/` or in a separate marketplace-style local skill, not in modifications to `trellis-meta` itself. See `customize-local/add-project-local-conventions.md`. + +## Removing a Bundled Skill From a Project + +There is no per-project opt-out flag for bundled skills. Two options: + +1. **Delete the directory in each platform skill root.** `trellis update` will see the file missing, compare against `.template-hashes.json`, and treat the deletion the same as any other user modification — it will not silently re-create the directory unless `--force` is passed. + +2. **Pin a Trellis version that did not ship the skill.** The bundled-skill set is determined at build time, so installing an older release of the CLI is the only way to permanently exclude a skill that the current release ships. + +A third option — globally disabling all bundled skills — is not supported. The dispatch is unconditional in every configurator. Adding such a flag would require changing `PLATFORM_FUNCTIONS` in `configurators/index.ts` and every `configureX` function. + +## Operating Rules + +- Treat `templates/common/bundled-skills/` as the single source of truth for what bundled skills exist. Do not hand-maintain platform-by-platform skill lists. +- Do not add platform-specific logic inside a bundled `SKILL.md`. If a behavior is platform-specific, put it in `templates/<platform>/skills/` instead. +- Do not couple bundled skills to a specific CLI binary (e.g. `trellis mem`) without surfacing the dependency in the skill's description and references — users on older releases may not have the command. +- Do not store project-private content in a bundled skill. Bundled skills are public, shipped to every user; project rules belong in `.trellis/spec/` or a local skill. diff --git a/.agents/skills/trellis-meta/references/local-architecture/context-injection.md b/.agents/skills/trellis-meta/references/local-architecture/context-injection.md new file mode 100644 index 0000000..4a7517b --- /dev/null +++ b/.agents/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/<task>/prd.md`, `design.md`, `implement.md`, `research/` | Current task requirements, design, execution plan, 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 jsonl-referenced files plus `prd.md`, `design.md` if present, and `implement.md` if present before the agent starts. +2. **agent pull**: the agent definition instructs the agent to read the active task, jsonl context, and task artifacts after startup. + +In both modes, JSONL files in the task directory are the manifest for spec/research context. Task artifacts are read separately in this order: `prd.md` -> `design.md if present` -> `implement.md if present`. + +## 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 task artifacts/spec/research. diff --git a/.agents/skills/trellis-meta/references/local-architecture/generated-files.md b/.agents/skills/trellis-meta/references/local-architecture/generated-files.md new file mode 100644 index 0000000..b460224 --- /dev/null +++ b/.agents/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`, `.trae/hooks.json` | Tell the platform when to run hooks or plugins. | +| agents | `.claude/agents/`, `.codex/agents/`, `.kiro/agents/`, `.zcode/agents/` | Define agents such as `trellis-research`, `trellis-implement`, and `trellis-check`. | +| skills | `.claude/skills/`, `.agents/skills/`, `.qoder/skills/`, `.zcode/skills/` | Skills that auto-trigger or can be read by AI. | +| commands/prompts/workflows | `.cursor/commands/`, `.github/prompts/`, `.devin/workflows/`, `.zcode/commands/` | 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/.agents/skills/trellis-meta/references/local-architecture/multi-agent-channel.md b/.agents/skills/trellis-meta/references/local-architecture/multi-agent-channel.md new file mode 100644 index 0000000..6df61eb --- /dev/null +++ b/.agents/skills/trellis-meta/references/local-architecture/multi-agent-channel.md @@ -0,0 +1,69 @@ +# Local Multi-Agent Channel Runtime + +`trellis channel` is the local multi-agent collaboration runtime shipped with the Trellis CLI. It lets the main AI session spawn peer workers (Claude Code, Codex, or any agent definition under `.trellis/agents/`), exchange durable messages through an event log, and coordinate review or brainstorm loops without hand-stitching shell pipelines. + +This reference covers how channels are wired into the user project so an AI customizing the project knows what to edit. For runtime usage (commands, forum/thread patterns, worker spawn flags), defer to the bundled `trellis-channel` capability skill. + +## Local System Model + +The channel runtime spans three local surfaces: + +1. **Storage layer** in the user's home directory: durable event logs and worker state files. +2. **Agent definitions** inside the project at `.trellis/agents/`: platform-agnostic role cards consumed by `trellis channel spawn --agent <name>`. +3. **Project configuration** in `.trellis/config.yaml`: worker guard thresholds and other channel knobs. + +## Core Paths + +| Path | Purpose | +| --- | --- | +| `~/.trellis/channels/<project>/<channel>/events.jsonl` | Per-channel append-only event log. Sequence-locked, replay-safe. | +| `~/.trellis/channels/<project>/<channel>/<channel>.lock` | Channel-level write lock. | +| `~/.trellis/channels/<project>/<channel>/<worker>.spawnlock` | Per-worker spawn lock used by the OOM guard. | +| `~/.trellis/channels/<project>/<channel>/.seq` | Sequence sidecar for ordered event assignment. | +| `~/.trellis/channels/_global/<channel>/...` | Channels created with `--scope global`. The project bucket is replaced by a shared key. | +| `.trellis/agents/check.md` | Default Check Agent role definition consumed by `--agent check`. | +| `.trellis/agents/implement.md` | Default Implement Agent role definition consumed by `--agent implement`. | +| `.trellis/config.yaml` (`channel.*` block) | Worker guard thresholds and channel defaults. | + +The project bucket name is derived from the absolute project path (slashes flattened, non-alphanumerics replaced with `-`), matching Claude Code's `~/.claude/projects/<sanitized-cwd>/` convention. Override with `TRELLIS_CHANNEL_ROOT` (root directory) or `TRELLIS_CHANNEL_PROJECT` (bucket name) for testing or sandboxing. + +## When To Reach For The Channel Runtime + +Channels are heavier than a single Bash call or a one-shot sub-agent dispatch. Use them only when at least one of these conditions holds: + +- The work needs **two or more agents to converse** through more than one turn (cross-AI brainstorm, peer review, dispatcher + worker). +- A worker should run as a **peer process** that the main session can interrupt, watch progress on, or wait for asynchronously. +- The conversation must be **durable and inspectable** later (forum/thread channels, issue boards, decision trails). +- Multiple workers must **share an event log** so each can see what the others reported. + +Prefer cheaper primitives when: + +- A single-shot Bash command or single Agent tool call is enough -> do that directly. +- The user just needs a static review against a file -> read the file and reply inline. +- The need is "remember what we discussed last week" -> use `trellis mem` instead of a channel. + +## Customization Points + +| Need | Edit location | +| --- | --- | +| Change default channel worker idle timeout | `channel.worker_guard.idle_timeout` in `.trellis/config.yaml`. Accepts `5m`, `30s`, etc. Set `0` to disable idle cleanup. | +| Change live worker budget | `channel.worker_guard.max_live_workers` in `.trellis/config.yaml`. Set `0` to disable the spawn-time budget check. | +| Override worker guard per spawn | Pass `--idle-timeout` / `--max-live-workers` on `trellis channel spawn`, or set `TRELLIS_CHANNEL_WORKER_IDLE_TIMEOUT` / `TRELLIS_CHANNEL_MAX_LIVE_WORKERS` in the environment. | +| Change what the default Check or Implement worker does | Edit `.trellis/agents/check.md` or `.trellis/agents/implement.md`. These are platform-agnostic role cards; the channel runtime injects them when `--agent check|implement` is passed. | +| Add a new role card | Drop `<name>.md` into `.trellis/agents/`. `trellis channel spawn --agent <name>` will pick it up. | +| Relocate channel storage (CI sandbox, ephemeral runs) | Set `TRELLIS_CHANNEL_ROOT=/path/to/dir`. Channel events move with it; existing channels stay at the old root. | +| Switch storage scope | Pass `--scope project` (default) or `--scope global` on every channel subcommand. The bucket directory changes; nothing else does. | + +Precedence for the worker guard is: CLI flag > environment variable > `.trellis/config.yaml` > built-in default. Built-in defaults are `idle_timeout: 5m` and `max_live_workers: 6`. + +## Relationship To Other Local Layers + +- **Workflow layer**: workflows that use channel dispatch (such as `channel-driven-subagent-dispatch`) instruct the main agent to call `trellis channel spawn --agent check` or `--agent implement` instead of a platform sub-agent. If `.trellis/agents/check.md` or `implement.md` is missing, `trellis workflow --template <id>` prints a non-blocking warning at install time. Restore them with `trellis update` if they are deleted by accident. +- **Task layer**: channel workers do not own task state. The supervising main session passes the active task path through the worker inbox; the worker resolves task artifacts from disk. +- **Spec layer**: workers read `.trellis/spec/` the same way the main session does. Channel runtime does not bypass spec context loading. +- **Platform integration layer**: channel runtime is platform-neutral. It does not depend on `.claude/`, `.codex/`, or any other platform directory. The adapters that normalize provider output (Claude `stream-json`, Codex `app-server`) live inside the Trellis CLI binary, not in the project. +- **Platform sub-agent files vs. channel workers**: editing `.claude/agents/trellis-implement.md` (and its peers in other platform `.X/agents/` directories) does NOT change channel-runtime worker behavior — channel workers load `.trellis/agents/<name>.md`. The platform-specific agent files are for direct sub-agent dispatch from the main AI session, not for channel-spawned workers. See `platform-files/agents.md` for the per-platform agent surface, and the `trellis-meta/SKILL.md` rule that codifies this split. + +## Runtime Usage + +For command syntax, forum/thread patterns, worker handles, progress inspection, and the `--kind done` / `--kind turn_finished` dispatcher wait pattern, load the bundled `trellis-channel` skill (auto-installed under each platform's skills directory after `trellis init` / `trellis update`). This reference only covers the local file layout and customization knobs; it does not duplicate command syntax that may change between releases. diff --git a/.agents/skills/trellis-meta/references/local-architecture/overview.md b/.agents/skills/trellis-meta/references/local-architecture/overview.md new file mode 100644 index 0000000..e97cab8 --- /dev/null +++ b/.agents/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/`, `.devin/`, `.reasonix/`, `.zcode/`, 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/.agents/skills/trellis-meta/references/local-architecture/spec-system.md b/.agents/skills/trellis-meta/references/local-architecture/spec-system.md new file mode 100644 index 0000000..38fdf14 --- /dev/null +++ b/.agents/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, planning may write relevant specs into `implement.jsonl` / `check.jsonl` when the task needs spec or research context beyond the task artifacts: + +```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/<package>/<layer>/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/.agents/skills/trellis-meta/references/local-architecture/task-system.md b/.agents/skills/trellis-meta/references/local-architecture/task-system.md new file mode 100644 index 0000000..7133495 --- /dev/null +++ b/.agents/skills/trellis-meta/references/local-architecture/task-system.md @@ -0,0 +1,130 @@ +# 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 +│ ├── design.md +│ ├── implement.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, constraints, and acceptance criteria. Lightweight tasks may be PRD-only. | +| `design.md` | Technical design for complex tasks: boundaries, contracts, data flow, compatibility, tradeoffs. | +| `implement.md` | Execution plan for complex tasks: ordered checklist, validation commands, review gates, rollback points. | +| `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. | + +## Parent / Child Task Trees + +Parent/child task relationships are for work structure. A parent task groups related deliverables under one source requirement set; it is not a dependency scheduler and does not replace the child task's own planning artifacts. + +Use a parent task when a request has multiple independently verifiable deliverables. The parent owns: + +- Source requirements and user-facing scope. +- The map of child tasks and their responsibility boundaries. +- Cross-child acceptance criteria and final integration review. + +Use child tasks for deliverables that can move through planning, implementation, check, and archive independently. If one child depends on another, write that dependency in the child `prd.md` / `implement.md`; do not rely on tree position to imply ordering. + +Create new children with: + +```bash +python3 ./.trellis/scripts/task.py create "<child title>" --slug <child-slug> --parent <parent-dir> +``` + +Link or unlink existing tasks with: + +```bash +python3 ./.trellis/scripts/task.py add-subtask <parent-dir> <child-dir> +python3 ./.trellis/scripts/task.py remove-subtask <parent-dir> <child-dir> +``` + +`children` on the parent is a historical list. When a child is archived, Trellis keeps that child name in the parent so progress like `[2/3 done]` remains meaningful after completed children move to `archive/`. + +The AI should not treat phase numbers as task status. Task progress is mainly determined by `status`, artifact presence (`prd.md`, optional `design.md` / `implement.md`), whether JSONL context is configured for sub-agent mode, 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/<context-key>.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. They do not replace `implement.md`; `implement.md` is the human-readable execution plan. + +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 "<title>" --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 | Planning artifact guidance 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/.agents/skills/trellis-meta/references/local-architecture/workflow.md b/.agents/skills/trellis-meta/references/local-architecture/workflow.md new file mode 100644 index 0000000..f0659ff --- /dev/null +++ b/.agents/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/.agents/skills/trellis-meta/references/local-architecture/workspace-memory.md b/.agents/skills/trellis-meta/references/local-architecture/workspace-memory.md new file mode 100644 index 0000000..c2958f2 --- /dev/null +++ b/.agents/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/.agents/skills/trellis-meta/references/platform-files/agents.md b/.agents/skills/trellis-meta/references/platform-files/agents.md new file mode 100644 index 0000000..26f472a --- /dev/null +++ b/.agents/skills/trellis-meta/references/platform-files/agents.md @@ -0,0 +1,83 @@ +# 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`, optional `design.md` / `implement.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` | +| Reasonix | `.reasonix/skills/trellis-*/SKILL.md` (subagent frontmatter) | +| ZCode | `.zcode/agents/trellis-*.md` | +| Kimi Code | `.kimi-code/skills/trellis-*/SKILL.md` (agent prompts as skills, dispatched to the built-in `coder`; research needs its file-editing tools to persist findings) | + +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 Devin 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` +- `implement.jsonl` or `check.jsonl` +- spec/research files referenced by JSONL +- current task `prd.md` +- `design.md` if present +- `implement.md` if present + +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, read jsonl/spec context, then read `prd.md`, `design.md` if present, and `implement.md` if present. +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/.agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md b/.agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md new file mode 100644 index 0000000..a2ff389 --- /dev/null +++ b/.agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md @@ -0,0 +1,72 @@ +# 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/` | +| Trae IDE | `.trae/hooks.json` | + +Reasonix is a pull-based platform whose agent files contain prelude instructions to read context after startup. ZCode uses `.zcode/config.json` with shared hooks, including PreToolUse for sub-agent prompt injection. Kimi Code is likewise pull-based and has no project-level settings/hooks file Trellis writes (hooks live only in the user-level `~/.kimi-code/config.toml`), so its agent prompts ship as skills with the same prelude. + +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/.agents/skills/trellis-meta/references/platform-files/overview.md b/.agents/skills/trellis-meta/references/platform-files/overview.md new file mode 100644 index 0000000..f9b72b0 --- /dev/null +++ b/.agents/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/`, `.snow/`, `.codex/`, `.cursor/`, `.opencode/`, `.kiro/`, `.gemini/`, `.qoder/`, `.codebuddy/`, `.github/`, `.factory/`, `.pi/`, `.trae/`, `.kilocode/`, `.agent/`, `.devin/`, `.reasonix/`, `.zcode/`, `.kimi-code/`, 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`, `.trae/hooks.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/`, `.zcode/agents/` | Define `trellis-research`, `trellis-implement`, and `trellis-check`. | +| skills | `.claude/skills/`, `.agents/skills/`, `.qoder/skills/`, `.zcode/skills/` | Capability descriptions that auto-trigger or can be read on demand. | +| commands/prompts/workflows | `.cursor/commands/`, `.github/prompts/`, `.devin/workflows/`, `.zcode/commands/` | 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/.agents/skills/trellis-meta/references/platform-files/platform-map.md b/.agents/skills/trellis-meta/references/platform-files/platform-map.md new file mode 100644 index 0000000..73b113a --- /dev/null +++ b/.agents/skills/trellis-meta/references/platform-files/platform-map.md @@ -0,0 +1,111 @@ +# 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/` | +| Devin | `--devin` | `.devin/` | `.devin/skills/` | Usually none | `.devin/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/` | `.agents/skills/` | `.pi/agents/` | `.pi/extensions/trellis/` (native `trellis_subagent` tool) + `.pi/settings.json` | +| Trae IDE | `--trae` | `.trae/` | `.trae/skills/` | `.trae/agents/` | `.trae/hooks/` + `.trae/hooks.json` | +| Reasonix | `--reasonix` | `.reasonix/` | `.reasonix/skills/` | None — sub-agents are skills with `runAs: subagent` frontmatter | None | +| ZCode | `--zcode` | `.zcode/` | `.zcode/skills/` | `.zcode/agents/` | `.zcode/hooks/` + `.zcode/config.json` (SessionStart + UserPromptSubmit + PreToolUse Agent/Task); sub-agents use hook-injected context | +| Grok Build | `--grok` | `.grok/` | `.grok/skills/` | `.grok/agents/` | pull-based prelude (no hooks; flat `.grok/commands/trellis-*.md`) | +| Kimi Code | `--kimi` | `.kimi-code/` | `.agents/skills/` (shared) + `.kimi-code/skills/` | None — agent prompts are skills under `.kimi-code/skills/` and dispatch to the built-in `coder` | None (pull-based prelude; no project hooks/settings) | +| Snow CLI | `--snow` | `.snow/` | `.snow/skills/` | `.snow/agents/` (auto-discovered; primary path) | class-1: auto inject + project agents + `beforeSubAgentStart` (`.snow/hooks/` `session`/`user`/`subagent` modes -> `additionalContext` JSON); no legacy sub-agent JSON; commands `.snow/commands/trellis-*.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 +- Trae IDE +- Reasonix (delivered as skills with `runAs: subagent` under `.reasonix/skills/`, not as a separate `agents/` directory) +- ZCode +- Grok Build (`.grok/agents/`; dispatch via `spawn_subagent` with `subagent_type`) +- Kimi Code (delivered as skills under `.kimi-code/skills/`; dispatched to the built-in `coder`, including research because it must persist files) +- Snow CLI (`.snow/agents/`; auto-discovered project agents + class-1 hooks) + +When changing implementation/check/research behavior, look for the corresponding platform agent files first. + +### Native Trellis Sub-Agent Tool + +Some platforms expose a first-class tool that the host runtime understands. The model calls it like any other tool and the host renders progress cards, validates the agent name against `.<platform>/agents/`, and enforces dispatch modes. + +- Pi Agent — `trellis_subagent` tool, defined in `.pi/extensions/trellis/index.ts`. Supports `single` / `parallel` / `chain` dispatch modes and emits live `trellis-subagent-progress` events. + +When changing sub-agent dispatch behavior on these platforms, edit the extension file, **not** the agent markdown — the agent markdown defines responsibilities, but the host extension owns dispatch, validation, and progress rendering. + +### Main-Session Workflow Platforms + +These platforms rely more on workflows/skills to guide the main session: + +- Kilo +- Antigravity +- Devin + +When changing behavior, inspect workflows and skills first. Do not assume Trellis sub-agents exist. + +### Shared `.agents/skills/` + +Codex, Gemini CLI, Pi Agent, and Kimi Code write 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. ZCode keeps Trellis-managed skills under `.zcode/skills/`. + +## 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. + +### `.omp/` — Oh My Pi (OMP) + +Extension-backed platform. OMP native provider auto-discovers all subdirectories. + +```text +.omp/ +├── commands/ # Slash commands (flat .md) +├── skills/ # Auto-triggered skills (SKILL.md per dir) +├── agents/ # Agent definitions (.md) +└── extensions/ + └── trellis/ + └── index.ts # Trellis extension (context injection) +``` + +No `settings.json` — OMP scans `.omp/` subdirectories automatically. +No Python hooks — hook-equivalent behavior lives in the TypeScript extension. diff --git a/.agents/skills/trellis-meta/references/platform-files/skills-and-commands.md b/.agents/skills/trellis-meta/references/platform-files/skills-and-commands.md new file mode 100644 index 0000000..89f15ce --- /dev/null +++ b/.agents/skills/trellis-meta/references/platform-files/skills-and-commands.md @@ -0,0 +1,86 @@ +# 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/` | +| Devin | `.devin/skills/`, `.devin/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 | `.agents/skills/` | +| Reasonix | `.reasonix/skills/` | +| ZCode | `.zcode/skills/`, `.zcode/commands/` | +| Kimi Code | `.agents/skills/`, `.kimi-code/skills/` (commands delivered as `/skill:trellis-*` 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/.agents/skills/trellis-session-insight/SKILL.md b/.agents/skills/trellis-session-insight/SKILL.md new file mode 100644 index 0000000..1d9f4ed --- /dev/null +++ b/.agents/skills/trellis-session-insight/SKILL.md @@ -0,0 +1,81 @@ +--- +name: trellis-session-insight +description: "Reach into past AI conversation history through the `trellis mem` CLI. Use whenever the user asks 'how did we solve X last time', 'have we discussed this before', 'what was the decision on X', 'remind me what we did in this task', '上次怎么解的', '之前讨论过吗', '想起一段对话', or when starting a brainstorm that overlaps prior work, debugging a familiar bug, continuing a task across sessions, or doing a finish-work review. Returns raw past dialogue; decide for the moment whether to update spec, append to task notes, quote inline in the answer, or just internalize." +--- + +# Trellis Session Insight + +This skill teaches an AI **how to call `trellis mem`** — the project's cross-session memory feedstock — and **when reaching for it is the right move**. + +It is intentionally a **capability skill, not a workflow**. There is no fixed output file, no required write-back step, no "always run after finish-work" rule. What to do with what `mem` returns is a judgement call made in the moment of the conversation. The skill exists so the AI knows the capability is there and can decide. + +## What `trellis mem` is + +A local CLI that indexes the user's past Claude Code, Codex, Pi Agent, and ZCode conversation logs and lets you list, search, slice by Trellis task boundaries, and dump cleaned dialogue from them. Claude and Codex use `~/.claude/projects/` and `~/.codex/sessions/`. Pi uses its default or environment-configured session root, global `~/.pi/agent/settings.json`, and the scoped project's `.pi/settings.json`; relative `sessionDir` values resolve from the settings file directory. Project-local Pi settings require project-scoped lookup through the current cwd or `--cwd`. ZCode uses `~/.zcode/cli/db/db.sqlite`. OpenCode logs are not yet indexable (provider adapter pending) — when an OpenCode session is the obvious target, surface that limitation rather than guessing. + +Nothing in `mem` is uploaded. All reads are local. + +## When to reach for it + +The bar is "would a senior teammate ask 'didn't we already talk about this?'" — those are the moments. Some concrete patterns: + +- **Brainstorm rerun risk.** Starting a new task that touches an area the user has been in before, and you want to check whether a decision was already made — before re-asking the user. +- **Familiar-bug debugging.** The current bug pattern feels like one the user reported / fixed before. Pulling the relevant past session can save a full debugging loop. +- **Cross-session continuation.** The user resumes work after a gap and says "where were we" / "继续上次的" without being specific. +- **Decision retrieval.** The user references "the decision we made about X" but the decision lives in an old brainstorm, not in any `prd.md` / `spec/`. +- **Finish-work retrospective.** When the user explicitly asks for a wrap-up of what was decided / what hurt / what surprised them in this task — not as a forced step on every finish-work. +- **Pattern-spotting across past work.** The user asks "do I keep making the same mistake on X" / "我每次都踩这个坑吗" — search across sessions answers that. + +If none of these apply, don't call `mem`. It is a tool, not a ceremony. + +## When NOT to reach for it + +- The relevant context is already in the current turn, `prd.md`, `design.md`, recent `git log`, or the open files. `mem` is for stuff that has fallen out of immediate reach. +- The user is asking about a fact in the code, not a fact from a past conversation. `git log -p` / `grep` / reading the file directly is faster and more authoritative. +- You are in a sub-agent (`trellis-implement` / `trellis-check`) whose dispatch prompt already includes the curated `implement.jsonl` / `check.jsonl` context. Adding `mem` on top usually just clutters. +- The user has explicitly said "don't dig through history, just answer what I asked". + +## What to do with what `mem` returns + +Treat the output as **raw material**, not a deliverable. Once you have it, decide based on the live conversation: + +- **Quote inline in your reply** if a specific past exchange answers the user's current question — and cite the session-id / phase so the user can verify. +- **Update `<task>/prd.md` or `<task>/design.md`** if `mem` surfaced a load-bearing decision that should have been written down but wasn't. Surface the proposed edit to the user first. +- **Append to a task-local notes file** (e.g. `<task>/notes.md` or extending an existing one) if the finding belongs to the current task's record but doesn't fit the PRD. +- **Update `.trellis/spec/`** if the finding is a project-wide convention or gotcha that would help future tasks. Run the `trellis-update-spec` skill for that — `session-insight` ends at the discovery. +- **Just absorb it** for the next few turns and answer better, without writing anything. This is often the right move for one-off recall. + +Trellis does not prescribe a single destination. Forcing every recall into a fixed file makes the file grow into noise. Let the situation decide. + +## How to call it + +Full CLI reference is in `references/cli-quick-reference.md`. The 80% case is one of: + +```bash +# Find sessions whose contents mention a keyword (project-scope is default; +# add --global to search every project on this machine). +trellis mem search "<keyword>" + +# Dump dialogue from one session, optionally filtered by phase or keyword. +trellis mem extract <session-id> --phase brainstorm +trellis mem extract <session-id> --grep "<keyword>" + +# Drill into a session: top-N hit turns + surrounding context. +trellis mem context <session-id> --turns 3 --around 2 + +# When you do not know the session id yet, start with list + filter. +trellis mem list --cwd <project-path> +trellis mem projects # → list active project cwds, then narrow +``` + +Phase slicing (`--phase brainstorm|implement|all`) cuts the session at `task.py create` and `task.py start` boundaries. For a finish-work review of the current task, `--phase brainstorm` recovers the planning discussion and `--phase implement` recovers the execution loop. Default is `all`. + +## Triggering patterns + +`references/triggering-patterns.md` lists more verbatim user phrasings (English + Chinese) that should make you think "reach for `mem`" — keep that handy when training instinct. + +## Out of scope + +- `mem` does not edit code or update files. Any write-back is your decision in the moment. +- `mem` is read-only on the platform JSONL stores. It does not push or sync to remote. +- This skill does not replace `trellis-update-spec` (which is the right tool for promoting a finding into project-wide guidance) or the platform-native task / spec workflow. diff --git a/.agents/skills/trellis-session-insight/references/cli-quick-reference.md b/.agents/skills/trellis-session-insight/references/cli-quick-reference.md new file mode 100644 index 0000000..78540f2 --- /dev/null +++ b/.agents/skills/trellis-session-insight/references/cli-quick-reference.md @@ -0,0 +1,65 @@ +# `trellis mem` CLI Reference + +Full flag reference for the five subcommands. Pin this as the authoritative source — `trellis mem help` prints the same content at runtime, so anything here that drifts is a bug. + +## Subcommands + +| Command | Purpose | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `list` | List sessions. Default subcommand when none is given. | +| `search <keyword>` | Find sessions whose contents match a keyword. | +| `context <session-id>` | Drill into one session: top-N hit turns + surrounding context. Pair with `--grep` for keyword anchoring. | +| `extract <session-id>` | Dump cleaned dialogue. Combine with `--phase` / `--grep` to slice. | +| `projects` | List active project `cwd` values with session counts. Use this to discover which `--cwd` to pass to other subcommands. | + +## Flags (apply where meaningful) + +| Flag | Subcommands | Meaning | +| --------------------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--platform claude\|codex\|opencode\|pi\|all` | all | Default `all`. OpenCode adapter is currently a stub on `0.6.0-beta.*` — see "Caveats" below. | +| `--since YYYY-MM-DD` | list / search | Inclusive lower date bound. | +| `--until YYYY-MM-DD` | list / search | Inclusive upper date bound. | +| `--global` | list / search | Include sessions from every project on this machine. Default is the current project `cwd`. | +| `--cwd <path>` | list / search | Force a specific project cwd instead of inferring from where you are. | +| `--limit N` | list / search | Cap output rows. Default `50`. | +| `--grep KW` | extract / context | Filter turns by keyword. Multi-token AND when whitespace-separated. | +| `--phase brainstorm\|implement\|all` | extract | Slice session by Trellis task boundaries. `brainstorm` = `[task.py create, task.py start)`. `implement` = turns outside brainstorm windows. Default `all`. | +| `--turns N` | context | Number of hit turns to return. Default `3`. | +| `--around N` | context | Surrounding turns to include per hit. Default `1`. | +| `--max-chars N` | context | Total character budget. Default `6000` (~1500 tokens). | +| `--include-children` | search / context | Merge OpenCode sub-agent sessions into their parent session. | +| `--json` | all | Emit machine-parseable JSON instead of human-readable output. | + +## Common one-liners + +```bash +# What past sessions discussed "deadlock" anywhere on this machine? +trellis mem search "deadlock" --global --limit 20 + +# Inside a specific session, surface the top 5 turns that mention "lock contention" +# plus 2 turns of surrounding context. +trellis mem context 5842592d --grep "lock contention" --turns 5 --around 2 + +# Recover the brainstorm window for a session — useful when continuing a task +# the user started a week ago. +trellis mem extract 5842592d --phase brainstorm + +# List every project this machine has Trellis sessions for, with counts. +trellis mem projects +``` + +## Output shapes + +- **Default human output** (no `--json`): wrapped to a terminal, with session ids highlighted and turn markers visible. Suitable to read inline but messy to paste into a markdown file. +- **`--json`**: stable schema, safe to parse and process. When piping `mem` output into a follow-up step (e.g. summarizing for a Lessons section), prefer `--json`. + +## Caveats + +- **OpenCode adapter is a stub on `0.6.0-beta.*`.** When `--platform` resolves to OpenCode (or `all` and OpenCode would be included), `mem` prints a one-line "reader unavailable" notice and continues with the other platforms. Don't promise OpenCode coverage in your reply until the adapter ships. +- **`--phase` slicing depends on `task.py create` / `task.py start` invocations appearing in the recorded bash calls of the session.** Sessions where the user ran `task.py` from a different terminal — outside the recorded AI loop — will not have phase boundaries. `--phase all` is the safe fallback. +- **`mem` indexes platform JSONL files directly.** If the user has cleared their Claude / Codex / Pi session storage, `mem` cannot recover what is no longer on disk. +- **`mem` is read-only.** No remote sync, no edits to platform JSONL. Any write you do based on `mem` findings is your own follow-up call into the editing tools available to you. + +## When you need more than this reference + +Run `trellis mem help` in the user's shell. The runtime help is authoritative and will be ahead of this reference during fast-moving beta releases. diff --git a/.agents/skills/trellis-session-insight/references/triggering-patterns.md b/.agents/skills/trellis-session-insight/references/triggering-patterns.md new file mode 100644 index 0000000..66021ca --- /dev/null +++ b/.agents/skills/trellis-session-insight/references/triggering-patterns.md @@ -0,0 +1,93 @@ +# Triggering Patterns + +Verbatim user phrasings that should make an AI reach for `trellis mem`. Calibrate instinct against these — if a user message hits one of these patterns and you do not reach for `mem`, you probably missed an obvious recall. + +Patterns are grouped by the *intent* behind the phrasing, not the surface words. The same intent shows up in different languages and registers. + +## Past-solution recall + +The user is asking "how did we (or I) solve this before". Past dialogue holds the answer; the codebase shows the result but not the reasoning. + +- "How did we solve this last time?" +- "What did we end up doing about X?" +- "We dealt with this once already, didn't we?" +- "上次怎么解的?" +- "之前是怎么搞定 X 的?" +- "我记得以前修过类似的" + +Reach: `trellis mem search "<symptom keyword>" --global --limit 10`, then `context` into the hit that looks closest. + +## Decision retrieval + +The user is referencing a decision that lives in old dialogue, not in any committed file. Look in brainstorm windows. + +- "What was the decision on X?" +- "Did we decide to use Postgres or SQLite?" +- "The rationale for choosing X over Y was…?" +- "我们当时为啥选了 X 而不是 Y?" +- "关于 X 我们之前是怎么定的?" +- "之前讨论过 X 的方案吗?" + +Reach: `trellis mem search "<decision keyword>"` to find the session, then `extract <id> --phase brainstorm` to recover the discussion. + +## Cross-session continuation + +The user resumed work after a gap and the context is implicit. + +- "Where were we?" +- "Continue from last time." +- "Pick up where we left off." +- "继续上次的" +- "我们上次做到哪了" +- "接着昨天那个任务" + +Reach: `trellis mem list --task <current-task-dir>` to find the most recent sessions tied to the active task, then `extract` the last one. + +## Familiar-bug debugging + +The current bug feels like one already seen. Past sessions probably hold the resolution path. + +- "I feel like I've hit this before." +- "Doesn't this look like that bug from last month?" +- "Same kind of timeout I had in X." +- "这个错好像之前见过" +- "这个 bug 是不是上次那个?" +- "怎么又是这个 error?" + +Reach: `trellis mem search "<error message fragment>" --global`. Anchor on a short, distinctive token from the actual error string. + +## Self-pattern spotting + +The user is asking whether they keep repeating the same kind of mistake or decision. + +- "Do I always make this mistake?" +- "How often have I run into X?" +- "Is this a recurring thing for me?" +- "我每次都踩这个坑吗?" +- "我老犯这个错?" +- "这类问题之前出现过几次?" + +Reach: `trellis mem search "<topic>" --global --limit 50` and scan the dates / projects in the listing. Optionally `extract` two or three for comparison. + +## Finish-work retrospective (on demand) + +The user explicitly wants to look back at this task — not as a forced step, only when they ask. + +- "Summarize what we did in this task." +- "What were the key decisions / surprises?" +- "Write up the lessons from this round." +- "总结一下这次的经验" +- "记一下这次踩的坑" +- "复盘下这个任务" + +Reach: identify the current task's session id (from `.trellis/.runtime/sessions/*.json` or `mem list --task <task-dir>`), then `extract <id> --phase brainstorm` and `--phase implement`. Present a summary — surface concrete file:line citations where possible. Whether to also write the summary somewhere (PRD, spec, notes file) is the user's call; offer, don't auto-write. + +## Anti-patterns: do NOT reach for `mem` here + +- "What does this function do?" → read the file. +- "Why is this test failing?" → read the test output and the file. +- "What's the right pattern for X in our codebase?" → grep / read spec files. +- "What's the latest npm version of Y?" → call `npm view`. +- "Fix this bug." → debug. Reach for `mem` only if you suspect prior context exists; otherwise it is noise. + +The bar stays: would a senior teammate ask "didn't we already talk about this?" before answering? If yes, reach for `mem`. If no, don't. diff --git a/.agents/skills/trellis-spec-bootstrap/SKILL.md b/.agents/skills/trellis-spec-bootstrap/SKILL.md new file mode 100644 index 0000000..e1650df --- /dev/null +++ b/.agents/skills/trellis-spec-bootstrap/SKILL.md @@ -0,0 +1,41 @@ +--- +name: trellis-spec-bootstrap +description: "Bootstrap project-specific Trellis coding specs with a platform-neutral single-agent workflow. Use when creating or refreshing .trellis/spec guidelines, analyzing a codebase with GitNexus, ABCoder, or source inspection, decomposing package/layer spec work, and writing real codebase-backed spec docs without placeholder text." +--- + +# Trellis Spec Bootstrap + +Use this skill to create or refresh `.trellis/spec/` guidelines from the real codebase. One capable agent owns the full loop: analyze the repository, choose the spec boundaries, write the docs, and verify the result. The workflow does not depend on a specific host, CLI, or agent brand. + +## Workflow + +1. Confirm Trellis is initialized and inspect the current `.trellis/spec/` tree. +2. Analyze the repository architecture with the best available tools: GitNexus, ABCoder, language tooling, and direct source reads. +3. Decompose the spec work by package and layer only when that reflects the actual codebase. +4. Fill or reshape the spec files with concrete patterns, file paths, examples, and anti-patterns from the project. +5. Verify that the final specs are internally consistent and contain no template placeholders. + +## Reference Routing + +| Need | Read | +|------|------| +| Repository architecture analysis | [references/repository-analysis.md](references/repository-analysis.md) | +| Spec work decomposition and task planning | [references/spec-task-planning.md](references/spec-task-planning.md) | +| Writing high-signal Trellis spec files | [references/spec-writing.md](references/spec-writing.md) | +| GitNexus and ABCoder MCP setup | [references/mcp-setup.md](references/mcp-setup.md) | + +## Operating Rules + +- Treat templates as starting points, not contracts. Delete, rename, split, or add spec files when the repository calls for it. +- Prefer source-backed rules over generic advice. Every important recommendation should point at a real file or repeated local pattern. +- Keep execution single-owner by default. Optional helper agents are an implementation detail, not a requirement or user-visible dependency. +- Do not write platform-specific instructions unless the target project already standardizes on that platform. +- Do not leave placeholder text, empty headings, or copied boilerplate in `.trellis/spec/`. + +## Done Criteria + +- `.trellis/spec/` describes the project as it exists now. +- Each relevant package or layer has practical coding guidance with real examples. +- Non-applicable template sections are removed. +- `index.md` files match the final spec file set. +- Any required setup or analysis assumptions are documented in the relevant spec or task notes. diff --git a/.agents/skills/trellis-spec-bootstrap/references/mcp-setup.md b/.agents/skills/trellis-spec-bootstrap/references/mcp-setup.md new file mode 100644 index 0000000..629fcbd --- /dev/null +++ b/.agents/skills/trellis-spec-bootstrap/references/mcp-setup.md @@ -0,0 +1,90 @@ +# MCP Setup + +GitNexus and ABCoder are recommended when bootstrapping Trellis specs because they expose architecture and AST context to the agent. They are tool choices, not platform requirements. Configure them through whatever MCP mechanism your agent host provides. + +## GitNexus + +GitNexus builds a code knowledge graph from the repository. Use it for module boundaries, execution flows, dependency relationships, blast radius, and graph queries. + +### Install and Index + +```bash +# Run from the repository root. +npx gitnexus analyze + +# Check index status. +npx gitnexus status + +# Re-index after code changes when the analysis is stale. +npx gitnexus analyze +``` + +The index is written to `.gitnexus/`. Keep embeddings only if the project already uses them; otherwise a normal index is enough for spec bootstrapping. + +### MCP Server Command + +Use this server command in the host's MCP configuration: + +```bash +npx -y gitnexus mcp +``` + +### Useful Tools + +| Tool | Purpose | +|------|---------| +| `gitnexus_query` | Find execution flows and functional areas by concept | +| `gitnexus_context` | Inspect callers, callees, references, and process participation for a symbol | +| `gitnexus_impact` | Understand blast radius before changing a symbol | +| `gitnexus_detect_changes` | Check changed symbols and affected flows before finishing | +| `gitnexus_cypher` | Run direct graph queries | +| `gitnexus_list_repos` | List indexed repositories | + +## ABCoder + +ABCoder parses code into UniAST and gives precise package, file, and node-level structure. Use it for signatures, type shapes, implementations, dependencies, and reverse references. + +### Install + +```bash +go install github.com/cloudwego/abcoder@latest +abcoder --help +``` + +### Parse Repositories + +```bash +abcoder parse /absolute/path/to/package \ + --lang typescript \ + --name package-name \ + --output ~/abcoder-asts +``` + +For monorepos, parse each package with a stable `--name` so task notes can reference the same repository names. + +### MCP Server Command + +Use this server command in the host's MCP configuration: + +```bash +abcoder mcp ~/abcoder-asts +``` + +### Useful Tools + +| Tool | Layer | Purpose | +|------|-------|---------| +| `list_repos` | 1 | List parsed repositories | +| `get_repo_structure` | 2 | Inspect packages and files | +| `get_package_structure` | 3 | Inspect nodes within a package | +| `get_file_structure` | 3 | Inspect functions, classes, types, and signatures in a file | +| `get_ast_node` | 4 | Retrieve code, dependencies, references, and implementations | + +## Verification + +After configuration, verify from the agent host that both MCP servers are visible. Then run one simple query against each server before starting the spec writing pass. + +```bash +ls .gitnexus/meta.json +ls ~/abcoder-asts/*.json +``` diff --git a/.agents/skills/trellis-spec-bootstrap/references/repository-analysis.md b/.agents/skills/trellis-spec-bootstrap/references/repository-analysis.md new file mode 100644 index 0000000..1309d29 --- /dev/null +++ b/.agents/skills/trellis-spec-bootstrap/references/repository-analysis.md @@ -0,0 +1,59 @@ +# Repository Analysis + +The goal is to discover the project's real architecture before writing rules. Do not start from generic spec templates and fill blanks. Start from the code, then let the spec structure follow. + +## Analysis Order + +1. Read the existing `.trellis/spec/` tree and note which files are templates, outdated, or already project-specific. +2. Inspect package manifests, build scripts, workspace config, and top-level documentation to identify packages and runtime layers. +3. Use GitNexus for execution flows, module clusters, dependency hubs, and impact-sensitive areas. +4. Use ABCoder or language-native tooling for exact signatures, types, class boundaries, and implementation examples. +5. Read representative source and test files directly before turning any finding into a spec rule. + +## What To Capture + +| Area | Questions | +|------|-----------| +| Package boundaries | What does each package own? What imports cross boundaries? | +| Runtime layers | Which code is CLI, backend, frontend, worker, shared library, test-only, or tooling? | +| Core abstractions | Which types, services, stores, commands, routes, or adapters define the system shape? | +| Data flow | Where does user input enter, how is it validated, and where does state persist? | +| Error handling | How are failures represented, logged, surfaced, and tested? | +| Configuration | Where do defaults, environment config, generated files, and templates live? | +| Tests | Which test styles are trusted examples for new work? | + +## GitNexus Usage + +Start broad, then inspect specific symbols: + +```text +gitnexus_query({query: "CLI command execution flow"}) +gitnexus_query({query: "template generation and migration"}) +gitnexus_context({name: "SymbolName"}) +gitnexus_cypher({query: "MATCH (n)-[r]->(m) RETURN n.name, type(r), m.name LIMIT 30"}) +``` + +Use GitNexus results to find important files and flows. Do not quote graph output as the final authority until you have checked the relevant source files. + +## ABCoder Usage + +Use ABCoder when the spec needs exact code shapes: + +```text +list_repos() +get_repo_structure({repo_name: "package-name"}) +get_file_structure({repo_name: "package-name", file_path: "src/example.ts"}) +get_ast_node({repo_name: "package-name", node_ids: [{mod_path: "...", pkg_path: "...", name: "SymbolName"}]}) +``` + +ABCoder is most valuable for documenting constructor patterns, function signatures, type contracts, and reference chains. + +## Analysis Notes + +Keep short notes while analyzing. The notes should include: + +- Package or layer name. +- Files that define the local pattern. +- Rules the spec should teach. +- Anti-patterns found in old code, comments, tests, or migration paths. +- Spec files that should be created, deleted, renamed, or merged. diff --git a/.agents/skills/trellis-spec-bootstrap/references/spec-task-planning.md b/.agents/skills/trellis-spec-bootstrap/references/spec-task-planning.md new file mode 100644 index 0000000..dca2687 --- /dev/null +++ b/.agents/skills/trellis-spec-bootstrap/references/spec-task-planning.md @@ -0,0 +1,61 @@ +# Spec Task Planning + +Use a single agent as the default execution model. The agent may create Trellis tasks for traceability, but the skill should not require a specific platform, CLI, or parallel worker model. + +## Decomposition + +Create spec work units around real ownership boundaries: + +- One package when a package has its own conventions. +- One layer when the same package has distinct frontend, backend, CLI, worker, or shared-library rules. +- One cross-cutting guide when a pattern spans packages and is not owned by one layer. + +Avoid artificial decomposition. A small library usually needs one focused spec pass, not several tasks. + +## Task Shape + +When a Trellis task is useful, write a concise PRD with these sections: + +```markdown +# Fill <package-or-layer> Trellis Specs + +## Goal +Write project-specific `.trellis/spec/` guidance for <scope>. + +## Scope +- Spec directory: +- Source directories to inspect: +- Tests to inspect: +- Out of scope: + +## Architecture Context +Summarize the concrete findings from repository analysis. + +## Files To Create Or Update +- `.trellis/spec/.../index.md` +- `.trellis/spec/.../<topic>.md` + +## Rules +- Adapt the spec file set to the real codebase. +- Use real source examples with file paths. +- Remove template-only sections that do not apply. +- Do not modify product source code unless the task explicitly asks for it. + +## Acceptance Criteria +- [ ] Specs contain concrete examples and anti-patterns from the repository. +- [ ] No placeholder text remains. +- [ ] Index files match the final spec files. +- [ ] Claims are backed by source files, tests, or project docs. +``` + +## Optional Helper Agents + +If the host supports subagents, helpers can inspect independent packages or run verification. They are optional. The main agent still owns integration and final quality. + +Helper tasks must have clear ownership: + +- Read-only research tasks may inspect any source needed for the assigned scope. +- Write tasks should own disjoint spec directories. +- Verification tasks should check placeholder removal, broken links, and consistency. + +Do not encode helper-agent names, vendor-specific commands, or platform-specific routing in the skill. Put only the required work and acceptance criteria in the task. diff --git a/.agents/skills/trellis-spec-bootstrap/references/spec-writing.md b/.agents/skills/trellis-spec-bootstrap/references/spec-writing.md new file mode 100644 index 0000000..6bc7dec --- /dev/null +++ b/.agents/skills/trellis-spec-bootstrap/references/spec-writing.md @@ -0,0 +1,70 @@ +# Spec Writing + +Trellis specs are coding guidance for future agents. They should explain how to work in this repository, not how a generic project might be organized. + +## Write From Evidence + +Each important rule should be backed by one of these: + +- A source file that demonstrates the preferred pattern. +- A test file that shows expected behavior. +- A project document that defines the convention. +- A repeated pattern across multiple files. + +Use short snippets only when they make the rule clearer. Prefer linking to the file path and naming the symbol or behavior. + +## File Structure + +Keep the spec tree aligned with the project: + +- Keep `index.md` as the navigation file for the spec directory. +- Split topics when developers would look for them independently. +- Merge topics when separate files would repeat the same rule. +- Delete template files that do not apply. +- Add new files for important local patterns the template missed. + +## Content Standards + +Good spec sections include: + +- When the rule applies. +- The local pattern to follow. +- The source or test files that prove the pattern. +- Common mistakes or anti-patterns. +- Verification commands or checks when they are specific and reliable. + +Avoid: + +- Placeholder prose. +- Generic framework advice. +- Tool instructions that only work in one agent host. +- Long copied code blocks. +- Rules based on a single accidental implementation detail. + +## Example Shape + +```markdown +## Command Handlers + +Command handlers should keep argument parsing, validation, and side effects separate. The local pattern is: + +- Parse CLI flags at the command boundary. +- Convert raw inputs into typed task options before invoking core logic. +- Keep filesystem writes in the command or service layer, not in template helpers. + +Reference files: +- `packages/cli/src/commands/example.ts` +- `packages/cli/test/commands/example.test.ts` + +Avoid passing raw `process.argv` or unvalidated config objects into shared helpers. +``` + +## Final Pass + +Before finishing: + +```bash +grep -R "To be filled\\|TODO: fill\\|placeholder" .trellis/spec +``` + +Also check links, index files, and whether any spec still describes a template rather than this repository. diff --git a/.agents/skills/trellis-start/SKILL.md b/.agents/skills/trellis-start/SKILL.md new file mode 100644 index 0000000..3c7980d --- /dev/null +++ b/.agents/skills/trellis-start/SKILL.md @@ -0,0 +1,64 @@ +--- +name: trellis-start +description: "Initializes an AI development session by reading workflow guides, developer identity, git status, active tasks, and project guidelines from .trellis/. Classifies incoming tasks and routes to brainstorm, direct edit, or task workflow. Use when beginning a new coding session, resuming work, starting a new task, or re-establishing project context." +--- + +# Start Session + +Initialize a Trellis-managed development session. This platform has no session-start hook, so manually load the equivalent compact context by following these steps. + +--- + +## Step 1: Current state +Identity, git status, current task, active tasks, journal location. + +```bash +python3 ./.trellis/scripts/get_context.py +``` + +If this output includes a line beginning `Trellis update available:`, copy the full line verbatim when summarizing session context. Do not shorten operational command hints. + +## Step 2: Workflow overview +Compact Phase Index, request triage rules, planning artifact contract, and the step-detail command. + +```bash +python3 ./.trellis/scripts/get_context.py --mode phase +``` + +Full guide in `.trellis/workflow.md` (read on demand). + +## Step 3: Guideline indexes +Discover packages + spec layers, then read each relevant index file. + +```bash +python3 ./.trellis/scripts/get_context.py --mode packages +cat .trellis/spec/guides/index.md +cat .trellis/spec/<package>/<layer>/index.md # for each relevant layer +``` + +Index files list the specific guideline docs to read when you actually start coding. + +## Step 4: Decide next action +From Step 1 you know the current task and status. Check the task directory: + +- **Active task status `planning` + no `prd.md`** → Phase 1.1. Load the `trellis-brainstorm` skill. +- **Active task status `planning` + `prd.md` exists** → stay in Phase 1. Lightweight tasks can be PRD-only; complex tasks need `design.md` + `implement.md`. Load the relevant Phase 1 step detail before `task.py start`. +- **Active task status `in_progress`** → Phase 2 step 2.1. Load the step detail: + ```bash + python3 ./.trellis/scripts/get_context.py --mode phase --step 2.1 --platform codex + ``` +- **No active task** → classify first. For simple conversation / small task, ask only whether this turn should create a Trellis task. For complex work, ask whether you may create a Trellis task and enter planning. If the user says no, skip Trellis for this session. + +--- + +## Skill routing (quick reference) + +| User intent | Skill | +|---|---| +| New feature / unclear requirements | `trellis-brainstorm` | +| About to write code | `trellis-before-dev` | +| Done coding / quality check | `trellis-check` | +| Stuck / fixed same bug multiple times | `trellis-break-loop` | +| Learned something worth capturing | `trellis-update-spec` | + +Full rules + anti-rationalization table in `.trellis/workflow.md`. diff --git a/.agents/skills/trellis-update-spec/SKILL.md b/.agents/skills/trellis-update-spec/SKILL.md new file mode 100644 index 0000000..81bad08 --- /dev/null +++ b/.agents/skills/trellis-update-spec/SKILL.md @@ -0,0 +1,356 @@ +--- +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 to update the relevant code-spec documents. + +**Timing**: After completing a task, fixing a bug, or discovering a new pattern + +--- + +## Code-Spec First Rule (CRITICAL) + +In this project, "spec" for implementation work means **code-spec**: +- Executable contracts (not principle-only text) +- Concrete signatures, payload fields, env keys, and boundary behavior +- Testable validation/error behavior + +If the change touches infra or cross-layer contracts, code-spec depth is mandatory. + +### Mandatory Triggers + +Apply code-spec depth when the change includes any of: +- New/changed command or API signature +- Cross-layer request/response contract change +- Database schema/migration change +- Infra integration (storage, queue, cache, secrets, env wiring) + +### Mandatory Output (7 Sections) + +For triggered tasks, include all sections below: +1. Scope / Trigger +2. Signatures (command/API/DB) +3. Contracts (request/response/env) +4. Validation & Error Matrix +5. Good/Base/Bad Cases +6. Tests Required (with assertion points) +7. Wrong vs Correct (at least one pair) + +--- + +## When to Update Code-Specs + +| Trigger | Example | Target Spec | +|---------|---------|-------------| +| **Implemented a feature** | Added 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. + +--- + +## Spec Structure Overview + +``` +.trellis/spec/ +├── <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!) + ├── index.md # Guide index + └── *.md # Topic-specific guides +``` + +### CRITICAL: Code-Spec vs Guide - Know the Difference + +| Type | Location | Purpose | Content Style | +|------|----------|---------|---------------| +| **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 a spec layer directory +- "This is **what to consider** before writing" → Put in `guides/` + +**Example**: + +| Learning | Wrong Location | Correct Location | +|----------|----------------|------------------| +| "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. + +--- + +## Update Process + +### Step 1: Identify What You Learned + +Answer these questions: + +1. **What did you learn?** (Be specific) +2. **Why is it important?** (What problem does it prevent?) +3. **Where does it belong?** (Which spec file?) + +### Step 2: Classify the Update Type + +| Type | Description | Action | +|------|-------------|--------| +| **Design Decision** | Why we chose approach X over Y | Add to "Design Decisions" section | +| **Project Convention** | How we do X in this project | Add to relevant section with examples | +| **New Pattern** | A reusable approach discovered | Add to "Patterns" section | +| **Forbidden Pattern** | Something that causes problems | Add to "Anti-patterns" or "Don't" section | +| **Common Mistake** | Easy-to-make error | Add to "Common Mistakes" section | +| **Convention** | Agreed-upon standard | Add to relevant section | +| **Gotcha** | Non-obvious behavior | Add warning callout | + +### Step 3: Read the Target Code-Spec + +Before editing, read the current code-spec to: +- Understand existing structure +- Avoid duplicating content +- Find the right section for your update + +```bash +cat .trellis/spec/<category>/<file>.md +``` + +### Step 4: Make the Update + +Follow these principles: + +1. **Be Specific**: Include concrete examples, not just abstract rules +2. **Explain Why**: State the problem this prevents +3. **Show Contracts**: Add signatures, payload fields, and error behavior +4. **Show Code**: Add code snippets for key patterns +5. **Keep it Short**: One concept per section + +### Step 5: Update the Index (if needed) + +If you added a new section or the code-spec status changed, update the category's `index.md`. + +--- + +## Update Templates + +### Mandatory Template for Infra/Cross-Layer Work + +```markdown +## Scenario: <name> + +### 1. Scope / Trigger +- Trigger: <why this requires code-spec depth> + +### 2. Signatures +- Backend command/API/DB signature(s) + +### 3. Contracts +- Request fields (name, type, constraints) +- Response fields (name, type, constraints) +- Environment keys (required/optional) + +### 4. Validation & Error Matrix +- <condition> -> <error> + +### 5. Good/Base/Bad Cases +- Good: ... +- Base: ... +- Bad: ... + +### 6. Tests Required +- Unit/Integration/E2E with assertion points + +### 7. Wrong vs Correct +#### Wrong +... +#### Correct +... +``` + +### Adding a Design Decision + +```markdown +### Design Decision: [Decision Name] + +**Context**: What problem were we solving? + +**Options Considered**: +1. Option A - brief description +2. Option B - brief description + +**Decision**: We chose Option X because... + +**Example**: +\`\`\`typescript +// How it's implemented +code example +\`\`\` + +**Extensibility**: How to extend this in the future... +``` + +### Adding a Project Convention + +```markdown +### Convention: [Convention Name] + +**What**: Brief description of the convention. + +**Why**: Why we do it this way in this project. + +**Example**: +\`\`\`typescript +// How to follow this convention +code example +\`\`\` + +**Related**: Links to related conventions or specs. +``` + +### Adding a New Pattern + +```markdown +### Pattern Name + +**Problem**: What problem does this solve? + +**Solution**: Brief description of the approach. + +**Example**: +\`\`\` +// Good +code example + +// Bad +code example +\`\`\` + +**Why**: Explanation of why this works better. +``` + +### Adding a Forbidden Pattern + +```markdown +### Don't: Pattern Name + +**Problem**: +\`\`\` +// Don't do this +bad code example +\`\`\` + +**Why it's bad**: Explanation of the issue. + +**Instead**: +\`\`\` +// Do this instead +good code example +\`\`\` +``` + +### Adding a Common Mistake + +```markdown +### Common Mistake: Description + +**Symptom**: What goes wrong + +**Cause**: Why this happens + +**Fix**: How to correct it + +**Prevention**: How to avoid it in the future +``` + +### Adding a Gotcha + +```markdown +> **Warning**: Brief description of the non-obvious behavior. +> +> Details about when this happens and how to handle it. +``` + +--- + +## Interactive Mode + +If you're unsure what to update, answer these prompts: + +1. **What did you just finish?** + - [ ] Fixed a bug + - [ ] Implemented a feature + - [ ] Refactored code + - [ ] Had a discussion about approach + +2. **What did you learn or decide?** + - Design decision (why X over Y) + - Project convention (how we do X) + - Non-obvious behavior (gotcha) + - Better approach (pattern) + +3. **Would future AI/developers need to know this?** + - To understand how the code works → Yes, update spec + - To maintain or extend the feature → Yes, update spec + - To avoid repeating mistakes → Yes, update spec + - Purely one-off implementation detail → Maybe skip + +4. **Which area does it relate to?** + - [ ] Backend code + - [ ] Frontend code + - [ ] Cross-layer data flow + - [ ] Code organization/reuse + - [ ] Quality/testing + +--- + +## Quality Checklist + +Before finishing your code-spec update: + +- [ ] Is the content specific and actionable? +- [ ] Did you include a code example? +- [ ] Did you explain WHY, not just WHAT? +- [ ] Did you include executable signatures/contracts? +- [ ] Did you include validation and error matrix? +- [ ] Did you include Good/Base/Bad cases? +- [ ] Did you include required tests with assertion points? +- [ ] Is it in the right code-spec file? +- [ ] Does it duplicate existing content? +- [ ] Would a new team member understand it? + +--- + +## Relationship to Other Commands + +``` +Development Flow: + Learn something → `update-spec` (Trellis command) → Knowledge captured + ↑ ↓ + `break-loop` (Trellis command) ←──────────────────── Future sessions benefit + (deep bug analysis) +``` + +- ``break-loop` (Trellis command)` - Analyzes bugs deeply, often reveals spec updates needed +- ``update-spec` (Trellis command)` - Actually makes the updates +- ``finish-work` (Trellis command)` - Reminds you to check if specs need updates + +--- + +## Core Philosophy + +> **Code-specs are living documents. Every debugging session, every "aha moment" is an opportunity to make the implementation contract clearer.** + +The goal is **institutional memory**: +- What one person learns, everyone benefits from +- What AI learns in one session, persists to future sessions +- Mistakes become documented guardrails diff --git a/.claude/agents/trellis-check.md b/.claude/agents/trellis-check.md new file mode 100644 index 0000000..14334f2 --- /dev/null +++ b/.claude/agents/trellis-check.md @@ -0,0 +1,115 @@ +--- +name: trellis-check +description: | + Code quality check expert. Reviews code changes against specs and self-fixes issues. +tools: Read, Write, Edit, Bash, Glob, Grep +--- +# Check Agent + +You are the Check Agent in the Trellis workflow. + +## Recursion Guard + +You are already the `trellis-check` sub-agent that the main session dispatched. Do the review and fixes directly. + +- Do NOT spawn another `trellis-check` or `trellis-implement` sub-agent. +- If SessionStart context, workflow-state breadcrumbs, or workflow.md say to dispatch `trellis-implement` / `trellis-check`, treat that as a main-session instruction that is already satisfied by your current role. +- Only the main session may dispatch Trellis implement/check agents. If more implementation work is needed, report that recommendation instead of spawning. + +## Trellis Context Loading Protocol + +Look for the `<!-- trellis-hook-injected -->` marker in your input above. + +- **If the marker is present**: task artifacts, spec, and research files have already been auto-loaded for you above. Proceed with the check work directly. +- **If the marker is absent**: hook injection didn't fire (Windows + Claude Code, `--continue` resume, fork distribution, hooks disabled, etc.). Find the active task path from your dispatch prompt's first line `Active task: <path>`, then Read `<task-path>/check.jsonl`, each listed file, `<task-path>/prd.md`, `<task-path>/design.md` if present, and `<task-path>/implement.md` if present before doing the work. + +## Context + +Before checking, read: +- `.trellis/spec/` - Development guidelines +- Task `prd.md` - Requirements document +- Task `design.md` - Technical design (if exists) +- Task `implement.md` - Execution plan (if exists) +- Pre-commit checklist for quality standards + +## Core Responsibilities + +1. **Get code changes** - Use git diff to get uncommitted code +2. **Review task artifacts** - Check changes against prd.md, design.md if present, and implement.md if present +3. **Check against specs** - Verify code follows guidelines +4. **Self-fix** - Fix issues yourself, not just report them +5. **Run verification** - typecheck and lint + +## Important + +**Fix issues yourself**, don't just report them. + +You have write and edit tools, you can modify code directly. + +--- + +## Workflow + +### Step 1: Get Changes + +```bash +git diff --name-only # List changed files +git diff # View specific changes +``` + +### Step 2: Check Against Specs and Task Artifacts + +Read the task's prd.md, design.md if present, and implement.md if present, then read relevant specs in `.trellis/spec/` to check code: + +- Does it satisfy the task requirements +- Does it follow the technical design and implementation plan when present +- Does it follow directory structure conventions +- Does it follow naming conventions +- Does it follow code patterns +- Are there missing types +- Are there potential bugs + +### Step 3: Self-Fix + +After finding issues: + +1. Fix the issue directly (use edit tool) +2. Record what was fixed +3. Continue checking other issues + +### Step 4: Run Verification + +Run project's lint and typecheck commands to verify changes. + +If failed, fix issues and re-run. + +--- + +## Report Format + +```markdown +## Self-Check Complete + +### Files Checked + +- src/components/Feature.tsx +- src/hooks/useFeature.ts + +### Issues Found and Fixed + +1. `<file>:<line>` - <what was fixed> +2. `<file>:<line>` - <what was fixed> + +### Issues Not Fixed + +(If there are issues that cannot be self-fixed, list them here with reasons) + +### Verification Results + +- TypeCheck: Passed +- Lint: Passed + +### Summary + +Checked X files, found Y issues, all fixed. +``` diff --git a/.claude/agents/trellis-implement.md b/.claude/agents/trellis-implement.md new file mode 100644 index 0000000..333bf73 --- /dev/null +++ b/.claude/agents/trellis-implement.md @@ -0,0 +1,110 @@ +--- +name: trellis-implement +description: | + Code implementation expert. Understands specs and requirements, then implements features. No git commit allowed. +tools: Read, Write, Edit, Bash, Glob, Grep +--- +# Implement Agent + +You are the Implement Agent in the Trellis workflow. + +## Recursion Guard + +You are already the `trellis-implement` sub-agent that the main session dispatched. Do the implementation work directly. + +- Do NOT spawn another `trellis-implement` or `trellis-check` sub-agent. +- If SessionStart context, workflow-state breadcrumbs, or workflow.md say to dispatch `trellis-implement` / `trellis-check`, treat that as a main-session instruction that is already satisfied by your current role. +- Only the main session may dispatch Trellis implement/check agents. If more parallel work is needed, report that recommendation instead of spawning. + +## Trellis Context Loading Protocol + +Look for the `<!-- trellis-hook-injected -->` marker in your input above. + +- **If the marker is present**: prd / spec / research files have already been auto-loaded for you above. Proceed with the implementation work directly. +- **If the marker is absent**: hook injection didn't fire (Windows + Claude Code, `--continue` resume, fork distribution, hooks disabled, etc.). Find the active task path from your dispatch prompt's first line `Active task: <path>`, then Read `<task-path>/implement.jsonl`, each listed file, `<task-path>/prd.md`, `<task-path>/design.md` if present, and `<task-path>/implement.md` if present before doing the work. + +## Context + +Before implementing, read: +- `.trellis/workflow.md` - Project workflow +- `.trellis/spec/` - Development guidelines +- Task `prd.md` - Requirements document +- Task `design.md` - Technical design (if exists) +- Task `implement.md` - Execution plan (if exists) + +## Core Responsibilities + +1. **Understand specs** - Read relevant spec files in `.trellis/spec/` +2. **Understand task artifacts** - Read prd.md, design.md if present, and implement.md if present +3. **Implement features** - Write code following specs and task artifacts +4. **Self-check** - Ensure code quality +5. **Report results** - Report completion status + +## Forbidden Operations + +**Do NOT execute these git commands:** + +- `git commit` +- `git push` +- `git merge` + +--- + +## Workflow + +### 1. Understand Specs + +Read relevant specs based on task type: + +- Spec layers: `.trellis/spec/<package>/<layer>/` +- Shared guides: `.trellis/spec/guides/` + +### 2. Understand Requirements + +Read the task's prd.md, design.md if present, and implement.md if present: + +- What are the core requirements +- Key points of technical design +- Implementation order, validation commands, and rollback points + +### 3. Implement Features + +- Write code following specs and task artifacts +- Follow existing code patterns +- Only do what's required, no over-engineering + +### 4. Verify + +Run project's lint and typecheck commands to verify changes. + +--- + +## Report Format + +```markdown +## Implementation Complete + +### Files Modified + +- `src/components/Feature.tsx` - New component +- `src/hooks/useFeature.ts` - New hook + +### Implementation Summary + +1. Created Feature component... +2. Added useFeature hook... + +### Verification Results + +- Lint: Passed +- TypeCheck: Passed +``` + +--- + +## Code Standards + +- Follow existing code patterns +- Don't add unnecessary abstractions +- Only do what's required, no over-engineering +- Keep code readable diff --git a/.claude/agents/trellis-research.md b/.claude/agents/trellis-research.md new file mode 100644 index 0000000..916686c --- /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, Skill, mcp__* +--- +# 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/<topic>.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 <TASK_DIR>/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/<topic-slug>.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/<topic>.md` should follow: + +```markdown +# Research: <topic> + +- **Query**: <original query> +- **Scope**: <internal / external / mixed> +- **Date**: <YYYY-MM-DD> + +## Findings + +### Files Found + +| File Path | Description | +|---|---| +| `src/services/xxx.ts` | Main implementation | +| `src/types/xxx.ts` | Type definitions | + +### Code Patterns + +<describe patterns, cite file:line> + +### External References + +- [Library X docs](url) — <why relevant, version constraints> + +### Related Specs + +- `.trellis/spec/xxx.md` — <description> + +## Caveats / Not Found + +<anything incomplete or uncertain> +``` + +--- + +## 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..1f7a9e6 --- /dev/null +++ b/.claude/commands/trellis/continue.md @@ -0,0 +1,56 @@ +# 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. This command replaces the user needing to remember the Trellis flow; it does not itself approve implementation. + +- `status=planning` + no `prd.md` → **1.1** (load `trellis-brainstorm`) +- `status=planning` + `prd.md` only → decide whether the task is lightweight or complex. Lightweight can move to **1.4** review; complex returns to **1.1** to add `design.md` + `implement.md`. +- `status=planning` + complex artifacts complete + sub-agent jsonl not curated (only the seed `_example` row) → **1.3** +- `status=planning` + required artifacts complete + required jsonl curated or inline mode → **1.4** (ask for start review; only run `task.py start` after user confirms) +- `status=in_progress` + implementation not started → **2.1** +- `status=in_progress` + implementation done, not yet checked → **2.2** +- `status=in_progress` + check passed → **3.3** (spec update) → **3.4** (commit) +- `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 required output exists. `prd.md` alone can be enough only for lightweight tasks; complex tasks also need `design.md` and `implement.md`. +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 <X.X> --platform claude +``` + +Follow the loaded instructions. After each `[required]` step completes, move to the next. + +--- + +## Reference + +Full workflow and detailed phase steps 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: `<list>`. 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: `<list>`." +- **Genuinely unsure** — ask the user once: "Are `<list>` 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 <task-name> +``` + +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: `<work commits from 3.4>` → `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 100644 index 0000000..cfe7b15 --- /dev/null +++ b/.claude/hooks/inject-subagent-context.py @@ -0,0 +1,1141 @@ +#!/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 +- design.md - Technical design for complex tasks +- implement.md - Execution plan for complex tasks +- 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 + +# Hook hosts send UTF-8 JSON regardless of the process locale. +_stdin_reconfigure = getattr(sys.stdin, "reconfigure", None) +if callable(_stdin_reconfigure): + try: + _stdin_reconfigure(encoding="utf-8", errors="replace") + except (OSError, ValueError): + pass + +# 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 _hook_event_name(input_data) == "SubagentStart": + return "codex" + if isinstance(input_data.get("cursor_version"), str): + return "cursor" + env_map = { + "ZCODE_PROJECT_DIR": "zcode", + "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" + if ".zcode" in script_parts: + return "zcode" + return None + + +def get_current_task( + repo_root: str, + input_data: dict, + *, + platform: str | None = None, + allow_single_session_fallback: bool = True, + allow_environment_context: bool = True, + require_existing: bool = False, +) -> 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=platform or _detect_platform(input_data), + allow_single_session_fallback=allow_single_session_fallback, + allow_environment_context=allow_environment_context, + ) + if require_existing and active.stale: + return None + return active.task_path + + +# ============================================================================= +# Context Injection Limits (issue #441) +# +# Notice text and behavior mirrored byte-for-byte in the Pi TS extension +# (templates/pi/extensions/trellis/index.ts.txt). Changing wording here +# requires changing it there too. +# ============================================================================= + +DEFAULT_MAX_FILE_BYTES = 32768 +DEFAULT_MAX_ARTIFACT_BYTES = 65536 +DEFAULT_MAX_TOTAL_BYTES = 131072 + +DEFAULT_LIMITS: dict[str, int] = { + "max_file_bytes": DEFAULT_MAX_FILE_BYTES, + "max_artifact_bytes": DEFAULT_MAX_ARTIFACT_BYTES, + "max_total_bytes": DEFAULT_MAX_TOTAL_BYTES, +} + + +def _get_limits(repo_root: str) -> dict[str, int]: + """Load context-injection byte limits from config.yaml, with safe fallback.""" + 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.config import get_context_injection_limits # type: ignore[import-not-found] + + return get_context_injection_limits(Path(repo_root)) + except Exception: + return dict(DEFAULT_LIMITS) + + +def truncate_utf8(data: bytes, cap: int) -> bytes: + """Truncate ``data`` to at most ``cap`` bytes without splitting a UTF-8 + multi-byte sequence. + + ``cap <= 0`` means "no limit" — returns ``data`` unchanged. + """ + if cap <= 0 or len(data) <= cap: + return data + + truncated = data[:cap] + i = len(truncated) + # Back off over continuation bytes (10xxxxxx) to find the lead byte. + while i > 0 and (truncated[i - 1] & 0xC0) == 0x80: + i -= 1 + if i == 0: + return b"" + + lead = truncated[i - 1] + if lead & 0x80: + if (lead & 0xE0) == 0xC0: + seq_len = 2 + elif (lead & 0xF0) == 0xE0: + seq_len = 3 + elif (lead & 0xF8) == 0xF0: + seq_len = 4 + else: + seq_len = 1 + # Drop the lead byte too if its full sequence didn't fit. + if (i - 1) + seq_len > len(truncated): + i -= 1 + + return truncated[:i] + + +class _Budget: + """Tracks the running total of bytes emitted into the sub-agent context.""" + + def __init__(self, max_total_bytes: int) -> None: + self.max_total_bytes = max_total_bytes + self.used = 0 + + def has_room(self, size: int) -> bool: + if self.max_total_bytes <= 0: + return True + return self.used + size <= self.max_total_bytes + + def add(self, size: int) -> None: + self.used += size + + +def _read_file_bytes(base_path: str, file_path: str) -> bytes | None: + """Read raw file bytes, 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, "rb") as f: + return f.read() + except Exception: + return None + return None + + +def _truncate_notice(path: str, cap: int) -> str: + return f"\n[Trellis: truncated at {cap} bytes — read {path} for the full content]" + + +def _is_binary_content(data: bytes) -> bool: + """Return True when raw bytes should not be decoded into model context.""" + if b"\x00" in data: + return True + try: + data.decode("utf-8", errors="strict") + except UnicodeDecodeError: + return True + return False + + +def _binary_notice(path: str, size: int, reason: str) -> str: + return ( + f"[Trellis: not inlined (binary file) — " + f"{path} ({size} bytes): {reason}]" + ) + + +def _index_notice(path: str, size: int, reason: str) -> str: + return ( + f"[Trellis: not inlined (total context limit reached) — " + f"{path} ({size} bytes): {reason}]" + ) + + +def _budgeted_block( + budget: _Budget, + header: str, + plain_path: str, + content: str, + reason: str, + size_for_index: int, +) -> str: + """Return an inlined ``=== header ===`` block, or degrade to an index + notice once the total context budget is exhausted.""" + block = f"=== {header} ===\n{content}" + block_bytes = len(block.encode("utf-8")) + if not budget.has_room(block_bytes): + notice = _index_notice(plain_path, size_for_index, reason) + budget.add(len(notice.encode("utf-8"))) + return notice + budget.add(block_bytes) + return block + + +def _materialize_file( + base_path: str, + file_path: str, + reason: str, + limits: dict[str, int], + budget: _Budget, +) -> str | None: + """Read a JSONL-referenced file, apply the per-file cap, then budget it.""" + data = _read_file_bytes(base_path, file_path) + if data is None: + return None + + size = len(data) + if _is_binary_content(data): + notice = _binary_notice(file_path, size, reason) + budget.add(len(notice.encode("utf-8"))) + return notice + + cap = limits["max_file_bytes"] + truncated_bytes = truncate_utf8(data, cap) + content = truncated_bytes.decode("utf-8", errors="replace") + if len(truncated_bytes) < size: + content += _truncate_notice(file_path, cap) + + return _budgeted_block(budget, file_path, file_path, content, reason, size) + + +def _materialize_directory( + base_path: str, + dir_path: str, + reason: str, + limits: dict[str, int], + budget: _Budget, + max_files: int = 20, +) -> list[str]: + """Read all .md files in a directory, applying the same per-file and + total caps as a single-file JSONL entry.""" + full_path = os.path.join(base_path, dir_path) + if not os.path.exists(full_path) or not os.path.isdir(full_path): + return [] + + blocks: list[str] = [] + try: + 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]: + relative_path = os.path.join(dir_path, filename) + block = _materialize_file(base_path, relative_path, reason, limits, budget) + if block: + blocks.append(block) + except Exception: + pass + + return blocks + + +def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[dict]: + """ + Parse all file/directory entries referenced in a jsonl context 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: + [{"file": path, "type": "file" | "directory", "reason": reason}, ...] + """ + 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 task artifacts", + file=sys.stderr, + ) + return [] + + entries: list[dict] = [] + 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") + + if not file_path: + # Seed / comment row — skip silently + continue + + saw_real_entry = True + entries.append( + { + "file": file_path, + "type": item.get("type", "file"), + "reason": item.get("reason") or "-", + } + ) + 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"task artifacts. See workflow.md planning artifact guidance.", + file=sys.stderr, + ) + + return entries + + +def _materialize_jsonl_entries( + base_path: str, jsonl_path: str, limits: dict[str, int], budget: _Budget +) -> list[str]: + """Materialize every entry in a jsonl context file into context blocks, + applying per-file and total budget caps.""" + blocks: list[str] = [] + for entry in read_jsonl_entries(base_path, jsonl_path): + if entry["type"] == "directory": + blocks.extend( + _materialize_directory( + base_path, entry["file"], entry["reason"], limits, budget + ) + ) + else: + block = _materialize_file( + base_path, entry["file"], entry["reason"], limits, budget + ) + if block: + blocks.append(block) + return blocks + + +def get_agent_context( + repo_root: str, + task_dir: str, + agent_type: str, + limits: dict[str, int], + budget: _Budget, +) -> 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). + """ + agent_jsonl = f"{task_dir}/{agent_type}.jsonl" + blocks = _materialize_jsonl_entries(repo_root, agent_jsonl, limits, budget) + return "\n\n".join(blocks) + + +def _materialize_artifact( + base_path: str, + file_path: str, + header_label: str, + reason: str, + limits: dict[str, int], + budget: _Budget, +) -> str | None: + """Read a task artifact (prd/design/implement.md), apply the per-artifact + cap, then budget it.""" + data = _read_file_bytes(base_path, file_path) + if data is None: + return None + + size = len(data) + cap = limits["max_artifact_bytes"] + truncated_bytes = truncate_utf8(data, cap) + content = truncated_bytes.decode("utf-8", errors="replace") + if len(truncated_bytes) < size: + content += _truncate_notice(file_path, cap) + + return _budgeted_block(budget, header_label, file_path, content, reason, size) + + +def get_implement_context(repo_root: str, task_dir: str) -> str: + """ + Complete context for Implement Agent + + Read order: + 1. All files in implement.jsonl (spec/research manifests) + 2. prd.md (requirements) + 3. design.md if present (technical design) + 4. implement.md if present (execution plan) + """ + limits = _get_limits(repo_root) + budget = _Budget(limits["max_total_bytes"]) + context_parts = [] + + # 1. Read implement.jsonl + base_context = get_agent_context(repo_root, task_dir, "implement", limits, budget) + if base_context: + context_parts.append(base_context) + + # 2. Requirements document + prd_block = _materialize_artifact( + repo_root, + f"{task_dir}/prd.md", + f"{task_dir}/prd.md (Requirements)", + "Requirements document", + limits, + budget, + ) + if prd_block: + context_parts.append(prd_block) + + # 3. Technical design for complex tasks + design_block = _materialize_artifact( + repo_root, + f"{task_dir}/design.md", + f"{task_dir}/design.md (Technical Design)", + "Technical design document", + limits, + budget, + ) + if design_block: + context_parts.append(design_block) + + # 4. Execution plan for complex tasks + implement_plan_block = _materialize_artifact( + repo_root, + f"{task_dir}/implement.md", + f"{task_dir}/implement.md (Execution Plan)", + "Execution plan document", + limits, + budget, + ) + if implement_plan_block: + context_parts.append(implement_plan_block) + + return "\n\n".join(context_parts) + + +def get_check_context(repo_root: str, task_dir: str) -> str: + """ + Context for Check Agent: check.jsonl + task artifacts. + """ + limits = _get_limits(repo_root) + budget = _Budget(limits["max_total_bytes"]) + context_parts = [] + + base_context = get_agent_context(repo_root, task_dir, "check", limits, budget) + if base_context: + context_parts.append(base_context) + + prd_block = _materialize_artifact( + repo_root, + f"{task_dir}/prd.md", + f"{task_dir}/prd.md (Requirements)", + "Requirements document", + limits, + budget, + ) + if prd_block: + context_parts.append(prd_block) + + design_block = _materialize_artifact( + repo_root, + f"{task_dir}/design.md", + f"{task_dir}/design.md (Technical Design)", + "Technical design document", + limits, + budget, + ) + if design_block: + context_parts.append(design_block) + + implement_plan_block = _materialize_artifact( + repo_root, + f"{task_dir}/implement.md", + f"{task_dir}/implement.md (Execution Plan)", + "Execution plan document", + limits, + budget, + ) + if implement_plan_block: + context_parts.append(implement_plan_block) + + 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"""<!-- trellis-hook-injected --> +# 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 task artifacts** - Read requirements, technical design if present, and execution plan if present + 3. **Implement feature** - Implement following specs and task artifacts +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"""<!-- trellis-hook-injected --> +# 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"""<!-- trellis-hook-injected --> +# 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 task artifacts** - Check requirements in prd.md and, when present, design.md / implement.md +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 +- Verify design.md and implement.md constraints when those files are present""" + + + +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 _hook_event_name(input_data: dict) -> str: + """Return a hook event name from the documented snake/camel-case fields.""" + return _string_value( + input_data.get("hook_event_name") or input_data.get("hookEventName") + ) + + +def _codex_subagent_type(input_data: dict) -> str: + """Return a Trellis Codex agent type only for a native start event.""" + if _hook_event_name(input_data) != "SubagentStart": + return "" + agent_type = _string_value( + input_data.get("agent_type") or input_data.get("agentType") + ) + return agent_type if agent_type in AGENTS_ALL else "" + + +def build_codex_subagent_context( + subagent_type: str, + task_dir: str, + context: str, +) -> str: + """Build developer context for a native, already-dispatched Codex role.""" + role = subagent_type.removeprefix("trellis-") + return f"""<!-- trellis-hook-injected --> +# Trellis Native {role.title()} Subagent + +You are the dispatched `{subagent_type}` role for this task. Perform that role +directly; do not follow main-session dispatch or wait instructions, and do not +spawn another Trellis subagent. + +Active task: {task_dir} + +## Curated Context + +{context}""" + + +def _handle_codex_subagent_start(input_data: dict) -> None: + """Emit Codex developer context for a recognised native Trellis subagent. + + The event supplies the parent session id. Disabling the generic + single-session fallback is essential here: native starts must never borrow + a task from another Codex window when that parent id is absent or stale. + """ + subagent_type = _codex_subagent_type(input_data) + parent_session_id = _string_value(input_data.get("session_id")) + if not subagent_type or not parent_session_id: + return + + cwd = _string_value(input_data.get("cwd")) or os.getcwd() + repo_root = find_repo_root(cwd) + if not repo_root: + return + + task_dir = get_current_task( + repo_root, + {"session_id": parent_session_id}, + platform="codex", + allow_single_session_fallback=False, + allow_environment_context=False, + require_existing=True, + ) + if not task_dir: + return + + if subagent_type in AGENTS_REQUIRE_TASK: + task_dir_full = Path(repo_root) / task_dir + if not task_dir_full.is_dir(): + return + + if subagent_type == AGENT_IMPLEMENT: + context = get_implement_context(repo_root, task_dir) + elif subagent_type == AGENT_CHECK: + context = get_check_context(repo_root, task_dir) + else: + context = get_research_context(repo_root, task_dir) + + if not context: + return + + output = { + "hookSpecificOutput": { + "hookEventName": "SubagentStart", + "additionalContext": build_codex_subagent_context( + subagent_type, task_dir, context + ), + } + } + print(json.dumps(output, ensure_ascii=False)) + + +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) + - ZCode: toolName=Agent, toolInput/tool_input.subagent_type + - 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", {}) + if not isinstance(tool_input, dict): + tool_input = input_data.get("toolInput", {}) + if not isinstance(tool_input, dict): + 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) + if not isinstance(input_data, dict): + sys.exit(0) + + if _hook_event_name(input_data) == "SubagentStart": + try: + _handle_codex_subagent_start(input_data) + except Exception: + # A native context hook must never prevent Codex from spawning the + # requested child when its runtime state is unavailable or stale. + pass + 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. Most platforms ignore unrecognized fields, so we + # include multiple formats. ZCode is stricter; live probing confirmed the + # nested Claude-compatible shape below reaches the sub-agent prompt. + updated = {**tool_input, "prompt": new_prompt} + if _detect_platform(input_data) == "zcode": + output = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "updatedInput": updated, + } + } + else: + 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 100644 index 0000000..ab8e276 --- /dev/null +++ b/.claude/hooks/inject-workflow-state.py @@ -0,0 +1,465 @@ +#!/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 <workflow-state> +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). Kiro wires this via the CLI +custom agent's ``hooks.userPromptSubmit`` and the IDE ``.kiro.hook`` +``promptSubmit`` event; its output branch emits a plain-text breadcrumb +(Kiro adds hook stdout directly to the conversation context). 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 +import queue +import threading +from pathlib import Path + +# Force UTF-8 on stdin/stdout/stderr on Windows. Default codepage there is +# cp936 / cp1252 / etc. — non-ASCII content (Chinese task names, prd snippets) +# both in stdin (hook payload from host CLI) and stdout (our emitted blocks) +# raises UnicodeDecodeError / UnicodeEncodeError. Equivalent to `python -X utf8` +# but applied per-stream so we don't depend on host CLI's command wiring. +if sys.platform.startswith("win"): + import io as _io + for _stream_name in ("stdin", "stdout", "stderr"): + _stream = getattr(sys, _stream_name, None) + if _stream is None: + continue + if hasattr(_stream, "reconfigure"): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + except Exception: + pass # Optional Windows stream setup; keep hook startup non-fatal. + elif hasattr(_stream, "detach"): + try: + setattr(sys, _stream_name, _io.TextIOWrapper(_stream.detach(), encoding="utf-8", errors="replace")) + except Exception: + pass # Optional Windows stream setup; keep hook startup non-fatal. +from typing import Optional + + +# Bootstrap notice for Codex while the session has no active task. Codex does not +# get the full SessionStart overview; this short reminder points the main session +# at the start skill once and leaves the per-turn state block compact. +CODEX_NO_TASK_BOOTSTRAP_NOTICE = """<trellis-bootstrap> +If you have not already loaded Trellis context this session, read the `trellis-start` skill once. +</trellis-bootstrap>""" + + +# --------------------------------------------------------------------------- +# 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 = { + # ZCode may set both ZCODE_PROJECT_DIR and CLAUDE_PROJECT_DIR; check + # ZCODE first so ZCode sessions aren't misdetected as claude. + "ZCODE_PROJECT_DIR": "zcode", + "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", + "TRAE_PROJECT_DIR": "trae", + } + 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" + if ".trae" in script_parts: + return "trae" + if ".zcode" in script_parts: + return "zcode" + 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 _read_trellis_config(root: Path) -> dict: + """Load .trellis/config.yaml via the bundled trellis_config helper. + + The helper lives in .trellis/scripts/common; the hook lives outside the + scripts tree, so we extend sys.path before importing. + """ + scripts_dir = root / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.trellis_config import read_trellis_config # type: ignore[import-not-found] + except Exception: + return {} + try: + return read_trellis_config(root) + except Exception: + return {} + + +DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD = "no-trellis" + + +def _resolve_skip_keyword(config: dict) -> str: + """Read `prompt_injection.skip_keyword` from parsed .trellis/config.yaml. + + Mirrors `common.config.get_prompt_injection_config()`. Defaults to + "no-trellis"; "" disables the escape hatch entirely. A non-string value + falls back to the default. + """ + if isinstance(config, dict): + section = config.get("prompt_injection") + if isinstance(section, dict): + raw = section.get("skip_keyword", DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD) + if isinstance(raw, str): + return raw + return DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD + + +def prompt_has_skip_keyword(prompt: str, keyword: str) -> bool: + """Case-insensitive, word-boundary match of `keyword` in `prompt`. + + Hyphen counts as a word char so "no-trellisx" / "xno-trellis" / + "foo-no-trellis" don't match, but punctuation/whitespace boundaries do. + Empty keyword never matches (disables the escape hatch). + """ + if not keyword or not isinstance(prompt, str): + return False + pattern = r"(?<![\w-])" + re.escape(keyword) + r"(?![\w-])" + return re.search(pattern, prompt, re.IGNORECASE) is not None + + +def _resolve_codex_dispatch_mode(config: dict) -> str: + """Normalize `codex.dispatch_mode` from .trellis/config.yaml to "auto" or "inline". + + Defaults to `auto`. The legacy `sub-agent` value is an alias for `auto`. + Any other explicit value (including invalid ones) falls back to `inline` + without per-turn warnings. Shared by `_codex_mode_banner` (the per-turn + banner) and `resolve_breadcrumb_key` (the breadcrumb tag key) so the two + stay in lockstep. + """ + mode = "auto" + if isinstance(config, dict): + codex_cfg = config.get("codex") + if isinstance(codex_cfg, dict): + cfg_mode = str(codex_cfg.get("dispatch_mode", mode)).strip().lower() + if cfg_mode == "inline": + mode = "inline" + elif cfg_mode in ("auto", "sub-agent"): + mode = "auto" + else: + mode = "inline" + return mode + + +def _codex_mode_banner(config: dict) -> str: + """Emit a `<codex-mode>` banner for the additionalContext payload. + + Reads `codex.dispatch_mode` from .trellis/config.yaml; defaults to + `auto`, which dispatches Trellis sub-agents using native Codex context + injection with a child-side fallback. This does not rely on inherited + parent transcripts: `fork_turns` remains caller-controlled, and + fresh-history sub-agents still receive their explicit delegated task and + inherited session configuration. `inline` is an explicit opt-out; the + legacy `sub-agent` value is an alias for `auto`. Invalid explicit values + fall back to `inline` without per-turn warnings. The banner makes the + active mode explicit to Codex AI per turn, complementing the workflow-state + body which is per-status. Mode tells AI which dispatch protocol to follow; + workflow-state tells AI what step it's at. + """ + mode = _resolve_codex_dispatch_mode(config) + if mode == "auto": + meaning = ( + "auto: implement/check work defaults to Trellis sub-agents; native Codex " + "context injection is preferred and child-side loading is the fallback. " + "The main session still coordinates, clarifies, updates specs, commits, and finishes." + ) + else: + meaning = ( + "inline: the main session implements/checks directly; " + "do not dispatch implement/check sub-agents." + ) + return f"<codex-mode>{meaning}</codex-mode>" + + +def resolve_breadcrumb_key( + status: str, platform: str | None, config: dict +) -> str: + """Pick the breadcrumb tag key based on Codex dispatch_mode. + + Codex defaults to ``auto`` and therefore uses the ordinary ``<status>`` + breadcrumb for native SubagentStart dispatch with child-side fallback; + it does not depend on an inherited parent transcript. ``inline`` selects + the parallel ``<status>-inline`` tag; ``sub-agent`` remains an alias for + ``auto``. Invalid explicit values fall back to inline without per-turn + warnings. + + Non-codex platforms return the plain status unchanged. + """ + if platform == "codex": + mode = _resolve_codex_dispatch_mode(config) + return f"{status}-inline" if mode == "inline" else status + return status + + +def build_breadcrumb( + task_id: Optional[str], + status: str, + templates: dict[str, str], + source: str | None = None, + breadcrumb_key: str | None = None, +) -> str: + """Build the <workflow-state>...</workflow-state> 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 + """ + lookup_key = breadcrumb_key or status + body = templates.get(lookup_key) + if body is None and lookup_key != status: + 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})" + return f"<workflow-state>\n{header}\n{body}\n</workflow-state>" + + +# --------------------------------------------------------------------------- +# Entry +# --------------------------------------------------------------------------- + +def _load_hook_input() -> dict: + """Read hook JSON without trusting host runners to close stdin. + + Kiro IDE `runCommand` and similar hook runners can leave stdin open while + sending no payload. A plain `json.load(sys.stdin)` then blocks forever. + Normal hook runners write the complete JSON payload and close stdin, so the + short daemon read preserves that path while failing closed to `{}` for + non-piping hosts. + """ + result_queue: "queue.Queue[str | Exception]" = queue.Queue(maxsize=1) + + def _read() -> None: + try: + result_queue.put(sys.stdin.read()) + except Exception as exc: + result_queue.put(exc) + + reader = threading.Thread(target=_read, daemon=True) + reader.start() + try: + raw = result_queue.get(timeout=0.2) + except queue.Empty: + return {} + + if isinstance(raw, Exception): + return {} + try: + data = json.loads(raw) if raw.strip() else {} + except (json.JSONDecodeError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def main() -> int: + if os.environ.get("TRELLIS_HOOKS") == "0" or os.environ.get("TRELLIS_DISABLE_HOOKS") == "1": + return 0 + + data = _load_hook_input() + + 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 + + config = _read_trellis_config(root) + if prompt_has_skip_keyword(data.get("prompt", ""), _resolve_skip_keyword(config)): + return 0 # user opted out of the per-turn breadcrumb for this turn + + templates = load_breadcrumbs(root) + platform = _detect_platform(data) + 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. + no_task_key = resolve_breadcrumb_key("no_task", platform, config) + breadcrumb = build_breadcrumb( + None, "no_task", templates, breadcrumb_key=no_task_key + ) + else: + task_id, status, source = task + status_key = resolve_breadcrumb_key(status, platform, config) + source_for_breadcrumb = None if platform == "codex" else source + breadcrumb = build_breadcrumb( + task_id, status, templates, source_for_breadcrumb, breadcrumb_key=status_key + ) + if platform == "codex": + parts: list[str] = [] + if task is None: + parts.append(CODEX_NO_TASK_BOOTSTRAP_NOTICE) + parts.append(_codex_mode_banner(config)) + parts.append(breadcrumb) + breadcrumb = "\n\n".join(parts) + + # Kiro (CLI userPromptSubmit / IDE promptSubmit) adds a hook's stdout + # directly to the conversation context — no JSON envelope. Emit the bare + # breadcrumb text. Conditionally isolated: all other platforms keep the + # hookSpecificOutput JSON path below unchanged. + if platform == "kiro": + print(breadcrumb) + return 0 + + # 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 platform == "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 100644 index 0000000..a18e57b --- /dev/null +++ b/.claude/hooks/session-start.py @@ -0,0 +1,864 @@ +#!/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) + rest = rest.replace('/', '\\') + return f"{drive}:\\{rest}" + + # 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) + rest = rest.replace('/', '\\') + return f"{drive}:\\{rest}" + + # 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) + rest = rest.replace('/', '\\') + return f"{drive}:\\{rest}" + + return path_str + + +FIRST_REPLY_NOTICE = """<first-reply-notice> +On the first visible assistant reply in this session, briefly acknowledge that Trellis SessionStart context loaded. +Choose the acknowledgment language in this order: +1. Use the language of the user's current request (the user message that triggered this reply). +2. If that request has no clear natural language, use an explicitly established project communication language. +3. If neither provides a language, output the language-neutral fallback exactly: `Trellis SessionStart ✓`. +Continue directly with the user's request after the acknowledgment. +The acknowledgment must not alter the language used for the remainder of the response. +This notice is one-shot: do not repeat it after the first visible assistant reply in this session. +</first-reply-notice>""" + +# Force UTF-8 on stdin/stdout/stderr on Windows. Default codepage there is +# cp936 / cp1252 / etc. — non-ASCII content (Chinese task names, prd snippets) +# both in stdin (hook payload from host CLI) and stdout (our emitted blocks) +# raises UnicodeDecodeError / UnicodeEncodeError. Equivalent to `python -X utf8` +# but applied per-stream so we don't depend on host CLI's command wiring. +if sys.platform.startswith("win"): + import io as _io + for _stream_name in ("stdin", "stdout", "stderr"): + _stream = getattr(sys, _stream_name, None) + if _stream is None: + continue + if hasattr(_stream, "reconfigure"): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + except Exception: + pass # Optional Windows stream setup; keep hook startup non-fatal. + elif hasattr(_stream, "detach"): + try: + setattr(sys, _stream_name, _io.TextIOWrapper(_stream.detach(), encoding="utf-8", errors="replace")) + except Exception: + pass # Optional Windows stream setup; keep hook startup non-fatal. + + + +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", + "TRAE_NON_INTERACTIVE", + "ZCODE_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 _repo_relative(repo_root: Path, path: Path) -> str: + try: + return path.relative_to(repo_root).as_posix() + except ValueError: + return str(path) + + +def _run_git(repo_root: Path, args: list[str]) -> str: + try: + result = subprocess.run( + ["git", *args], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=3, + cwd=str(repo_root), + ) + except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError): + return "" + if result.returncode != 0: + return "" + return result.stdout.strip() + + +def _format_git_state(repo_root: Path) -> str: + branch = _run_git(repo_root, ["branch", "--show-current"]) or "(detached)" + dirty_lines = [ + line for line in _run_git(repo_root, ["status", "--porcelain"]).splitlines() + if line.strip() + ] + dirty_text = "clean" if not dirty_lines else f"dirty {len(dirty_lines)} paths" + return f"Git: branch {branch}; {dirty_text}." + + +def _detect_platform(input_data: dict) -> str | None: + if isinstance(input_data.get("cursor_version"), str): + return "cursor" + env_map = { + # ZCode may set both ZCODE_PROJECT_DIR and CLAUDE_PROJECT_DIR; check + # ZCODE first so ZCode sessions aren't misdetected as claude. + "ZCODE_PROJECT_DIR": "zcode", + "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", + "TRAE_PROJECT_DIR": "trae", + } + 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" + if ".trae" in script_parts: + return "trae" + if ".zcode" in script_parts: + return "zcode" + 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 # Optional shell bridge; keep session-start non-fatal. + + +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: + """Return compact active-task status, artifact presence, and next action.""" + active = _resolve_active_task(trellis_dir, input_data) + + if not active.task_path: + return ( + "Status: NO ACTIVE TASK\n" + "Next-Action: Classify the current turn before creating any Trellis task. " + "Simple conversation / small task asks only whether this turn should create a Trellis task. " + "Complex task asks whether task creation and planning are allowed." + ) + + 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"Next-Action: Run `python3 ./.trellis/scripts/task.py finish` to clear the stale pointer, " + "then ask the user what to work on next." + ) + + 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 # Optional task metadata; fall back to generic status. + + task_title = task_data.get("title", task_ref) + task_status = task_data.get("status", "unknown") + artifact_names = ("prd.md", "design.md", "implement.md", "implement.jsonl", "check.jsonl") + present = [name for name in artifact_names if (task_dir / name).is_file()] + if (task_dir / "research").is_dir(): + present.append("research/") + present_line = ", ".join(present) if present else "(none)" + + if task_status == "completed": + return ( + f"Status: COMPLETED\nTask: {task_title}\n" + f"Present: {present_line}\n" + "Next-Action: Run `/trellis:finish-work`. If the working tree is dirty, return to Phase 3.4 first." + ) + + has_prd = (task_dir / "prd.md").is_file() + has_design = (task_dir / "design.md").is_file() + has_implement_plan = (task_dir / "implement.md").is_file() + implement_jsonl = task_dir / "implement.jsonl" + check_jsonl = task_dir / "check.jsonl" + jsonl_ready = ( + (not implement_jsonl.is_file() or _has_curated_jsonl_entry(implement_jsonl)) + and (not check_jsonl.is_file() or _has_curated_jsonl_entry(check_jsonl)) + ) + + if task_status == "planning" and not has_prd: + return ( + f"Status: PLANNING\nTask: {task_title}\n" + f"Present: {present_line}\n" + "Next-Action: Load `trellis-brainstorm` and write `prd.md`. Stay in planning." + ) + + if task_status == "planning": + missing_complex = [ + name for name, exists in ( + ("design.md", has_design), + ("implement.md", has_implement_plan), + ) + if not exists + ] + next_bits: list[str] = [] + if missing_complex: + next_bits.append( + "Lightweight task can request start review with PRD-only; " + f"complex task must add {', '.join(missing_complex)} before start" + ) + else: + next_bits.append("Planning artifacts are present; ask for review before `task.py start`") + if not jsonl_ready: + next_bits.append("curate `implement.jsonl` and `check.jsonl` before sub-agent mode start") + return ( + f"Status: PLANNING\nTask: {task_title}\n" + f"Present: {present_line}\n" + f"Next-Action: {'; '.join(next_bits)}. Do not enter implementation until the user confirms start." + ) + + return ( + f"Status: {str(task_status).upper()}\nTask: {task_title}\n" + f"Present: {present_line}\n" + "Next-Action: Follow the matching per-turn workflow-state. " + "Implementation/check context order is jsonl entries -> `prd.md` -> `design.md if present` -> `implement.md if present`." + ) + + +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 # Optional package metadata; fall back to default scope. + + default_pkg = get_default_package(repo_root) + return is_mono, packages, scope, task_pkg, default_pkg + except Exception: + return False, {}, None, None, None + + +def _check_legacy_spec(trellis_dir: Path, is_mono: bool, packages: dict) -> str | None: + """Check for legacy spec directory structure in monorepo. + + Returns warning message if legacy structure detected, None otherwise. + """ + if not is_mono or not packages: + return None + + spec_dir = trellis_dir / "spec" + if not spec_dir.is_dir(): + return None + + # Check for legacy flat spec dirs (spec/backend/, spec/frontend/ with index.md) + has_legacy = False + for legacy_name in ("backend", "frontend"): + legacy_dir = spec_dir / legacy_name + if legacy_dir.is_dir() and (legacy_dir / "index.md").is_file(): + has_legacy = True + break + + if not has_legacy: + return None + + # Check which packages are missing spec/<pkg>/ directory + missing = [ + name for name in sorted(packages.keys()) + if not (spec_dir / name).is_dir() + ] + + if not missing: + return None # All packages have spec dirs + + if len(missing) == len(packages): + return ( + f"[!] Legacy spec structure detected: found `spec/backend/` or `spec/frontend/` " + f"but no package-scoped `spec/<package>/` directories.\n" + f"Monorepo packages: {', '.join(sorted(packages.keys()))}\n" + f"Please reorganize: `spec/backend/` -> `spec/<package>/backend/`" + ) + return ( + f"[!] Partial spec migration detected: packages {', '.join(missing)} " + f"still missing `spec/<pkg>/` directory.\n" + f"Please complete migration for all packages." + ) + + +def _resolve_spec_scope( + is_mono: bool, + packages: dict, + scope, + task_pkg: str | None, + default_pkg: str | None, +) -> set | None: + """Resolve which packages should have their specs injected. + + Returns: + Set of package names to include, or None for full scan. + """ + if not is_mono or not packages: + return None # Single-repo: full scan + + if scope is None: + return None # No scope configured: full scan + + if isinstance(scope, str) and scope == "active_task": + if task_pkg and task_pkg in packages: + return {task_pkg} + if default_pkg and default_pkg in packages: + return {default_pkg} + return None # Fallback to full scan + + if isinstance(scope, list): + valid = set() + for entry in scope: + if entry in packages: + valid.add(entry) + else: + print( + f"Warning: spec_scope contains unknown package: {entry}, ignoring", + file=sys.stderr, + ) + + if valid: + # Warn if active task is out of scope + if task_pkg and task_pkg not in valid: + print( + f"Warning: active task package '{task_pkg}' is out of configured spec_scope", + file=sys.stderr, + ) + return valid + + # All entries invalid: fallback chain + print( + "Warning: all spec_scope entries invalid, falling back to task/default/full", + file=sys.stderr, + ) + if task_pkg and task_pkg in packages: + return {task_pkg} + if default_pkg and default_pkg in packages: + return {default_pkg} + return None # Full scan + + return None # Unknown scope type: full scan + + +def _collect_spec_index_paths(trellis_dir: Path, allowed_pkgs: set | None) -> list[str]: + paths: list[str] = [] + guides_index = trellis_dir / "spec" / "guides" / "index.md" + if guides_index.is_file(): + paths.append(".trellis/spec/guides/index.md") + + spec_dir = trellis_dir / "spec" + if not spec_dir.is_dir(): + return paths + + for sub in sorted(spec_dir.iterdir()): + if not sub.is_dir() or sub.name.startswith(".") or sub.name == "guides": + continue + + index_file = sub / "index.md" + if index_file.is_file(): + paths.append(f".trellis/spec/{sub.name}/index.md") + continue + + 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") + + return paths + + +def _build_compact_current_state( + trellis_dir: Path, + input_data: dict, + spec_index_paths: list[str], +) -> str: + repo_root = trellis_dir.parent + lines: list[str] = [] + + try: + from common.paths import get_active_journal_file, get_developer, get_tasks_dir, count_lines # type: ignore[import-not-found] + from common.tasks import iter_active_tasks # type: ignore[import-not-found] + except Exception: + get_active_journal_file = None # type: ignore[assignment] + get_developer = None # type: ignore[assignment] + get_tasks_dir = None # type: ignore[assignment] + count_lines = None # type: ignore[assignment] + iter_active_tasks = None # type: ignore[assignment] + + developer = get_developer(repo_root) if get_developer else None + lines.append(f"Developer: {developer or '(not initialized)'}") + lines.append(_format_git_state(repo_root)) + + active = _resolve_active_task(trellis_dir, input_data) + if active.task_path: + task_dir = _resolve_task_dir(trellis_dir, active.task_path) + status = "unknown" + task_json = task_dir / "task.json" + if task_json.is_file(): + try: + data = json.loads(task_json.read_text(encoding="utf-8")) + if isinstance(data, dict): + status = str(data.get("status") or "unknown") + except (json.JSONDecodeError, OSError): + pass # Optional task metadata; fall back to generic status. + lines.append(f"Current task: {_repo_relative(repo_root, task_dir)}; status={status}.") + else: + lines.append("Current task: none.") + + if get_tasks_dir and iter_active_tasks: + try: + task_count = sum(1 for _ in iter_active_tasks(get_tasks_dir(repo_root))) + lines.append( + f"Active tasks: {task_count} total. Use `python3 ./.trellis/scripts/task.py list --mine` only if needed." + ) + except Exception: + pass # Optional task summary; keep compact state available. + + if get_active_journal_file and count_lines: + journal = get_active_journal_file(repo_root) + if journal: + lines.append( + f"Journal: {_repo_relative(repo_root, journal)}, {count_lines(journal)} / 2000 lines." + ) + + if spec_index_paths: + lines.append(f"Spec indexes: {len(spec_index_paths)} available.") + + return "\n".join(lines) + + +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. + """ + stripped = _BREADCRUMB_TAG_RE.sub("", content) + stripped = re.sub(r"<!--.*?-->", "", stripped, flags=re.DOTALL) + stripped = re.sub(r"^\[(?!/?workflow-state:)/?[^\]\n]+\]\s*\n?", "", stripped, flags=re.MULTILINE) + return re.sub(r"\n{3,}", "\n\n", stripped).strip() + + +def _build_workflow_overview(workflow_path: Path) -> str: + """Inject only the compact Phase Index summary for SessionStart.""" + content = read_file(workflow_path) + if not content: + return "No workflow.md found" + + out_lines = [ + "# Development Workflow - Session Summary", + "Full guide: .trellis/workflow.md. Step detail: `python3 ./.trellis/scripts/get_context.py --mode phase --step <X.Y>`.", + "", + ] + + phases = _extract_range(content, "Phase Index", "Phase 1: Plan") + 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", + "TRAE_PROJECT_DIR", + "ZCODE_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() + + spec_index_paths = _collect_spec_index_paths(trellis_dir, allowed_pkgs) + + output.write("""<session-context> +Trellis compact SessionStart context. Use it to orient the session; load details on demand. +</session-context> + +""") + 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"<migration-warning>\n{legacy_warning}\n</migration-warning>\n\n") + + output.write("<current-state>\n") + output.write(_build_compact_current_state(trellis_dir, hook_input, spec_index_paths)) + output.write("\n</current-state>\n\n") + + output.write("<trellis-workflow>\n") + output.write(_build_workflow_overview(trellis_dir / "workflow.md")) + output.write("\n</trellis-workflow>\n\n") + + output.write("<guidelines>\n") + output.write( + "Task context order for implementation/check: jsonl entries -> `prd.md` -> " + "`design.md if present` -> `implement.md if present`. Missing optional artifacts " + "are skipped for lightweight tasks.\n\n" + ) + + if spec_index_paths: + output.write("## Available indexes (read on demand)\n") + for p in spec_index_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("</guidelines>\n\n") + + # Check task status and inject structured tag + task_status = _get_task_status(trellis_dir, hook_input) + output.write(f"<task-status>\n{task_status}\n</task-status>\n\n") + + output.write("""<ready> +Context loaded. Follow <task-status>. Load workflow/spec/task details only when needed. +</ready>""") + + context_text = output.getvalue() + + # Kiro (CLI trellis agent agentSpawn) adds a hook's stdout directly to the + # conversation context — no JSON envelope. Emit the bare overview text. + # Conditionally isolated: all other platforms keep the JSON path below. + if _detect_platform(hook_input) == "kiro": + print(context_text, flush=True) + return + + platform = _detect_platform(hook_input) + result: dict[str, object] = { + # Claude Code / Qoder / CodeBuddy / Droid / Gemini / Copilot / Trae / + # ZCode format. + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": context_text, + }, + } + # Cursor sessionStart format (top-level snake_case per Cursor docs). + # ZCode reads BOTH `hookSpecificOutput.additionalContext` and top-level + # `additional_context` without deduplication, so emitting both keys would + # duplicate the context in the conversation. Keep the previous shared output + # shape for every other platform. + if platform != "zcode": + result["additional_context"] = context_text + + # 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/hooks/statusline.py b/.claude/hooks/statusline.py new file mode 100644 index 0000000..6b398ea --- /dev/null +++ b/.claude/hooks/statusline.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Trellis StatusLine — project-level status display for Claude Code. + +Reads Claude Code session JSON from stdin + Trellis task data from filesystem. +Outputs 1-2 lines: + With active task: [P1] Task title (status) + info line + Without task: info line only +Info line: model · ctx% · branch · duration · developer · tasks · rate limits +When COLUMNS (injected by Claude Code v2.1.153+) is too narrow for the info +line, the rate-limit segments move to their own line via an explicit "\n". +""" +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import time +from datetime import datetime +from pathlib import Path + +# Hook hosts send UTF-8 JSON regardless of the process locale. +_stdin_reconfigure = getattr(sys.stdin, "reconfigure", None) +if callable(_stdin_reconfigure): + try: + _stdin_reconfigure(encoding="utf-8", errors="replace") + except (OSError, ValueError): + pass + +# Fix: Windows Python defaults to GBK encoding, which corrupts UTF-8 +# characters like the middle dot (·). Wrap stdout/stderr with UTF-8. +if sys.platform == "win32": + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if callable(reconfigure): + reconfigure(encoding="utf-8", errors="replace") + + +def _read_text(path: Path) -> str: + try: + return path.read_text(encoding="utf-8").strip() + except (FileNotFoundError, PermissionError, OSError): + return "" + + +def _read_json(path: Path) -> dict: + text = _read_text(path) + if not text: + return {} + try: + return json.loads(text) + except (json.JSONDecodeError, ValueError): + return {} + + +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 _find_trellis_dir() -> Path | None: + """Walk up from cwd to find .trellis/ directory.""" + current = Path.cwd() + for parent in [current, *current.parents]: + candidate = parent / ".trellis" + if candidate.is_dir(): + return candidate + return None + + +def _get_current_task(trellis_dir: Path) -> dict | None: + """Load current task info through Trellis' active task resolver.""" + return _get_current_task_for_input(trellis_dir, {}) + + +def _get_current_task_for_input(trellis_dir: Path, cc_data: dict) -> dict | None: + """Load current task info for the Claude Code session JSON.""" + scripts_dir = trellis_dir / "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(trellis_dir.parent, cc_data, platform="claude") + if not active.task_path: + return None + + task_path = _resolve_task_dir(trellis_dir, active.task_path) + if active.stale: + return { + "title": task_path.name, + "status": "stale", + "priority": "P?", + "source": active.source, + } + + task_data = _read_json(task_path / "task.json") + if not task_data: + return None + + return { + "title": task_data.get("title") or task_data.get("name") or "unknown", + "status": task_data.get("status", "unknown"), + "priority": task_data.get("priority", "P2"), + "source": active.source, + } + + +def _count_active_tasks(trellis_dir: Path) -> int: + """Count non-archived task directories with valid task.json.""" + tasks_dir = trellis_dir / "tasks" + if not tasks_dir.is_dir(): + return 0 + count = 0 + for d in tasks_dir.iterdir(): + if d.is_dir() and d.name != "archive" and (d / "task.json").is_file(): + count += 1 + return count + + +def _get_developer(trellis_dir: Path) -> str: + content = _read_text(trellis_dir / ".developer") + if not content: + return "unknown" + for line in content.splitlines(): + if line.startswith("name="): + return line[5:].strip() + return content.splitlines()[0].strip() or "unknown" + + +def _get_git_branch() -> str: + try: + result = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, text=True, timeout=3, + ) + return result.stdout.strip() if result.returncode == 0 else "" + except (FileNotFoundError, subprocess.TimeoutExpired): + return "" + + +def _format_ctx_size(size: int) -> str: + if size >= 1_000_000: + return f"{size // 1_000_000}M" + if size >= 1_000: + return f"{size // 1_000}K" + return str(size) + + +def _format_duration(ms: int) -> str: + secs = ms // 1000 + hours, remainder = divmod(secs, 3600) + mins = remainder // 60 + if hours > 0: + return f"{hours}h{mins}m" + return f"{mins}m" + + +def _format_remaining(secs: int) -> str: + if secs <= 0: + return "" + days, remainder = divmod(secs, 86400) + hours, remainder = divmod(remainder, 3600) + mins = remainder // 60 + if days > 0: + return f"{days}d{hours}h" + if hours > 0: + return f"{hours}h{mins}m" + return f"{mins}m" + + +def _parse_resets_at(value: object) -> int: + """`resets_at` is epoch seconds (int/float, possibly stringified) or an + ISO-8601 timestamp depending on Claude Code version. Return epoch + seconds, or 0 when absent/unparseable (countdown is then omitted).""" + if isinstance(value, (int, float)): + return int(value) + if isinstance(value, str) and value: + try: + return int(float(value)) + except ValueError: + pass + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + return int(parsed.timestamp()) + except ValueError: + pass + return 0 + + +def _rate_limit_part(label: str, window: dict, now: int) -> str: + try: + pct = int(float(window.get("used_percentage"))) # pyright: ignore[reportArgumentType] + except (TypeError, ValueError): + return "" + part = f"{label} {pct}%" + remaining = _format_remaining(_parse_resets_at(window.get("resets_at")) - now) + if remaining: + part += f" \033[90m(reset {remaining})\033[0m" + return part + + +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + + +def _visible_len(s: str) -> int: + """Length of s with ANSI escape sequences stripped.""" + return len(_ANSI_RE.sub("", s)) + + +def _terminal_width() -> int | None: + """Terminal width from the COLUMNS env var, or None. + + The statusline stdin JSON has no width field and stdout is a pipe, so + the COLUMNS env var (injected by Claude Code v2.1.153+) is the only + width signal. Absent or malformed values return None.""" + try: + width = int(os.environ.get("COLUMNS", "")) + except ValueError: + return None + return width if width > 0 else None + + +def main() -> None: + # Read Claude Code session JSON from stdin + try: + cc_data = json.loads(sys.stdin.read()) + except (json.JSONDecodeError, ValueError): + cc_data = {} + + trellis_dir = _find_trellis_dir() + SEP = " \033[90m·\033[0m " + + # --- Trellis data --- + task = _get_current_task_for_input(trellis_dir, cc_data) if trellis_dir else None + dev = _get_developer(trellis_dir) if trellis_dir else "" + task_count = _count_active_tasks(trellis_dir) if trellis_dir else 0 + + # --- CC session data --- + model = cc_data.get("model", {}).get("display_name", "?") + ctx_pct = int(cc_data.get("context_window", {}).get("used_percentage") or 0) + ctx_size = _format_ctx_size(cc_data.get("context_window", {}).get("context_window_size") or 0) + duration = _format_duration(cc_data.get("cost", {}).get("total_duration_ms") or 0) + branch = _get_git_branch() + + # Avoid "Opus 4.6 (1M context) (1M)" + if re.search(r"\d+[KMG]\b", model, re.IGNORECASE): + model_label = model + else: + model_label = f"{model} ({ctx_size})" + + # Context % with color + if ctx_pct >= 90: + ctx_color = "\033[31m" + elif ctx_pct >= 70: + ctx_color = "\033[33m" + else: + ctx_color = "\033[32m" + + # Build info line: model · ctx · branch · duration · dev · tasks [· rate limits] + parts = [ + model_label, + f"ctx {ctx_color}{ctx_pct}%\033[0m", + ] + if branch: + parts.append(f"\033[35m{branch}\033[0m") + parts.append(duration) + if dev: + parts.append(f"\033[32m{dev}\033[0m") + if task_count: + parts.append(f"{task_count} task(s)") + + now = int(time.time()) + rate_limits = cc_data.get("rate_limits", {}) + rate_parts: list[str] = [] + for label, key in (("5h", "five_hour"), ("7d", "seven_day")): + part = _rate_limit_part(label, rate_limits.get(key) or {}, now) + if part: + rate_parts.append(part) + + info_line = SEP.join(parts + rate_parts) + + # Output: task line (only if active) + info line + if task: + source = str(task.get("source") or "") + source_tag = "session" if source.startswith("session:") else source + source_suffix = f" \033[90m[{source_tag}]\033[0m" if source_tag else "" + print(f"\033[36m[{task['priority']}]\033[0m {task['title']} \033[33m({task['status']})\033[0m{source_suffix}") + + # Claude Code's status-bar height counts only "\n" characters, so a + # visually wrapped long line misaligns rows. When the host provides a + # terminal width and the info line would overflow, split the rate-limit + # segments onto their own line with an explicit "\n" instead. + width = _terminal_width() + if width is not None and rate_parts and _visible_len(info_line) > width: + print(SEP.join(parts)) + print(SEP.join(rate_parts)) + else: + print(info_line) + + +if __name__ == "__main__": + main() diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..f8a5472 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,77 @@ +{ + "env": { + "CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR": "1" + }, + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/session-start.py", + "timeout": 30 + } + ] + }, + { + "matcher": "clear", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/session-start.py", + "timeout": 30 + } + ] + }, + { + "matcher": "compact", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/session-start.py", + "timeout": 30 + } + ] + } + ], + "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": 15 + } + ] + } + ] + }, + "enabledPlugins": {}, + "statusLine": { + "type": "command", + "command": "python3 .claude/hooks/statusline.py" + } +} diff --git a/.claude/skills/trellis-before-dev/SKILL.md b/.claude/skills/trellis-before-dev/SKILL.md new file mode 100644 index 0000000..5a4b852 --- /dev/null +++ b/.claude/skills/trellis-before-dev/SKILL.md @@ -0,0 +1,40 @@ +--- +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. **Read current task artifacts**: + - `prd.md` for requirements and acceptance criteria + - `design.md` if present for technical design + - `implement.md` if present for execution order and validation plan + +2. **Discover packages and their spec layers**: + ```bash + python3 ./.trellis/scripts/get_context.py --mode packages + ``` + +3. **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.) + - Any spec/research paths referenced by the task artifacts + +4. **Read the spec index** for each relevant module: + ```bash + cat .trellis/spec/<package>/<layer>/index.md + ``` + Follow the **"Pre-Development Checklist"** section in the index. + +5. **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. + +6. **Always read shared guides**: + ```bash + cat .trellis/spec/guides/index.md + ``` + +7. Understand the coding standards and patterns you need to follow, then proceed with your development plan. + +This step is **mandatory** before writing any code. diff --git a/.claude/skills/trellis-brainstorm/SKILL.md b/.claude/skills/trellis-brainstorm/SKILL.md new file mode 100644 index 0000000..096eeda --- /dev/null +++ b/.claude/skills/trellis-brainstorm/SKILL.md @@ -0,0 +1,200 @@ +--- +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." +--- + +# Trellis Brainstorm + +## Non-Negotiable Planning Contract + +A request to build, implement, fix, refactor, or "go ahead" is not approval to leave planning. Task-creation consent is also not implementation approval. + +For every non-trivial task, the user must respond at least once after the initial request before implementation begins. If no clarification is needed, that response must approve the final planning summary described below. + +While any user-owned product, scope, UX, compatibility, risk, or acceptance decision remains unresolved, end the turn with exactly one highest-value question. Do not edit product code, dispatch implementation, or run `task.py start`. + +## Non-Negotiable Evidence Rule + +If a question can be answered by exploring the codebase, explore the codebase instead. + +This is mandatory. Before asking the user a question, first check whether the answer is already available in code, tests, configs, docs, existing specs, or task history. + +Do not ask the user to confirm facts that the repository can answer. Ask only for product intent, preference, scope, risk tolerance, acceptance behavior, or decisions that remain ambiguous after inspection. + +Repository evidence establishes current behavior and technical constraints. The user's intended behavior, feature scope boundaries, and UX preferences are never answerable by repository evidence alone, even when an existing pattern exists; existing patterns are options and recommendation evidence, not decisions. + +--- + +Use this skill during Phase 1 planning to turn the user's request into clear requirements and planning artifacts. + +## Preconditions + +Use this skill only after task-creation consent has been given and the user is ready to enter Trellis planning. + +If no task exists yet, create one: + +```bash +TASK_DIR=$(python3 ./.trellis/scripts/task.py create "<short task title>" --slug <slug>) +``` + +Use a concise title from the user's request. Use a slug without a date prefix. `task.py create` adds the `MM-DD-` directory prefix automatically. + +`task.py create` creates the default `prd.md`. Update that file with the current understanding before asking follow-up questions. + +## Planning Flow + +1. Capture the user's request and initial known facts in `prd.md`. +2. Inspect available evidence before asking questions: + - code, tests, fixtures, and configs + - README files, docs, existing specs, and domain notes + - related Trellis tasks, research files, and session history when present +3. Separate what you found into: + - confirmed facts + - product intent still needed from the user + - scope or risk decisions still needed from the user + - likely out-of-scope items +4. If a user-owned decision remains, ask the single highest-value question, include your recommendation and trade-off, then stop. Do not perform implementation work in the same turn. +5. After each user answer, update `prd.md`, recompute the decision inventory, and repeat from step 2. +6. When no user-owned decision remains, create or update `design.md` and `implement.md` for complex tasks. +7. Run the requirement convergence gate, then the PRD convergence pass. +8. Present the final planning summary and stop. Do not run `task.py start` or edit product code in the same turn. +9. Only a subsequent user message that explicitly approves the latest planning summary authorizes `task.py start` and implementation. If the artifacts change materially after approval, repeat the final review. + +Do not invent a project-specific product/spec hierarchy. If the repository already has product, domain, or spec docs, use them. If it does not, proceed with the evidence that exists. + +## Question Rules + +Ask only one question per message. + +Each question must include: + +- the decision needed +- why the answer matters +- your recommended answer +- the trade-off if the user chooses differently + +Do not ask process questions such as whether to search, inspect files, or continue brainstorming. Do the evidence work directly. Ask the user only when the remaining issue is a product decision, preference, scope boundary, or risk tolerance choice. + +Recommendations are not default selections. Never choose a recommended product decision on the user's behalf merely because the user asked for implementation. + +Do not manufacture clarification questions when the request and repository evidence already resolve every decision. In that case, proceed directly to the final planning summary, which still requires a subsequent explicit approval. + +The final review is a required phase-transition gate, not a prohibited process question. Task-creation consent, the initial implementation request, and approval given before the latest final summary do not satisfy this gate. + +## Thinking Framework: First Principles Analysis + +When requirements are vague, solutions feel over-engineered, or you're about to add complexity "because everyone does" — decompose to fundamental truths before reasoning upward. + +### Step 1: Restate the Problem + +Strip away implementation details to one sentence. + +> Bad: "We need to add Redis caching to the user profile endpoint" +> Good: "User profile data takes too long to load" + +### Step 2: List Fundamental Truths + +What is absolutely true (not opinion or convention)? + +| Category | Examples | +|----------|----------| +| **Physical constraints** | Network latency ≥ 0, disk I/O has limits | +| **Business rules** | "Users must see their own data" | +| **Technical invariants** | "Data must be consistent" | +| **User needs** | "The user wants X within Y seconds" | + +### Step 3: Challenge Assumptions + +For each component of the current plan: + +- **Fact or convention?** "We always use REST" — why? +- **What if we removed this?** If nothing breaks, it's unnecessary. +- **Solving the actual problem or a symptom?** Trace the causal chain. +- **Who benefits from this complexity?** If "nobody", simplify. + +### Step 4: Build Up from Truths + +1. Start with the minimum viable mechanism satisfying all truths +2. Add complexity only when a specific truth demands it +3. Each addition must answer: "Which truth requires this?" + +### Step 5: Validate + +- Does the solution solve the original problem? +- What assumptions need verification? +- What's the simplest experiment to test this? + +## Requirement Convergence Gate + +Before final review, verify all of the following: + +- the user outcome and product value are explicit +- in-scope and out-of-scope behavior are explicit +- acceptance criteria describe observable outcomes +- user-owned product, scope, UX, compatibility, and risk decisions are resolved +- blocking open questions are empty +- technical unknowns are researched or explicitly deferred without changing MVP behavior + +Lightweight tasks may omit `design.md` and `implement.md`; they may not skip evidence inspection, requirement convergence, final review, or fresh implementation approval. + +The final planning summary must show Goal, In Scope, Out of Scope, Acceptance Criteria, Key Decisions, relevant Risks or Deferred Items, and artifact status. + +## Artifact Rules + +`prd.md` records requirements and acceptance: + +- goal and user value +- confirmed facts +- requirements +- acceptance criteria +- out of scope +- open questions that still block planning + +`design.md` records technical design for complex tasks: + +- architecture and boundaries +- data flow and contracts +- compatibility and migration notes +- important trade-offs +- operational or rollback considerations + +`implement.md` records execution planning for complex tasks: + +- ordered implementation checklist +- validation commands +- risky files or rollback points +- follow-up checks before `task.py start` + +Lightweight tasks may have only `prd.md`. Complex tasks must have `prd.md`, `design.md`, and `implement.md` before `task.py start`. + +`implement.md` is not a replacement for `implement.jsonl`. On sub-agent-dispatch workflows, `implement.jsonl` and `check.jsonl` must each contain at least one real spec/research entry before `task.py start`; the seed `_example` row does not count. Inline workflows skip this JSONL gate because Phase 2 loads context through `trellis-before-dev`. + +## PRD Convergence Pass + +Before declaring planning ready or running `task.py start`, rewrite `prd.md` once against the final structure described in the artifact rules above. This is not optional cleanup; it is the final planning gate. + +The pass must be lossless: + +- Collapse repeated facts into one authoritative section. +- Fold temporary brainstorm sections such as `What I already know`, `Assumptions`, and resolved `Open Questions` into Goal, Background, Requirements, Technical Notes, or Acceptance Criteria. +- Remove resolved open questions instead of leaving empty or already-answered sections. +- Merge parallel bug and requirement lists when they describe the same work; keep each defect's severity, evidence, and file:line anchors on the owning requirement. +- Preserve every file:line anchor, decision, constraint, requirement ID, and acceptance-criteria mapping. +- Do not proceed to final review while any blocking open question remains. + +After the pass, read `prd.md` top to bottom and verify that no fact is repeated across sections unless the repetition adds new information. + +## Quality Bar + +Before declaring planning ready: + +- `prd.md` contains testable acceptance criteria. +- `prd.md` has passed the PRD convergence pass: no unresolved temporary brainstorm sections, no duplicate facts across sections, and no lost anchors, decisions, or acceptance mappings. +- Repository-answerable questions have already been answered through inspection. +- Blocking open questions are empty. +- Complex tasks have `design.md` and `implement.md`. +- Sub-agent-dispatch tasks have real curated entries in both `implement.jsonl` and `check.jsonl`; seed-only manifests are not ready. +- The latest final planning summary has been presented to the user. +- In a subsequent message, the user explicitly approved that summary for implementation. + +Do not start implementation merely because the user originally asked for implementation. diff --git a/.claude/skills/trellis-break-loop/SKILL.md b/.claude/skills/trellis-break-loop/SKILL.md new file mode 100644 index 0000000..1c8b397 --- /dev/null +++ b/.claude/skills/trellis-break-loop/SKILL.md @@ -0,0 +1,188 @@ +--- +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 for deep analysis to break the "fix bug -> forget -> repeat" cycle. + +--- + +## Analysis Framework + +Analyze the bug you just fixed from these 5 dimensions: + +### 1. Root Cause Category + +Which category does this bug belong to? + +| Category | Characteristics | Example | +|----------|-----------------|---------| +| **A. Missing Spec** | No documentation on how to do it | New feature without checklist | +| **B. Cross-Layer Contract** | Interface between layers unclear | API returns different format than expected | +| **C. Change Propagation Failure** | Changed one place, missed others | Changed function signature, missed call sites | +| **D. Test Coverage Gap** | Unit test passes, integration fails | Works alone, breaks when combined | +| **E. Implicit Assumption** | Code relies on undocumented assumption | Timestamp seconds vs milliseconds | + +### 2. Why Fixes Failed (if applicable) + +If you tried multiple fixes before succeeding, analyze each failure: + +- **Surface Fix**: Fixed symptom, not root cause +- **Incomplete Scope**: Found root cause, didn't cover all cases +- **Tool Limitation**: grep missed it, type check wasn't strict +- **Mental Model**: Kept looking in same layer, didn't think cross-layer + +### 3. Prevention Mechanisms + +What mechanisms would prevent this from happening again? + +| Type | Description | Example | +|------|-------------|---------| +| **Documentation** | Write it down so people know | Update thinking guide | +| **Architecture** | Make the error impossible structurally | Type-safe wrappers | +| **Compile-time** | 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?" | + +### 4. Systematic Expansion + +What broader problems does this bug reveal? + +- **Similar Issues**: Where else might this problem exist? +- **Design Flaw**: Is there a fundamental architecture issue? +- **Process Flaw**: Is there a development process improvement? +- **Knowledge Gap**: Is the team missing some understanding? + +### 5. Knowledge Capture + +Solidify insights into the system: + +- [ ] Update `.trellis/spec/guides/` thinking guides +- [ ] Update relevant `.trellis/spec/` docs +- [ ] Create issue record (if applicable) +- [ ] Create feature ticket for root fix +- [ ] Update check guidelines if needed + +--- + +## Output Format + +Please output analysis in this format: + +```markdown +## Bug Analysis: [Short Description] + +### 1. Root Cause Category +- **Category**: [A/B/C/D/E] - [Category Name] +- **Specific Cause**: [Detailed description] + +### 2. Why Fixes Failed (if applicable) +1. [First attempt]: [Why it failed] +2. [Second attempt]: [Why it failed] +... + +### 3. Prevention Mechanisms +| Priority | Mechanism | Specific Action | Status | +|----------|-----------|-----------------|--------| +| P0 | ... | ... | TODO/DONE | + +### 4. Systematic Expansion +- **Similar Issues**: [List places with similar problems] +- **Design Improvement**: [Architecture-level suggestions] +- **Process Improvement**: [Development process suggestions] + +### 5. Knowledge Capture +- [ ] [Documents to update / tickets to create] +``` + +--- + +## Core Philosophy + +> **The value of debugging is not in fixing the bug, but in making this class of bugs never happen again.** + +Three levels of insight: +1. **Tactical**: How to fix THIS bug +2. **Strategic**: How to prevent THIS CLASS of bugs +3. **Philosophical**: How to expand thinking patterns + +30 minutes of analysis saves 30 hours of future debugging. + +## Thinking Framework: Bayesian Reasoning + +When multiple root causes are plausible and evidence is incomplete, update your beliefs proportionally to new evidence rather than clinging to initial assumptions. + +### Step 1: Establish Priors + +Before investigating, state what you believe and why: + +| Hypothesis | Prior | Reasoning | +|------------|-------|-----------| +| H1: [cause A] | 40% | Most common for this pattern | +| H2: [cause B] | 30% | Plausible given environment | +| H3: [other] | 30% | Catch-all | + +Priors must sum to 100%. If you can't assign probabilities, investigate first. + +### Step 2: Observe Evidence + +Document what you found — be specific about reliability: + +- What exactly did you observe? +- How reliable? (test output > log message > user report > hunch) +- Could multiple hypotheses explain this? + +### Step 3: Update Beliefs + +For each hypothesis, ask: **How likely is this evidence if this hypothesis were true?** + +Direction of update matters more than calculation: +- Evidence strongly predicted by H1 → H1 probability increases +- Evidence contradicts H2 → H2 probability decreases +- Evidence equally likely under all → no update + +### Step 4: Seek Discriminating Evidence + +Don't gather more of the same. Find evidence that **differs strongly** between top hypotheses. + +> If H1 and H3 are close: "What would I see if H1 is true but not if H3 is true?" Then check for that. + +### Step 5: State Confidence + +| Confidence | Action | +|------------|--------| +| 90%+ | Proceed with fix, monitor | +| 70-90% | Proceed, add fallback check | +| 50-70% | Test hypothesis before committing | +| <50% | Need more evidence, don't guess | + +Never express binary certainty when evidence is incomplete. Use "most likely", "plausible but unlikely", "worth investigating". + +### Common Fallacies + +| Fallacy | Example | Correction | +|---------|---------|------------| +| **Base rate neglect** | "Test failed → code is broken" | How often do tests fail for other reasons? | +| **Confirmation bias** | "Must be a race condition, let me find race evidence" | Actively seek evidence AGAINST your top hypothesis | +| **Anchoring** | "Last time it was caching, probably caching again" | Establish priors from current context, not yesterday's bug | + +--- + +## After Analysis: Immediate Actions + +**IMPORTANT**: After completing the analysis above, you MUST immediately: + +1. **Update spec/guides** - Don't just list TODOs, actually update the relevant files: + - If it's a cross-platform issue → update `cross-platform-thinking-guide.md` + - If it's a cross-layer issue → update `cross-layer-thinking-guide.md` + - If it's a code reuse issue → update `code-reuse-thinking-guide.md` + - If it's domain-specific → update `backend/*.md` or `frontend/*.md` + +2. **Sync templates** - After updating `.trellis/spec/`, sync to `src/templates/markdown/spec/` + +3. **Commit the spec updates** - This is the primary output, not just the analysis text + +> **The analysis is worthless if it stays in chat. The value is in the updated specs.** diff --git a/.claude/skills/trellis-channel/SKILL.md b/.claude/skills/trellis-channel/SKILL.md new file mode 100644 index 0000000..511ee02 --- /dev/null +++ b/.claude/skills/trellis-channel/SKILL.md @@ -0,0 +1,67 @@ +--- +name: trellis-channel +description: Use Trellis channel for live multi-agent collaboration, spawned workers, cross-agent review, progress inspection, forum channels, and channel log debugging. +--- + +# trellis-channel + +`trellis channel` is the local multi-agent collaboration runtime. Reach for it when agents need to talk through a durable event log, when a worker should be spawned as a peer process, when an in-flight worker needs interrupt / debugging, or when feedback should be recorded on a durable `--type forum` channel. + +Typical user signals: "和 codex/claude 讨论", "brainstorm with another agent", "spawn an implement/check worker", "let agent review", "open an issue board / changelog forum", "look at this thread", "channel is stuck / no output", "progress was truncated", "how do I write that channel command". + +This skill is an index. Load only the reference file for the current job — do not preload all of them. + +## First Commands + +```bash +trellis --version +trellis channel --help +trellis channel list --all +trellis channel list --scope global --all +``` + +If the user names a channel or thread, inspect it before asking for background: + +```bash +trellis channel forum <board> --scope global +trellis channel thread <board> <thread> --scope global +trellis channel context list <board> --scope global --thread <thread> +``` + +## Route By User Intent + +| User intent | Read | +|---|---| +| "和 codex/claude 讨论一下", "brainstorm with another agent" | `references/workflows.md` | +| "派一个 implement/check agent", "让 agent review", "spawn a worker" | `references/workflows.md`, then `references/workers.md` | +| "开 issue 区 / topic 群 / changelog / board", "make a forum" | `references/forum.md` | +| "看看这个 thread / linked context", "inspect a thread" | `references/forum.md` | +| "channel 卡住了 / 没输出 / progress 被截断", "worker stalled" | `references/progress-debugging.md` | +| "具体命令怎么写", "what flags does X take" | `references/command-reference.md` | + +## Core Rules + +- New forum channels use `--type forum`. A `thread` is one item inside a forum channel. +- Use `--context-file` / `--context-raw` and `trellis channel context add/delete/list`. `--linked-context-*` is deprecated terminology. +- Use `--stdin` or `--text-file` for long messages. Do not put long mixed Chinese/English text in the positional shell argument. +- Pretty `messages` output is an operator dashboard and may truncate progress. Use `--raw` for audit. +- `--as` is the speaker or worker handle, depending on the command. Use explicit, stable names when multiple agents or sessions are involved. +- `--scope project` (default) operates on the current cwd's project bucket; `--scope global` operates on the shared `__global__` bucket. Pick scope deliberately — a global board is invisible from project listings unless `--scope global` is passed. +- For brainstorm, do multiple pressure-test rounds. One answer plus one confirmation is review, not brainstorm. +- **Dispatcher wait pattern**: use `--kind done` / `--kind turn_finished` (trellis-emitted system events), NOT a user `--tag` as the completion signal. CLI help lists `phase_done` / `question` as `--tag` examples but only `interrupt` is a reserved tag with hardcoded trellis behavior; the others are opaque user labels. Relying on a worker to run `send --tag <my_signal>` is unreliable — LLM workers commonly write the tag string into prose instead of running the actual CLI command. See `references/command-reference.md` "tag vs kind". +- Forum channels are event-sourced. Do not parse `events.jsonl` first; use `forum`, `thread`, `messages --thread`, and `context list`. +- `@mindfoldhq/trellis-core` owns reusable channel/thread state, event append, seq allocation, context/title projection, reducers, and task helpers. The CLI owns flags, terminal rendering, prompts, worker lifecycle, and process exits. + +## Reference Files + +- `references/workflows.md` — canonical collaboration patterns A–F (peer brainstorm, spawned review, dispatch-and-wait, forum issue capture, interrupt-and-redirect, one-shot run). +- `references/forum.md` — forum channels, context, title, rename, changelog forums, thread filtering. +- `references/workers.md` — spawn, agent cards, context injection (`--file` / `--jsonl`), interrupts, kill semantics. +- `references/progress-debugging.md` — progress/raw inspection, stalled worker diagnosis, OOM guard, exit codes. +- `references/command-reference.md` — current CLI command reference (every subcommand, every flag, output conventions, scope/type model). + +## Not For + +- One static review where a markdown file and prompt are enough. +- Replacing normal tool calls with self-logging. +- Long-term memory retrieval. Use durable forum channels for actionable issues, and `trellis mem` (the `trellis-session-insight` skill) for session/history search. diff --git a/.claude/skills/trellis-channel/references/command-reference.md b/.claude/skills/trellis-channel/references/command-reference.md new file mode 100644 index 0000000..75def26 --- /dev/null +++ b/.claude/skills/trellis-channel/references/command-reference.md @@ -0,0 +1,480 @@ +# Command Reference + +Authoritative current command reference for `trellis channel` subcommands, +validated against the source in `packages/cli/src/commands/channel/` +(`index.ts` Commander wiring and each subcommand handler). + +Every subcommand accepts `--scope <project|global>` unless noted; `project` +is the default and resolves against the current cwd's project bucket. + +## Top-level + +``` +trellis channel <subcommand> +``` + +> Multi-agent collaboration runtime — spawn / coordinate / interrupt worker +> agents through a shared event log. + +--- + +## Create / List + +### `create <name>` + +```bash +trellis channel create <name> + [--scope project|global] # default: project + [--type chat|forum] # default: chat + [--task <path>] # associated Trellis task dir + [--project <slug>] + [--labels a,b,c] + [--description <text>] # stable channel description + [--context-file <abs-path>] ... # repeatable + [--context-raw <text>] ... # repeatable + [--linked-context-file <abs-path>] # [deprecated alias] + [--linked-context-raw <text>] # [deprecated alias] + [--cwd <path>] # recorded in create event + [--by <agent>] # default: main + [--force] # overwrite existing channel + [--ephemeral] # hide from default list, prunable +``` + +Behavior: +- Appends a `create` event; immutable `type` (cannot mutate forum↔chat after). +- `--ephemeral` channels are hidden from `channel list` by default and are + the sweep target for `channel prune --ephemeral`. +- `--linked-context-*` are folded into `--context-*`; emit a deprecation + notice when used. + +### `list` + +```bash +trellis channel list + [--scope project|global] + [--json] + [--project <slug>] # substring match on task field + [--all] # include ephemeral (suffix '*') + [--all-projects] # scan every project bucket +``` + +Behavior: +- Default scope: current cwd's project. `--all-projects` scans every bucket. +- Pretty mode prints `NAME WORKERS EVENTS LAST KIND TYPE TASK`, sorted by + recency, with a footer noting hidden ephemeral count. +- `--json` switches to a JSON array. + +--- + +## Chat Messages + +### `send <name> [text]` + +```bash +trellis channel send <name> [text] + --as <agent> # REQUIRED — author + [--scope project|global] + [--to <agents,csv>] # default: broadcast + [--stdin | --text-file <path>] # body from stdin or file + [--delivery-mode appendOnly|requireKnownWorker|requireRunningWorker] +``` + +Behavior: +- Body precedence: positional `[text]` → `--stdin` → `--text-file`. +- `--to` with one entry stores a string; multiple stores an array; omitted + means broadcast. +- `--delivery-mode` selects targeted-delivery validation: + - `appendOnly` (default-ish — just record), + - `requireKnownWorker` (the named target must have a `spawned` event), + - `requireRunningWorker` (the worker must currently be live). +- Prints the appended event as one JSON line on stdout. + +> **Note:** `send` has **no** `--tag` and **no** `--kind` flag. See +> [`tag-vs-kind`](#tag-vs-kind--how-event-shape-is-actually-controlled) below. + +### `messages <name>` + +```bash +trellis channel messages <name> + [--scope project|global] + [--raw] # one JSON event per line + [--follow] # stream new events + [--last <N>] # last N matching events + [--since <seq>] # seq > N + [--kind <kind>] # one of CHANNEL_EVENT_KINDS + [--from <csv>] # author filter + [--to <target>] # routing target filter + [--thread <key>] # forum-only + [--action <thread-action>] # forum-only + [--no-progress] # hide progress events +``` + +Behavior: +- Auto-detects forum channels: with no filters it renders the thread board + instead of the event stream. `--thread` / `--action` are forum-only and + error against chat channels. +- `--kind` is validated against `CHANNEL_EVENT_KINDS` (single value, not + CSV — that's the `wait` side). + +### `wait <name>` + +```bash +trellis channel wait <name> + --as <agent> # REQUIRED — self for filter ctx + [--scope project|global] + [--timeout <Ns|Nm|Nh|Nms>] # parsed by parseDuration + [--from <a,b>] # author CSV + [--kind <k1,k2>] # CSV, OR semantics + [--thread <key>] # forum filter + [--action <thread-action>] # forum filter + [--to <target>] # default: own agent (broadcast + me) + [--include-progress] # also wake on progress events + [--all] # require every --from to match +``` + +Behavior: +- Streams matching events as JSON, one per line. +- Default `--to` filter is the caller's own agent (broadcast events still + match — broadcast + explicit-to-me). +- `--all` requires `--from` and blocks until every listed agent has produced + a matching event. +- **Timeout exits 124** and prints `timeout: still waiting on ...` to stderr + when `--all` was in play. + +--- + +## tag-vs-kind — how event shape is actually controlled + +There is **no `--tag` flag** anywhere in the v0.6.0 channel CLI; `--kind` is +not a legacy alias for any `--tag` flag. + +Concrete model in the current source: + +- `--kind` is the only event-type filter, and it is constrained to the + trellis-emitted whitelist (`CHANNEL_EVENT_KINDS` in + `packages/core/src/channel/internal/store/events.ts`): + - `create`, `join`, `leave`, `message`, `thread`, `context`, `channel`, + `spawned`, `killed`, `respawned`, `progress`, `done`, `error`, + `waiting`, `awake`, `undeliverable`, `interrupt_requested`, + `turn_started`, `turn_finished`, `interrupted`, `supervisor_warning` + - Passing anything else throws + `Invalid --kind '<x>'. Must be one of: …`. +- `--kind` lives on `wait` (CSV, OR semantics) and `messages` (single + value). `send` and `run` cannot emit a custom kind — every `send` writes + a `message` event. +- Mid-turn worker abort is **not** a tag. It is the dedicated + `channel interrupt` command, which appends an `interrupt_requested` / + `interrupted` pair and provider-level interrupts the worker. + +Practical rule for dispatchers waiting on workers: + +- Use `--kind done,turn_finished` for "worker finished a turn" — these are + system events that the supervisor fires automatically. Do not depend on + the worker LLM remembering to emit any custom signal. +- Use `trellis channel interrupt` (the command) only when you actually want + mid-turn abort behavior. +- Do **not** invent user-side tags as completion signals. There is no + `--tag` filter; a worker writing a custom string into its final message + is just text inside a `message` event and cannot be matched by `wait`. + +Long bodies always go through stdin or a file: + +```bash +trellis channel send T --as A --stdin < /tmp/message.md +trellis channel send T --as A --text-file /tmp/message.md +``` + +--- + +## Interrupt + +### `interrupt <name> [text]` + +```bash +trellis channel interrupt <name> [text] + --as <agent> # REQUIRED — caller + --to <agent> # REQUIRED — target worker + [--scope project|global] + [--stdin | --text-file <path>] +``` + +Behavior: +- Appends an `interrupt` event with `reason: "user"` and a replacement + instruction body; supervisor performs provider-level interrupt where + supported (Claude `/interrupt`, Codex turn cancel). +- Prints the appended event JSON on stdout. + +--- + +## Workers + +### `spawn <name>` + +```bash +trellis channel spawn <name> + [--scope project|global] + [--agent <agent-name>] # loads .trellis/agents/<name>.md + [--provider claude|codex] # overrides agent file + [--as <worker-name>] # default: agent name + [--cwd <path>] + [--model <id>] + [--resume <id>] # session/thread id resume + [--timeout <Ns|Nm|Nh>] # auto-kill after duration + [--warn-before <Ns|Nm|Nh>] # supervisor_warning lead time + # default 5m, 0ms disables + [--file <path>] ... # glob, repeatable; inject content + [--jsonl <path>] ... # Trellis manifest, repeatable + [--by <agent>] # spawn-event author + # default: TRELLIS_CHANNEL_AS env or 'main' + [--inbox-policy explicitOnly|broadcastAndExplicit] + # default explicitOnly + [--idle-timeout <Ns|Nm|Nh>] # OOM-guard idle TTL + # default 5m, 0 disables + [--max-live-workers <n>] # spawn-time live-worker budget + # default 6, 0 disables +``` + +Behavior: +- Provider is validated against the adapter registry + (`packages/cli/src/commands/channel/adapters/`); current: `claude`, + `codex`. +- Worker stays inbox-idle until the first `send --to <worker>`. +- Records a `spawned` event with `pid`, `provider`, `agent`, `files`, + `manifests`. +- OOM-guard precedence: CLI flag → env var + (`TRELLIS_CHANNEL_WORKER_IDLE_TIMEOUT`, + `TRELLIS_CHANNEL_MAX_LIVE_WORKERS`) → + `.trellis/config.yaml#channel.worker_guard` → built-in defaults. + +### `run [name]` + +```bash +trellis channel run [name?] + [--agent <name>] + [--provider claude|codex] + [--as <worker-name>] + [--cwd <path>] + [--model <id>] + [--file <path>] ... # repeatable, glob + [--jsonl <path>] ... # repeatable + [--message <text> | --message-file <path> | --stdin] + [--timeout <Ns|Nm|Nh>] # default 5m +``` + +Behavior: +- One-shot. Auto-generates `run-<hex>` if `name` omitted. +- Creates an ephemeral channel (`createMode=run`), spawns a single worker, + sends the prompt, waits for `done`, prints the final assistant text to + stdout, then removes the channel on success. On failure the channel is + kept for inspection and exit code is 1. + +> `run` has **no** `--tag` flag. Completion is detected via the `done` +> event the supervisor emits. + +### `kill <name>` + +```bash +trellis channel kill <name> + --as <agent> # REQUIRED — worker agent name + [--scope project|global] + [--force] # SIGKILL immediately +``` + +Behavior: +- Default path: SIGTERM → 8 s grace → SIGKILL escalation; the CLI writes a + `killed` event when SIGKILL was needed so the log stays truthful. +- Cleans `pid`, `worker-pid`, `config`, `spawnlock` sidecar files; keeps + `log`, `session-id`, `thread-id` for forensics / resume. + +### `rm <name>` + +```bash +trellis channel rm <name> + [--scope project|global] +``` + +Behavior: +- Kills any live workers, then deletes the entire channel directory. +- Prints `Removed channel '<name>'`. + +### `prune` + +```bash +trellis channel prune + [--scope project|global] # omitted: scan every project + [--all | --empty | --idle <Ns|Nm|Nh|Nd> | --ephemeral] # mutually exclusive + [--yes] # actually delete (default: dry-run) + [--dry-run] # default true; redundant with default + [--keep <names,csv>] # exclusion list +``` + +Behavior: +- Filter flags are mutually exclusive — error otherwise. +- Default is dry-run; `--yes` flips to real delete. +- Without `--scope`, scans **every** project bucket (intentional, repo-wide + cleanup); with `--scope project|global`, limited to that bucket. +- Live-worker channels are always skipped regardless of filter. +- Output: per-candidate line `name last-ts (reason)` plus a final summary. + +--- + +## Forum Channels + +### `post <name> <action>` + +```bash +trellis channel post <name> <action> + --as <agent> # REQUIRED + [--scope project|global] + [--thread <key>] # required except action=opened + [--title <text>] + [--text <text> | --stdin | --text-file <path>] + [--description <text>] # stable thread description + [--status <status>] + [--labels a,b] # REPLACES thread labels + [--assignees a,b] # REPLACES assignees + [--summary <text>] + [--context-file <abs-path>] ... + [--context-raw <text>] ... + [--linked-context-file <abs-path>] # [deprecated alias] + [--linked-context-raw <text>] # [deprecated alias] +``` + +Behavior: +- `<action>` is free-form on the CLI surface; conventional values include + `opened`, `comment`, `status`, `labels`, `assignees`, `summary`, + `processed`. +- `action=rename` is rejected — use `thread rename` instead. +- `--labels` / `--assignees` are replace-semantics, not append. +- Output: appended event JSON on stdout. + +### `forum <name>` + +```bash +trellis channel forum <name> + [--scope project|global] + [--status <status>] + [--raw] +``` + +Behavior: +- Lists threads (reduced state). `--status` filters by current thread + status. `--raw` prints one JSON per thread. + +### `thread <name> <thread>` / `thread rename` + +```bash +trellis channel thread <name> <thread-key> + [--scope project|global] + [--raw] + +trellis channel thread rename <name> <old-thread> <new-thread> + --as <agent> # REQUIRED + [--scope project|global] +``` + +Behavior: +- `thread <name> <key>` shows one thread's timeline: + header `<thread> [<status>] <title>`, then description / labels / + assignees / summary / timeline lines. `--raw` switches to raw events. +- `thread rename` is the only mutation; `post --action rename` is rejected. + +--- + +## Context / Title + +### `context add` / `context delete` / `context list` + +```bash +trellis channel context add <name> + [--as <agent>] # default: main + [--scope project|global] + [--thread <key>] # thread-level instead of channel-level + [--file <abs-path>] ... # repeatable + [--raw <text>] ... # repeatable + # at least one of --file or --raw + +trellis channel context delete <name> + [--as <agent>] # default: main + [--scope project|global] + [--thread <key>] + [--file <abs-path>] ... + [--raw <text>] ... + +trellis channel context list <name> + [--scope project|global] + [--thread <key>] + [--raw] # one JSON entry per line +``` + +Behavior: +- `add` / `delete` append a `context` event and print the event JSON. +- `list` projects current context entries; pretty output is + `file <path>` / `raw <truncated text>` lines, `(no context)` when empty. + +### `title set <name>` / `title clear <name>` + +```bash +trellis channel title set <name> + --title <text> # REQUIRED + [--as <agent>] # default: main + [--scope project|global] + +trellis channel title clear <name> + [--as <agent>] # default: main + [--scope project|global] +``` + +Behavior: +- Appends a `title` event projecting a stable display title onto the + channel. Output: event JSON. + +--- + +## Hidden / Internal + +| Command | Purpose | +|---|---| +| `channel __supervisor <channel> <worker> <config>` | Forked entry point invoked by `spawn`. Do not invoke directly. | +| `channel __parse-trace <adapter> <file>` | Dev helper — replays a recorded stream-json / wire trace through the matching adapter and prints the resulting channel events. Adapter is validated against the provider registry. | + +--- + +## Event Model + +`CHANNEL_EVENT_KINDS` (whitelist enforced by `parseChannelKind`): + +`create`, `join`, `leave`, `message`, `thread`, `context`, `channel`, +`spawned`, `killed`, `respawned`, `progress`, `done`, `error`, `waiting`, +`awake`, `undeliverable`, `interrupt_requested`, `turn_started`, +`turn_finished`, `interrupted`, `supervisor_warning`. + +`MEANINGFUL_EVENT_KINDS` (default-visible subset used by `wait` / +`messages` when no explicit `--kind` is given): + +`create`, `join`, `leave`, `message`, `thread`, `context`, `channel`, +`spawned`, `killed`, `respawned`, `done`, `error`. + +Non-meaningful kinds (e.g. `progress`, `waiting`, `awake`, +`supervisor_warning`, the `turn_*` / `interrupt*` set) still flow through +the store; opt in via `--kind` or `--include-progress`. + +Forum channels are event-sourced; use the CLI reducers +(`forum`, `thread`, `context list`) for state projection. + +--- + +## Output Conventions + +- **Mutations** (`send`, `interrupt`, `post`, `context add/delete`, + `title set/clear`, `thread rename`) print the appended event as one JSON + line on **stdout**. +- **Streaming reads** (`wait`, `messages --follow`) print one JSON event + per line on stdout. +- **Pretty reads** (`list`, `messages`, `forum`, `thread`, `context list`) + print colored, padded tables / timelines. +- **`run`** prints only the final assistant text on stdout (so callers can + pipe); diagnostic notes go to stderr. +- **Errors** go through `chalk.red("Error:")` to stderr and `exit 1`. +- **`wait` timeout** specifically exits **124**. + diff --git a/.claude/skills/trellis-channel/references/forum.md b/.claude/skills/trellis-channel/references/forum.md new file mode 100644 index 0000000..06b7f36 --- /dev/null +++ b/.claude/skills/trellis-channel/references/forum.md @@ -0,0 +1,233 @@ +# Forum Channels + +Forum channels are durable, topic-style channels. They are created with +`--type forum` at channel-creation time and are immutable after that. They are +not normal chat streams: the default read path is +**forum summary -> one thread timeline -> current context**. + +## Forum vs Regular Channel + +A channel's type is set with `--type` on `channel create` and never changes: + +- `chat` (default) — flat message timeline. `channel messages` always renders + the event stream. Forum-only flags such as `--thread` and `--action` are + rejected here. +- `forum` — thread-oriented. `channel messages` without filters renders a + thread-board summary instead of raw events. The `post`, `forum`, `thread`, + and `thread rename` subcommands only apply to forum channels. + +Both types share the same scope model (`--scope project` is the default; +`--scope global` puts the channel in the cross-project bucket). + +## Create A Forum Channel + +```bash +trellis channel create design-feedback \ + --type forum \ + --scope global \ + --description "Cross-project design feedback board." \ + --context-raw "One thread per design topic; close when resolved." \ + --by main +``` + +Use `--scope project` for a board scoped to one repo, `--scope global` for a +cross-project board. + +## Threads: Open, Comment, Status, Summary + +Threads live inside a forum channel. Each thread is identified by a stable +`--thread <key>` (lowercase kebab-case is conventional). The first action on +a thread is `opened`; everything afterwards uses the same `--thread` key. + +```bash +trellis channel post design-feedback opened \ + --scope global \ + --as main \ + --thread login-empty-state \ + --title "Empty state on the login screen" \ + --description "Track design feedback for the new login empty state." \ + --labels design,login \ + --context-raw "Spotted during the 0.4 release review." \ + --text-file /tmp/thread-open.md + +trellis channel post design-feedback comment \ + --scope global \ + --as reviewer \ + --thread login-empty-state \ + --text-file /tmp/review.md + +trellis channel post design-feedback status \ + --scope global \ + --as main \ + --thread login-empty-state \ + --status closed + +trellis channel post design-feedback summary \ + --scope global \ + --as main \ + --thread login-empty-state \ + --summary "Adopted the option-B layout; ticket TRELLIS-123 owns the fix." +``` + +Key distinctions: + +- `--description` is the **durable** thread description (the answer to "what + is this thread about?"). It is set on `opened` and edited by re-running + `post` with `--description`. +- `--text` / `--stdin` / `--text-file` is the **event body** — the comment or + payload attached to this specific timeline entry. +- `--labels` and `--assignees` are CSV and **replace** the current value; they + do not append. +- `--summary` is the rolling thread summary. Setting it on `status closed` is + the standard way to mark a thread resolved with context. + +`--thread` is required for every action except `opened` (where it is also +required in practice — there is no anonymous thread). + +## Read A Forum + +```bash +trellis channel messages design-feedback --scope global +trellis channel forum design-feedback --scope global --status open +trellis channel thread design-feedback login-empty-state --scope global +trellis channel messages design-feedback --scope global --raw --thread login-empty-state +``` + +If a peer says "I commented on the forum", run `channel forum` first to see +which thread changed, then drill into that thread with `channel thread <name> +<thread>`. Do not jump straight to ad-hoc `events.jsonl` parsing. + +## Context + +Context entries are durable background that should always be in scope when +reading a channel or a thread. They are **not** timeline events; they are +projected separately and replayed for every reader. + +Use the `context` subcommands. The legacy `--linked-context-file` / +`--linked-context-raw` flags on `create` and `post` are deprecated aliases +that fold into the canonical `--context-file` / `--context-raw`. + +### Add Context + +```bash +# Channel-level context (whole forum) +trellis channel context add design-feedback \ + --scope global \ + --raw "Upstream feedback board; please link tasks before opening threads." + +# Thread-level context (one thread) +trellis channel context add design-feedback \ + --scope global \ + --thread login-empty-state \ + --file "$PWD/.trellis/tasks/05-13-login-redesign/design.md" +``` + +- `--thread <key>` switches between channel-level and thread-level context. +- `--file` paths **must be absolute**; relative paths are rejected. +- `--raw` is plain text inline content. +- Both flags are repeatable; at least one is required for `add` / `delete`. +- `--as <agent>` records authorship; defaults to `main`. + +### List Context + +```bash +trellis channel context list design-feedback --scope global +trellis channel context list design-feedback --scope global --thread login-empty-state --raw +``` + +`--raw` on `list` emits one JSON entry per line (useful for piping); without +it you get a human-readable `file <path>` / `raw <truncated text>` listing. +An empty store prints `(no context)`. + +### Delete Context + +```bash +trellis channel context delete design-feedback \ + --scope global \ + --thread login-empty-state \ + --raw "stale note" +``` + +You delete by **value**, not by id: pass the same `--file` or `--raw` value +that was added. Repeat the flag to delete multiple entries in one call. + +### Reading Order + +When reading a thread, work top-down: + +1. Thread `description` (the durable "what is this about"). +2. Context entries (channel-level + thread-level). +3. Timeline (`opened`, `comment`, `status`, `summary`). + +If a context file is missing or unreadable, state that explicitly and +continue with the remaining data — do not fabricate the content. + +## Title Projection + +`title` projects a stable display title onto the channel without renaming the +storage address. The channel `name` you pass to every command stays the same. + +```bash +trellis channel title set design-feedback \ + --scope global \ + --title "Design feedback board" + +trellis channel title clear design-feedback --scope global +``` + +- `title set` requires `--title`. +- `--as <agent>` records authorship; defaults to `main`. +- This is a presentation-layer change. Tooling and scripts keep using the + original channel name. + +## Thread Rename + +`thread rename` is the correction path when a thread was opened with the +wrong key (typo, wrong slug convention, etc.). Threads do not support hard +deletion — rename is the supported corrective action. + +```bash +trellis channel thread rename design-feedback old-key new-key \ + --scope global \ + --as main +``` + +- `--as <agent>` is **required**. +- `post <name> rename` is rejected — you must use `thread rename`. + +## Deletion Discipline + +Do not model single-comment deletion or hard thread deletion as normal +workflow. Forum threads are append-only collaboration history. To correct +state, use: + +- `post ... status` to mark a thread closed / blocked / etc. +- `post ... summary` to record the resolution. +- `post ... --labels` to re-label (replaces the set). +- `thread rename` to correct a bad thread key. + +## Internal Changelog Pattern + +A common use of a global forum channel is an internal release / runtime +changelog. One thread per notable change keeps history searchable: + +```bash +trellis channel create release-notes \ + --type forum \ + --scope global \ + --description "Internal release and runtime changelog." \ + --context-raw "One thread per notable change; close when shipped." \ + --by main + +trellis channel post release-notes opened \ + --scope global \ + --as main \ + --thread release-2026-q1 \ + --title "Channel threads and forum UX in 0.6" \ + --description "Forum channel UX shipped in the 0.6 line." \ + --labels channel,release \ + --text-file /tmp/release-notes.md +``` + +Use stable, descriptive thread keys (e.g. `release-2026-q1`, +`runtime-event-schema-change`) so later readers can find them by name. diff --git a/.claude/skills/trellis-channel/references/progress-debugging.md b/.claude/skills/trellis-channel/references/progress-debugging.md new file mode 100644 index 0000000..3ed40d6 --- /dev/null +++ b/.claude/skills/trellis-channel/references/progress-debugging.md @@ -0,0 +1,226 @@ +# Progress And Debugging + +Pretty output is for operators. Raw output is the audit log. Subcommands +(`forum`, `thread`, `messages`, `context`) are the audit *interface* — reach +for them before grepping `events.jsonl` by hand. + +## Pretty vs `--raw` + +`trellis channel messages <channel>` renders a compact, human-readable view: +timestamps, identities, kind, and a short body. It is meant for operators +scanning a channel, not for diagnostics. + +Pretty output can and will truncate: + +- long progress deltas (`text_delta`, partial tool args) +- tool names and command lines +- multi-line status fields and structured `detail` blobs +- forum thread titles past the column budget + +When something looks "off" — a worker appears stuck, a progress line ends +mid-word, an action field shows `...` — switch to `--raw`. Raw mode emits +one JSON event per line exactly as it lives in `events.jsonl`, so nothing +is dropped. + +```bash +# Pretty (operator view) +trellis channel messages <channel> --kind done --last 10 +trellis channel messages <channel> --kind error --last 10 + +# Raw (diagnostic view) — one JSON per line +trellis channel messages <channel> --raw --kind progress --last 20 +trellis channel messages <channel> --raw --last 50 +``` + +Rule of thumb: never diagnose a worker from a truncated progress line. + +### Rebuild Streaming Text + +To reconstruct what a model actually streamed during a turn, concatenate +`detail.text_delta` from progress events: + +```bash +trellis channel messages <channel> --raw --kind progress --last 80 \ + | python3 -c 'import json,sys; [print((json.loads(l).get("detail") or {}).get("text_delta",""), end="") for l in sys.stdin if l.strip()]' +``` + +## Stalled Worker Diagnosis + +Symptom: `trellis channel list` shows the worker as running, but no new +events appear in `messages` and `wait` keeps timing out. + +Triage order: + +1. **Locate the channel files.** Use `list --all --all-projects` if you are + not sure which bucket the channel lives in. + + ```bash + trellis channel list --all --all-projects + CHAN=~/.trellis/channels/<bucket>/<channel> + ``` + +2. **Confirm the supervisor and worker PIDs are alive.** + + ```bash + cat "$CHAN/<worker>.pid" # supervisor PID + cat "$CHAN/<worker>.worker-pid" # actual CLI subprocess PID + ps -p "$(cat "$CHAN/<worker>.pid")" + ps -p "$(cat "$CHAN/<worker>.worker-pid")" + ``` + + If the supervisor PID is gone but the channel still lists the worker, + you have a ghost entry — clean it with + `trellis channel kill <name> --as <worker> --force`. + +3. **Tail the worker log.** This is the canonical place to see provider / + MCP / tool startup output that never makes it onto the channel. + + ```bash + tail -f "$CHAN/<worker>.log" + ``` + +4. **Check the last raw events.** A worker that emitted `progress` but no + `message`/`done` is usually mid-stream or blocked on a tool call: + + ```bash + trellis channel messages <channel> --raw --last 50 + ``` + +Common "alive but silent" causes: + +- Provider cold start before the first token (long, but eventually moves). +- A blocking MCP server during startup — visible in the worker log. +- Worker is waiting for a tool result whose subprocess hung. +- Prompt is huge / model is rate-limited; check provider-side errors in the + worker log. + +## Progress Event Interpretation + +A `progress` event represents an in-flight piece of work. Its shape varies +by `action` field, but the load-bearing fields are always under `detail`: + +- `detail.text_delta` — incremental model output (concatenate across events + to rebuild the streamed reply). +- `detail.tool_name`, `detail.tool_input` — tool call about to run or + currently running. +- `detail.status` — short string used by long-running actions + (`starting`, `running`, `flushing`, `done`). +- `detail.action` — semantic label (e.g. `status` for thread heartbeats). + +Progress events are **noisy** by design. `wait` ignores them unless you +pass `--include-progress`. When you do want to see them, prefer: + +```bash +trellis channel messages <channel> --raw --kind progress --last 80 +``` + +A stream that emits progress at a steady cadence but never closes with +`done`/`error`/`message` is the classic shape of a hung tool call — +inspect the worker log for the subprocess. + +## Wait Semantics (Quick Reference) + +`channel wait` watches `events.jsonl` from EOF and wakes on: + +- `message` +- `done` +- `error` +- `killed` +- `progress` only with `--include-progress` + +Useful filters: + +```bash +trellis channel wait T --as main --from check --kind done --timeout 15m +trellis channel wait T --as main --from check,check-cx --kind done --all --timeout 15m +trellis channel wait T --as worker --tag interrupt --timeout 1h +trellis channel wait T --as main --thread release-note --action status --timeout 10m +``` + +Exit codes: `0` matched, `124` timeout, `1`/`2` errors. On `wait --all` +timeout, stderr names the workers still missing. + +## Auditing `events.jsonl` — Use Subcommands, Not `grep` + +Every channel persists its full history at `$CHAN/events.jsonl`. It is +tempting to `tail` / `grep` / `jq` this file directly during debugging. +Don't make it a habit, and **never** do it for forum channels. + +Why subcommands first: + +- `messages` already replays the file with filters (`--kind`, `--from`, + `--last`, `--tag`, `--thread`, `--action`) and gives you `--raw` for the + exact JSON. Anything you would write a one-liner for, `messages` already + does. +- `wait` consumes the same file with EOF semantics — re-implementing that + with `tail -f | jq` will drop events under load and misorder them under + rotation. +- `context` materializes a worker's inbox view, including cursor state. + Hand-rolled filters do not respect `<worker>.inbox-cursor`. + +### Forum channels: never parse `events.jsonl` directly + +Forum channels multiplex many logical threads onto a single `events.jsonl`. +Each event carries `thread`, `action`, and tag fields that the forum +subcommands know how to fold together. Parsing the file by hand will: + +- Mix threads together and make a thread look incoherent. +- Miss thread lifecycle events (open / status / close) that change how + later events should be interpreted. +- Ignore worker inbox cursors, so you will "see" events a worker has + already consumed and assume they are pending. + +Use the forum-aware views instead: + +```bash +# List logical threads inside the forum channel +trellis channel forum list <channel> + +# Inspect one thread end-to-end +trellis channel thread show <channel> <thread> + +# Replay messages for a thread (supports --raw, --kind, --last) +trellis channel messages <channel> --thread <thread> --raw --last 100 + +# What a specific worker still has pending +trellis channel context <channel> --as <worker> +``` + +Direct reads of `events.jsonl` are reserved for the case where the CLI +itself is suspect — e.g. confirming an event was actually persisted, or +diffing against `<worker>.inbox-cursor` while debugging the supervisor. + +## Common Failures + +| Symptom | Cause | Fix | +|---|---|---| +| `trellis: command not found` | CLI not installed globally | `npm install -g @mindfoldhq/trellis` | +| `wait` exits immediately | wrong filter or identity collision | use distinct `--as`, inspect raw messages | +| zsh errors on message text | shell interpreted punctuation | use `--stdin` or `--text-file` | +| progress line is cut off | pretty output truncation | use `messages --raw --kind progress` | +| worker never speaks | provider startup / prompt / MCP delay | inspect `<worker>.log`, `ps`, raw events | +| channel not found in another cwd | project bucket mismatch | `cd` to project, use `--scope global`, or `list --all-projects` | +| ghost worker in list | supervisor died without cleanup | `trellis channel kill <name> --as <worker> --force` | +| forum thread looks scrambled | parsed `events.jsonl` directly | use `forum`, `thread`, `messages --thread` | + +## Storage Layout + +```text +~/.trellis/channels/ +└── <bucket>/ + └── <channel-name>/ + ├── events.jsonl + ├── <channel>.lock + ├── <worker>.log + ├── <worker>.pid + ├── <worker>.worker-pid + ├── <worker>.config + ├── <worker>.session-id + ├── <worker>.thread-id + ├── <worker>.inbox-cursor + └── <worker>.spawnlock +``` + +Agents normally use the CLI, not direct file reads. Direct file reads are +for debugging when CLI views are insufficient — and even then, never on a +forum channel's `events.jsonl`. diff --git a/.claude/skills/trellis-channel/references/workers.md b/.claude/skills/trellis-channel/references/workers.md new file mode 100644 index 0000000..bcec98f --- /dev/null +++ b/.claude/skills/trellis-channel/references/workers.md @@ -0,0 +1,276 @@ +# Workers And Agent Cards + +Use workers when a peer agent should execute independently and report back +through the channel event log. A worker is a registered child process (claude +or codex) attached to a channel; the supervisor forwards inbox messages to it +and translates its output back into channel events. + +## Spawn + +```bash +trellis channel create impl-task --by dispatcher --cwd /path/to/repo +trellis channel spawn impl-task --provider codex --as codex-impl --timeout 30m + +echo "Implement the schema for table X per .trellis/.../prd.md" \ + | trellis channel send impl-task --as dispatcher --to codex-impl --stdin + +trellis channel wait impl-task --as dispatcher --from codex-impl --kind done --timeout 30m +``` + +`spawn` forks a `channel __supervisor` worker that emits `spawned`, streams +`progress`, and should end with `done`, `error`, or `killed`. Workers stay +inbox-idle until a `send --to <worker>` (or a broadcast when +`--inbox-policy broadcastAndExplicit` is set) wakes them. + +Key `spawn` flags: + +- `--agent <name>` — load `.trellis/agents/<name>.md` (provider/model/as/system prompt defaults). +- `--provider <claude|codex>` — overrides the agent card; validated against the adapter registry. +- `--as <name>` — channel worker handle; defaults to the agent name. +- `--cwd <path>` — worker working directory (also the jail root for `--file`/`--jsonl`). +- `--model <id>` — model override. +- `--resume <id>` — resume an existing claude session / codex thread. +- `--timeout <duration>` — auto-kill after `30s` / `2m` / `1h`. +- `--warn-before <duration>` — supervisor_warning lead time (default `5m`; `0ms` disables). +- `--file <path>` (repeatable, glob-supported) — inject file content into the system prompt. +- `--jsonl <path>` (repeatable) — Trellis jsonl manifest (`{file, reason}` per line). +- `--by <agent>` — author of the `spawned` event (defaults to `$TRELLIS_CHANNEL_AS` or `main`). +- `--inbox-policy <explicitOnly|broadcastAndExplicit>` — default `explicitOnly`. +- `--idle-timeout <duration>` — OOM guard idle TTL (default `5m`; `0` disables). +- `--max-live-workers <n>` — spawn-time live-worker budget (default `6`; `0` disables). + +The success event `spawned` records `pid`, `provider`, `agent`, the injected +`files`, and the resolved `manifests` so later spectators can audit context. + +## Agent Cards + +`--agent <name>` resolves to `.trellis/agents/<name>.md`. The card name must +match `[A-Za-z0-9._-]+`. The default Trellis install ships two cards: + +- `.trellis/agents/check.md` — code-quality reviewer. +- `.trellis/agents/implement.md` — coding worker for implementation runs. + +```yaml +--- +name: check +description: Code quality check expert. +provider: claude +--- +``` + +Frontmatter fields populate `spawn` defaults (provider, model, `as`); the +markdown body becomes the worker's system-prompt role. Cards do **not** +auto-attach task files — context must be injected explicitly per spawn (see +below). + +Always inspect project cards before spawning a named agent: + +```bash +ls .trellis/agents +sed -n '1,100p' .trellis/agents/check.md +``` + +## Context Injection + +Two flags inject content into the worker's system prompt under a +`# CONTEXT FILES` block, assembled by `context-loader`: + +- `--file <path>` — repeatable, glob-supported (`*`, `**`). Each match is + read and concatenated. +- `--jsonl <path>` — repeatable Trellis manifest where every line is + `{"file":"<path>","reason":"<why>"}`. The reason is preserved as a header + comment above each file's content. + +Limits enforced by the loader: + +- 1 MB hard cap per file (oversize → error). +- 200 KB per-file warning to stderr. +- 500 KB total assembled-context warning to stderr. +- Path-traversal jail: all resolved paths must stay under `--cwd`. + +Example spawning a check agent against a task directory: + +```bash +TASK=.trellis/tasks/05-13-example +trellis channel spawn cr-example --agent check --provider codex --as check-cx \ + --file "$TASK/prd.md" \ + --file "$TASK/design.md" \ + --file "$TASK/implement.md" \ + --jsonl "$TASK/check.jsonl" \ + --cwd "$PWD" --timeout 30m +``` + +The `spawned` event records both the literal `files` array and any `manifests` +expanded from `--jsonl`, so the audit trail captures whatever the worker was +actually shown. + +## Names And Routing + +`--as` has two meanings: + +- `send` / `wait` / `interrupt`: speaker identity (author of the resulting event). +- `spawn`: the worker handle that other agents address with `--to`. + +Use explicit names when multiple workers or providers participate in one +channel: + +```bash +trellis channel spawn cr-feature --agent check --as check-claude +trellis channel spawn cr-feature --agent check --provider codex --as check-cx + +trellis channel wait cr-feature --as main \ + --from check-claude,check-cx --kind done --all --timeout 15m +``` + +`--all` requires `--from` and blocks until every listed worker has produced a +matching event; timeout exits with code **124** and prints +`timeout: still waiting on ...` to stderr. + +## Soft Interrupt — `interrupt` + +`channel interrupt` is the cooperative redirect: it appends an `interrupt` +event (reason `"user"`) and, where the adapter supports it, issues a +provider-level turn interrupt with a replacement instruction. Use it when the +worker should drop its current turn and act on new input immediately, without +losing its session. + +```bash +echo "Stop refactoring the parser — switch to fixing the failing test in src/foo.ts" \ + | trellis channel interrupt impl-task --as dispatcher --to codex-impl --stdin +``` + +Flags: + +- `--as <agent>` **(required)** — caller identity. +- `--to <agent>` **(required)** — target worker. +- `--scope <project|global>` — channel scope. +- `--stdin` / `--text-file <path>` / `[text]` — replacement instruction body. + +The appended event has `kind: "interrupt"` — downstream `wait` / `messages` +filters can subscribe with `--kind interrupt` to react to redirections (e.g. +to log the rerouting, or to gate other workers behind a coordinator's +correction). + +For low-priority hints that should wait for the worker's next turn, send a +plain tagged message instead: + +```bash +echo "Check this when you reach the next turn." \ + | trellis channel send impl-task --as dispatcher --to codex-impl \ + --stdin --tag question +``` + +## Hard Interrupt — `kill` + `--resume` + +Use `kill` when the worker must stop **now** (e.g. runaway loop, bad +instructions already in flight, or `interrupt` is not honored by the +adapter). The supervisor escalates SIGTERM → 8 s grace → SIGKILL; the CLI +writes a `killed` event when SIGKILL is needed so the event log stays +truthful. + +```bash +trellis channel kill impl-task --as codex-impl +trellis channel spawn impl-task --as codex-impl --provider codex \ + --resume "$(cat ~/.trellis/channels/<bucket>/impl-task/worker.session-id)" + +echo "STOP — new instructions: ..." \ + | trellis channel send impl-task --as dispatcher --to codex-impl --stdin +``` + +`kill` flags: + +- `--as <agent>` **(required)** — names the worker (positional `<name>` is the channel). +- `--scope <project|global>`. +- `--force` — SIGKILL immediately (also kills the inner worker pid). + +Side effects: cleans `pid`, `worker-pid`, `config`, `spawnlock` sidecar +files; keeps `log`, `session-id`, `thread-id` for forensics and resume. + +When `interrupt` will not converge, kill + `--resume` is the guaranteed +redirection path. + +## Worker OOM Guard + +The OOM guard prevents orphaned/idle workers from accumulating and exhausting +host resources. It runs at every `spawn` and enforces two policies per +project bucket: + +- **Idle TTL** — sweep workers whose last activity is older than the + configured threshold (default `5m`; `0` disables). +- **Live-worker budget** — refuse the new spawn if more than N workers are + already alive in the same project bucket (default `6`; `0` disables). + +Precedence (highest first): + +1. CLI flags: `--idle-timeout`, `--max-live-workers` on `spawn`. +2. Environment variables: `TRELLIS_CHANNEL_WORKER_IDLE_TIMEOUT`, + `TRELLIS_CHANNEL_MAX_LIVE_WORKERS`. +3. `.trellis/config.yaml` under `channel.worker_guard`. +4. Built-in defaults (`5m`, `6`). + +Cleanup notices are written to stderr at spawn time so operators can see which +idle workers were swept and why a new spawn was rejected. The guard does not +touch ephemeral / `channel run` workers any differently — they are subject to +the same idle TTL and budget. + +To audit current state, list workers via `channel list` (the `WORKERS` +column) and inspect per-channel `pid` / `worker-pid` sidecar files under +`~/.trellis/channels/<bucket>/<channel>/`. + +## Worker Inbox APIs + +The inbox is the channel surface workers wake on. Routing is controlled by +two knobs: + +- **Inbox policy** (`spawn --inbox-policy`): + - `explicitOnly` (default) — worker only wakes on `send --to <worker>` or + `interrupt --to <worker>`. + - `broadcastAndExplicit` — also wakes on broadcasts (`send` with no `--to`). +- **Delivery mode** (`send --delivery-mode`): + - `appendOnly` — append the event regardless of worker state. + - `requireKnownWorker` — fail if no worker named in `--to` was ever spawned. + - `requireRunningWorker` — fail if the named worker is not currently alive. + +Stricter delivery modes prevent silent message loss when callers expect a +running peer. + +Inbox-relevant subcommands: + +- `send <channel> [text]` — append a `message` event. + - `--as <agent>` **(required)** — author. + - `--to <agents>` — CSV; one → string, many → array; broadcast if omitted. + - `--stdin` / `--text-file <path>` / `[text]` — body source. + - `--delivery-mode <appendOnly|requireKnownWorker|requireRunningWorker>`. +- `interrupt <channel> [text]` — soft-interrupt redirect (see above). +- `wait <channel>` — block until matching events arrive. + - `--as <agent>` **(required)** — `self` for filter context. + - `--from <agents>` — CSV authors. + - `--kind <kind[,kind...]>` — CSV (OR semantics); supports `interrupt`, + `done`, `progress`, etc. + - `--to <target>` — defaults to own agent (broadcast + explicit-to-me). + - `--include-progress` — also wake on progress events. + - `--all` — require every `--from` agent to match (timeout → exit **124**). + - `--timeout <duration>` — `30s` / `2m` / `1h` / `1000ms`. +- `messages <channel>` — view / filter / follow the event stream. + - `--follow` to tail, `--kind` / `--from` / `--to` to filter, `--raw` for + JSON-per-line, `--no-progress` to hide progress noise. + +A typical dispatcher loop: + +```bash +# 1. Wake the worker. +echo "Run the failing test and report." \ + | trellis channel send impl-task --as dispatcher --to codex-impl --stdin \ + --delivery-mode requireRunningWorker + +# 2. Block until it finishes. +trellis channel wait impl-task --as dispatcher \ + --from codex-impl --kind done,error --timeout 30m + +# 3. Read the final answer. +trellis channel messages impl-task --from codex-impl --last 1 --raw +``` + +All event-emitting subcommands (`send`, `interrupt`, `post`, `context add` / +`delete`, `title set` / `clear`, `thread rename`) print the appended event as +a single JSON line on stdout, making the inbox layer easy to script against. diff --git a/.claude/skills/trellis-channel/references/workflows.md b/.claude/skills/trellis-channel/references/workflows.md new file mode 100644 index 0000000..3319764 --- /dev/null +++ b/.claude/skills/trellis-channel/references/workflows.md @@ -0,0 +1,128 @@ +# Workflows + +Use these patterns by intent. Prefer durable channels for multi-round work and +`channel run` for one-shot questions. + +## Pattern A: Multi-round Brainstorm + +Use when the user says "和 codex/claude 讨论一下", "brainstorm", or "拉一个 agent +进来一起看". + +```bash +trellis channel create brainstorm-storage-layer --by main \ + --task .trellis/tasks/05-XX-storage-adapter + +trellis channel spawn brainstorm-storage-layer \ + --agent architect --provider codex \ + --file .trellis/tasks/05-XX-storage-adapter/prd.md \ + --file .trellis/tasks/05-XX-storage-adapter/design.md \ + --as cx-arch --timeout 30m + +trellis channel send brainstorm-storage-layer \ + --as main --to cx-arch --text-file /tmp/brainstorm-r1.md + +trellis channel wait brainstorm-storage-layer \ + --as main --kind done --from cx-arch --timeout 10m +``` + +Do not stop after one answer. Read the answer, identify vague areas, send a +new probe, and repeat until the result is executable. + +Minimum round structure: + +1. Direction split: should this live in an existing mechanism or a new one? +2. MVP boundary: v1, v2, and what would force v2 back into v1. +3. Data contract: events, schema, metadata, state source of truth, compatibility. +4. CLI / UX contract: command names, flags, errors, defaults, ambiguity. +5. Cross-layer risk and tests: shared helpers, drift points, release-blocking tests. + +Optional rounds: + +- Operations: logs, debugging, stuck workers, kill/restart, recovery. +- Migration/release: breaking status, manifest, changelog, docs-site. +- Opposition review: ask the peer agent to argue against the current plan. + +Every probe should request concrete file paths, commands, schema, rejected +alternatives, and release-blocking issues. Reject hedging when a decision is +needed. + +## Pattern B: Implement / Check Agent + +Use when the user asks to dispatch implementation or review work. + +```bash +TASK=.trellis/tasks/05-12-foo +trellis channel create cr-foo --task "$TASK" --by main + +trellis channel spawn cr-foo \ + --agent check \ + --jsonl "$TASK/check.jsonl" \ + --file "$TASK/prd.md" \ + --file "$TASK/design.md" \ + --file "$TASK/implement.md" \ + --cwd "$PWD" --timeout 15m + +trellis channel send cr-foo --as main --to check --text-file /tmp/cr-brief.md +trellis channel wait cr-foo --as main --kind done --from check --timeout 15m +trellis channel messages cr-foo --kind message --from check --tag final_answer +``` + +For implement work, use `--agent implement` and send an implementation brief. +For check work, include the exact diff scope, relevant specs, and validation +already run. + +## Pattern C: Parallel Reviewers + +Use one channel and distinct worker names. + +```bash +trellis channel create cr-feature --by main --ephemeral + +trellis channel spawn cr-feature --agent check \ + --jsonl "$TASK/check.jsonl" --file "$TASK/prd.md" --file "$TASK/design.md" \ + --timeout 15m + +trellis channel spawn cr-feature --agent check --provider codex --as check-cx \ + --jsonl "$TASK/check.jsonl" --file "$TASK/prd.md" --file "$TASK/design.md" \ + --timeout 15m + +trellis channel send cr-feature --as main --to check --text-file /tmp/cr-brief.md +trellis channel send cr-feature --as main --to check-cx --text-file /tmp/cr-brief.md +trellis channel wait cr-feature --as main --kind done --from check,check-cx --all --timeout 15m +``` + +`--all` means every listed worker must emit a matching event. + +## Pattern D: One-shot Worker + +```bash +trellis channel run --provider codex --message "say hi in 3 words" --timeout 1m +trellis channel run --agent plan --message-file /tmp/plan-question.md --timeout 10m +``` + +On success, `run` removes the ephemeral channel. On error/timeout/killed, it +keeps the channel and prints the path for inspection. + +## Pattern E: Forum Channel + +Use for issue forums, topic-style feedback, release todos, agent findings, and +internal changelogs. Read `forum.md` for the full model. + +## Pattern F: Take Over Existing Thread + +If the user gives a forum/thread name, restore context yourself: + +```bash +trellis channel forum <board> --scope global +trellis channel thread <board> <thread> --scope global --raw +trellis channel context list <board> --scope global --thread <thread> +trellis channel messages <board> --scope global --raw --thread <thread> +``` + +Output a constraint summary, not a transcript dump: + +- user-level problem +- context files that affect this repo +- current-version versus future-version requirements +- whether current code/design satisfies it +- next action or comment to append diff --git a/.claude/skills/trellis-check/SKILL.md b/.claude/skills/trellis-check/SKILL.md new file mode 100644 index 0000000..c695abd --- /dev/null +++ b/.claude/skills/trellis-check/SKILL.md @@ -0,0 +1,98 @@ +--- +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 Task Artifacts and Applicable Specs + +Read the current task artifacts in order: + +- `prd.md` +- `design.md` if present +- `implement.md` if present + +```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/<package>/<layer>/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..0ffe593 --- /dev/null +++ b/.claude/skills/trellis-meta/SKILL.md @@ -0,0 +1,85 @@ +--- +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, workflows, the channel runtime (trellis channel), bundled runtime agents under .trellis/agents/, selectable workflow templates, registry-backed spec refresh, cross-session memory (trellis mem) generated by trellis init, or AI-facing bundled skills (trellis-channel, trellis-session-insight, trellis-spec-bootstrap) and bundled-skill auto-dispatch flow." +--- + +# 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. + +Trellis v0.6 adds three architectural surfaces on top of the pre-v0.6 workflow / persistence / platform model. First, a multi-agent collaboration runtime: `trellis channel` coordinates multiple AI worker processes through project-scoped JSONL event logs at `~/.trellis/channels/<project>/<channel>/events.jsonl`, with worker OOM guard, forum/thread channels, durable idempotency keys, and bundled `.trellis/agents/{check,implement}.md` runtime definitions. Second, cross-session memory: `trellis mem list | search | context | extract | projects` reads raw Claude Code, Codex, and Pi Agent JSONL already on disk, slices by `--phase brainstorm|implement|all`, and never uploads anything. Third, a dual-package npm release: `@mindfoldhq/trellis` (CLI) and `@mindfoldhq/trellis-core` (SDK with `/channel`, `/task`, `/mem`, `/testing` subpaths) ship in lockstep on one version. Treat these as first-class customization surfaces alongside the per-platform integration files. + +The default operating scope is local files in the user project: + +- `.trellis/`: workflow, config, tasks, spec, workspace, scripts, bundled runtime agents, and runtime state. +- Platform directories: `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, `.kiro/`, `.gemini/`, `.qoder/`, `.codebuddy/`, `.github/`, `.factory/`, `.pi/`, `.reasonix/`, `.kilocode/`, `.agent/`, `.devin/`, `.kimi-code/`, and similar directories. Pi additionally exposes a native `trellis_subagent` tool with `single` / `parallel` / `chain` dispatch modes, throttled progress cards, and `isTrellisAgent()` validation on top of the file layout. Reasonix stores both workflow skills and subagent skills as `.reasonix/skills/<name>/SKILL.md`; subagent skills carry `runAs: subagent` frontmatter. Kimi Code keeps workflow skills in the shared `.agents/skills/` layer and delivers commands plus agent prompts as `.kimi-code/skills/<name>/SKILL.md`. +- Shared skill layer: `.agents/skills/`. +- User-owned channel store outside the project tree: `~/.trellis/channels/<project>/<channel>/events.jsonl`. +- Raw platform conversation logs queryable via `trellis mem`: `~/.claude/projects/`, `~/.codex/sessions/`, and `~/.pi/agent/sessions/` (OpenCode adapter degraded for the v0.6 line). + +Do not assume the user has the Trellis source repository. Do not default to modifying the global npm install directory or `node_modules` — both `@mindfoldhq/trellis` and `@mindfoldhq/trellis-core` ship as published packages sharing one version and one git tag per release. + +## 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 request involves multi-agent dispatch or channel workers, read `references/local-architecture/multi-agent-channel.md` and the bundled `.trellis/agents/` files. +4. If the user wants to change behavior, read `references/customize-local/overview.md`, then open the specific customization topic. +5. 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 layered local Trellis architecture (workflow / persistence / platform / channel runtime) and customization principles. +- `references/local-architecture/generated-files.md`: Files generated by `trellis init` and their customization boundaries, including `.trellis/agents/`. +- `references/local-architecture/workflow.md`: Phases, routing, workflow-state blocks, and selectable workflow templates (`native`, `tdd`, `channel-driven-subagent-dispatch`, marketplace) in `.trellis/workflow.md`. +- `references/local-architecture/task-system.md`: Task directories, active task, JSONL context, parent/child task trees, and task runtime. +- `references/local-architecture/spec-system.md`: How `.trellis/spec/` is organized, injected, and refreshed from a `registry.spec` source. +- `references/local-architecture/workspace-memory.md`: `.trellis/workspace/` journals plus `trellis mem` cross-session recall and the `@mindfoldhq/trellis-core/mem` SDK. +- `references/local-architecture/context-injection.md`: Hooks, sub-agent preludes, and channel-runtime worker inbox routing. +- `references/local-architecture/multi-agent-channel.md`: `trellis channel` subcommands, project-scoped event store, forum/thread channels, worker OOM guard, durable idempotency, and bundled `.trellis/agents/` runtime agents. +- `references/local-architecture/bundled-skills.md`: Auto-dispatched bundled skills (`trellis-meta`, `trellis-spec-bootstrap`, `trellis-session-insight`) and how `getBundledSkillTemplates()` ships them to every platform skill root. + +### Platform Files + +- `references/platform-files/overview.md`: How shared `.trellis/` files relate to platform directories and the four platform integration modes (hook-driven, agent prelude, main-session workflow, channel runtime). +- `references/platform-files/platform-map.md`: Platform directories and paths for skills, agents, hooks, and extensions across all supported platforms including Reasonix and Pi's native `trellis_subagent` extension. +- `references/platform-files/hooks-and-settings.md`: How settings/config files, hooks, plugins, and extensions connect to Trellis; covers `channel.worker_guard.*` and `codex.dispatch_mode`. +- `references/platform-files/agents.md`: Per-platform `trellis-research` / `trellis-implement` / `trellis-check` sub-agent files plus bundled `.trellis/agents/{check,implement}.md` for the channel runtime. +- `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, workflow-state, and the selected workflow template. +- `references/customize-local/change-task-lifecycle.md`: Change task creation, status, archive behavior, parent/child links, archive slug collision handling, and lifecycle hooks. +- `references/customize-local/change-context-loading.md`: Change how tasks, specs, journals, hook context, channel inbox messages, and `trellis mem` recall are loaded. +- `references/customize-local/change-hooks.md`: Change platform hooks, settings, task lifecycle hooks (`hooks.after_*`), and shell session bridges. +- `references/customize-local/change-agents.md`: Change research, implement, and check agent behavior across platform sub-agents, bundled channel runtime agents, and the Codex `dispatch_mode` toggle. +- `references/customize-local/change-skills-or-commands.md`: Add or modify local skills, commands, prompts, and workflows; covers upstream bundled-skill auto-dispatch. +- `references/customize-local/change-spec-structure.md`: Adjust the project spec structure under `.trellis/spec/`, including registry-backed sources. +- `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; its initial content was selected from a workflow template (built-in `native`, `tdd`, `channel-driven-subagent-dispatch`, or a marketplace template) at `trellis init` time and can be re-selected via `trellis workflow --template <id>`. Missing `.trellis/agents/<name>.md` files referenced by the active template trigger a non-blocking stderr warning pointing at `trellis update`. +- `.trellis/config.yaml` is the project-level Trellis configuration entry point. It hosts task lifecycle hooks (`hooks.after_create` / `after_start` / `after_finish` / `after_archive`), journal shape (`session_commit_message` / `max_journal_lines` / `session_auto_commit`), channel worker guard (`channel.worker_guard.idle_timeout` / `max_live_workers`), Codex dispatch mode (`codex.dispatch_mode: inline | sub-agent`), and the spec registry block (`registry.spec.source` + `registry.spec.template`). +- `.trellis/spec/` stores the user's project-specific coding conventions and design constraints. When `registry.spec` is set, files are refreshed by `trellis update`; local edits surface as "modified by user" conflicts in `.trellis/.template-hashes.json`. +- `.trellis/tasks/` stores task PRDs, design notes, implement plans, research files, and JSONL context. Tasks form parent/child trees: `task.py create --parent <slug>`, `task.py add-subtask <parent> <child>`, `task.py remove-subtask <parent> <child>`, and `task.py list-context <task>`. `task.py create` rejects a slug already present in `.trellis/tasks/archive/**`. +- `.trellis/workspace/` stores **deliberately written** developer journals. Raw cross-session dialogue is **not** stored here — it lives on disk under `~/.claude/projects/`, `~/.codex/sessions/`, and `~/.pi/agent/sessions/` and is recovered via `trellis mem search|extract|context`. The bundled `trellis-session-insight` skill teaches when to reach for `mem`. +- `.trellis/agents/{check,implement}.md` are bundled, platform-agnostic channel runtime agent definitions loaded by `trellis channel spawn --agent <name>`. Editable; `trellis update` backfills missing ones. Editing the per-platform `trellis-implement.md` / `trellis-check.md` does **not** change channel-runtime worker behavior. +- `~/.trellis/channels/<project>/<channel>/events.jsonl` is the channel runtime event log per project per channel. User-owned, file-locked sequence numbering, durable `idempotencyKey` support; never under `.trellis/`. +- Bundled multi-file skills (`trellis-meta`, `trellis-spec-bootstrap`, `trellis-session-insight`, `trellis-channel`) are auto-dispatched to every platform skill root by `getBundledSkillTemplates()` in `packages/cli/src/templates/common/index.ts`. Dropping a new directory under `packages/cli/src/templates/common/bundled-skills/` (upstream) ships it to every platform on the next `trellis update`. +- Platform settings/config files decide which hooks, agents, skills, commands, prompts, and workflows actually run. Reasonix has no settings file — behavior is encoded inside skill frontmatter. +- `.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` or `node_modules/@mindfoldhq/trellis-core` to implement project needs; both packages ship in lockstep. +- Do not overwrite user-modified local files with default templates; check `.trellis/.template-hashes.json` first and prefer `.new` sidecar files over destructive overwrites. +- Do not put team-private project rules into any public bundled skill (`trellis-meta`, `trellis-spec-bootstrap`, `trellis-session-insight`, `trellis-channel`); put project rules in `.trellis/spec/`, a project-local skill, the current task, or the workspace journal — `trellis update` will overwrite anything inside a bundled skill directory. +- Do not hand-edit `~/.trellis/channels/<project>/<channel>/events.jsonl`; sequence numbers are assigned under a file lock and replay-safe writes go through the `trellis channel` CLI or the `@mindfoldhq/trellis-core/channel` SDK. +- Do not edit `.claude/agents/trellis-implement.md` (or any other per-platform sub-agent file) when the goal is to change channel runtime worker behavior — edit `.trellis/agents/<name>.md` instead. +- Do not describe removed or never-shipped mechanisms as current Trellis behavior; cross-check against the local `.trellis/config.yaml` and the installed CLI's `trellis --help` before claiming a knob exists. 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/<layer>/` | +| Cross-layer thinking methods | `.trellis/spec/guides/` | +| AI capability for a project-specific flow | Platform-local skill | +| One-off task material | `.trellis/tasks/<task>/` | +| Session summary | `.trellis/workspace/<developer>/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 <task> implement ".trellis/spec/backend/error-handling.md" "Error handling conventions" +python3 ./.trellis/scripts/task.py add-context <task> 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..860c34c --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/change-agents.md @@ -0,0 +1,56 @@ +# 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` | +| Reasonix | `.reasonix/skills/trellis-*/SKILL.md` (subagent frontmatter) | +| ZCode | `.zcode/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..002a259 --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/change-context-loading.md @@ -0,0 +1,84 @@ +# 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/<task>/prd.md` | Current task requirements. | +| `.trellis/tasks/<task>/design.md` | Complex task technical design. | +| `.trellis/tasks/<task>/implement.md` | Complex task execution plan. | +| `.trellis/tasks/<task>/implement.jsonl` | Spec/research to read before implementation. | +| `.trellis/tasks/<task>/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. the corresponding JSONL +3. spec/research referenced by the JSONL +4. `prd.md` +5. `design.md` if present +6. `implement.md` if present + +## Troubleshooting Order + +```bash +python3 ./.trellis/scripts/task.py current --source +python3 ./.trellis/scripts/task.py list-context <task> +python3 ./.trellis/scripts/task.py validate <task> +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..79aa5c5 --- /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`, `.trae/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 <task> +``` + +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..9e25eff --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/change-skills-or-commands.md @@ -0,0 +1,123 @@ +# 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. + +Before editing, classify the skill you are about to touch: + +- **Bundled upstream skill** — `trellis-meta`, `trellis-spec-bootstrap`, `trellis-session-insight`, `trellis-channel`. Source of truth lives in the Trellis CLI repo under `packages/cli/src/templates/common/bundled-skills/<name>/`; auto-dispatched to every platform's skill root by `getBundledSkillTemplates()` on `trellis init` / `trellis update`. Local edits here are tracked by `.trellis/.template-hashes.json` and will be flagged on the next update. +- **Project-local skill** — anything else under `.{platform}/skills/`. Owned by the user; not refreshed by `trellis update`. + +The remainder of this file uses "skill" for the local file; the override and conflict rules differ between the two cases. + +## 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/` +5. `.trellis/.template-hashes.json` — confirms whether the skill you are about to edit is upstream-owned (entry present) or project-local (entry absent) + +## 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 — never a bundled skill directory. | +| Tweak a bundled skill (`trellis-meta` et al.) for the user's own project | Create a project-local sibling skill (different name) that overrides intent, or edit `.trellis/spec/`. Edits inside the bundled skill directory survive only until the next `trellis update` and will need a "keep" choice each time. | +| Contribute the change back upstream | Edit `packages/cli/src/templates/common/bundled-skills/<name>/` in the Trellis CLI repo, not the deployed copy. | +| Change Trellis flow semantics | Synchronize `.trellis/workflow.md`. | + +## Modify A Skill + +A skill is usually: + +```text +<skill-name>/ +├── 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. + +### Bundled vs. Project-Local + +The same directory shape is used by two very different ownership models: + +| Aspect | Bundled (`trellis-meta`, `trellis-spec-bootstrap`, `trellis-session-insight`, `trellis-channel`) | Project-local | +| --- | --- | --- | +| Source of truth | `packages/cli/src/templates/common/bundled-skills/<name>/` in Trellis CLI repo | Inside the user project itself | +| Dispatch | Auto-dispatched to every platform skill root by `getBundledSkillTemplates()` (`packages/cli/src/templates/common/index.ts`) on `trellis init` / `trellis update` | Created by the user (or another skill) and never moved | +| Hash tracking | Every file recorded in `.trellis/.template-hashes.json`; conflict prompt on update | Not tracked | +| Editing locally | Allowed but will be marked "modified by user" on next update | Free editing | +| The right way to customize | Add a *new* project-local skill with a *different* name that supplements (or supersedes) the bundled one | Edit the file directly | + +If the goal is "make my project's AI behave differently when discussing release notes," the answer is almost always a project-local skill, not surgery on `trellis-meta/`. + +## 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/` | +| Gemini CLI | `.agents/skills/`, `.gemini/commands/` | +| Kiro | `.kiro/skills/` | +| Qoder | `.qoder/skills/`, `.qoder/commands/` | +| CodeBuddy | `.codebuddy/skills/`, `.codebuddy/commands/` | +| GitHub Copilot | `.github/skills/`, `.github/prompts/` | +| Factory Droid | `.factory/skills/`, `.factory/commands/` | +| Pi Agent | `.agents/skills/` | +| Reasonix | `.reasonix/skills/` (no separate commands dir; slash commands built into the platform) | +| ZCode | `.zcode/skills/`, `.zcode/commands/` | +| Kilo / Antigravity / Devin | workflows + skills | + +Every directory above is a deploy target for the four bundled skills. Each platform receives a full copy on `trellis init` and refresh on `trellis update`; nothing has to be wired by hand. + +## Add A Project-Local Skill + +If the user wants to document team-private customizations, create a project-local skill — never put project-private content into a bundled skill directory, since `trellis update` will overwrite it. + +```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 (Codex, Gemini CLI). + +Pick a name that does **not** collide with the bundled set: + +- `trellis-meta` +- `trellis-spec-bootstrap` +- `trellis-session-insight` +- `trellis-channel` + +A reused name causes `getBundledSkillTemplates()` to overwrite the project-local copy on the next update. A common convention is to prefix the project name: `acme-trellis-deploy`, `acme-trellis-onboarding`. + +## 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/`. +- Do not hand-edit files inside `trellis-meta/`, `trellis-spec-bootstrap/`, `trellis-session-insight/`, or `trellis-channel/` under any `.{platform}/skills/` directory expecting the change to persist — they are bundled and refreshed by `trellis update`. Either contribute upstream or add a project-local skill that complements them. +- After `trellis update` reports a "modified by you" conflict on a bundled skill file, choose **keep** only if you accept maintaining the divergence by hand; otherwise accept the overwrite and re-apply the intent as a project-local skill. 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..ee9a176 --- /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` planning artifact guidance 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/<layer>/` or `.trellis/spec/<package>/<layer>/` | +| 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 <task> implement ".trellis/spec/webapp/security/index.md" "Security conventions" +python3 ./.trellis/scripts/task.py add-context <task> 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>/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/<context_key>.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 <dir>` 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..337c985 --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/change-workflow.md @@ -0,0 +1,65 @@ +# 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` | lightweight task with `prd.md` complete | ask for start review, then run `task.py start` | +| `planning` | complex task missing `design.md` or `implement.md` | complete missing planning artifacts | +| `planning` | complex task has `prd.md`, `design.md`, and `implement.md` | ask for start review, then 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.3 (spec update) → 3.4 (commit) | +| `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..b75d208 --- /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/`, `.zcode/`. +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/<task>/` | +| Project specs | `.trellis/spec/` | +| Runtime scripts | `.trellis/scripts/` | +| Platform integration | `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, `.zcode/`, 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/bundled-skills.md b/.claude/skills/trellis-meta/references/local-architecture/bundled-skills.md new file mode 100644 index 0000000..ae28870 --- /dev/null +++ b/.claude/skills/trellis-meta/references/local-architecture/bundled-skills.md @@ -0,0 +1,147 @@ +# Bundled Skills + +"Bundled skills" are multi-file built-in skills shipped inside the Trellis CLI npm package. Unlike marketplace skills (which a user installs separately into their own `.claude/skills/` or other platform skill root), bundled skills are written automatically into every supported platform's skill root by `trellis init` and kept in sync by `trellis update`. They are part of Trellis itself, not third-party content. + +A bundled skill is a directory under `packages/cli/src/templates/common/bundled-skills/<skill>/` that already contains its own `SKILL.md` (with YAML frontmatter) plus optional `references/`, assets, or other supporting files. Trellis copies the whole directory tree as-is into each platform's skill root, so references stay lazy-loadable instead of being flattened into one oversized `SKILL.md`. + +## What Counts As Bundled (vs. Adjacent Concepts) + +| Source path | Type | How it ships | +| --- | --- | --- | +| `templates/common/bundled-skills/<name>/` | Bundled skill (multi-file) | Whole directory copied to every platform skill root | +| `templates/common/skills/<name>.md` | Single-file workflow skill | Wrapped with frontmatter, written as `<root>/<name>/SKILL.md` | +| `templates/common/commands/<name>.md` | Slash command / prompt | Written to each platform's command directory (`.claude/commands/trellis/`, `.cursor/commands/trellis-*.md`, `.gemini/commands/trellis/*.toml`, etc.) | +| `templates/<platform>/skills/` | Platform-specific skill | Written only into that platform's directory (e.g. `.codex/skills/`) | +| User skills under `.claude/skills/<my-skill>/` etc. | Marketplace or user-authored | Not managed by Trellis at all | + +The Trellis CLI never touches anything that is not produced by one of its own template loaders. Anything a user drops into a platform skill root by hand is left alone. + +## Current Bundled Skills (v0.6.0) + +The set is discovered at runtime by listing directories under `templates/common/bundled-skills/`: + +| Skill | Purpose | +| --- | --- | +| `trellis-meta` | This skill. Explains the local Trellis architecture and customization entry points to an AI working inside a user project. | +| `trellis-session-insight` | Wraps the `trellis mem` CLI so an AI knows when and how to reach into past Claude Code / Codex / Pi Agent conversation logs. | +| `trellis-spec-bootstrap` | Platform-neutral workflow for creating or refreshing `.trellis/spec/` from the real codebase (with optional GitNexus / ABCoder integration). | +| `trellis-channel` | Capability skill teaching an AI when to reach for `trellis channel` for multi-agent collaboration, forum/thread persistent boards, and dispatcher-wait patterns. | + +The list is discovered at runtime, so adding a new directory under `bundled-skills/` is the only step required to register a new skill (see "Adding a New Bundled Skill" below). + +## Where Bundled Skills Land Per Platform + +Each platform configurator calls `writeSkills(<root>, <workflowSkills>, resolveBundledSkills(ctx))` during `trellis init`. `resolveBundledSkills` reads every directory under `templates/common/bundled-skills/`, resolves placeholders, and returns a flat list of `{relativePath, content}` entries. `writeSkills` then mirrors them under the platform's skill root. + +| Platform | Bundled skill root | Notes | +| --- | --- | --- | +| Claude Code | `.claude/skills/<skill>/` | `configureClaude` | +| Cursor | `.cursor/skills/<skill>/` | `configureCursor` | +| Codex | `.agents/skills/<skill>/` | `configureCodex` writes the shared `.agents/skills/` root, which Gemini CLI 0.40+ also reads | +| Gemini CLI | `.agents/skills/<skill>/` | Same shared root as Codex; the two configurators are required to produce byte-identical output | +| Kiro | `.kiro/skills/<skill>/` | `configureKiro` (skills-based platform — no commands) | +| Qoder | `.qoder/skills/<skill>/` | `configureQoder` | +| Codebuddy | `.codebuddy/skills/<skill>/` | `configureCodebuddy` | +| Copilot | `.github/skills/<skill>/` | `configureCopilot` | +| Droid | `.factory/skills/<skill>/` | `configureDroid` | +| Antigravity | `.agent/skills/<skill>/` | `configureAntigravity` | +| Devin | `.devin/skills/<skill>/` | `configureDevin` | +| Kilo | `.kilocode/skills/<skill>/` | `configureKilo` | +| ZCode | `.zcode/skills/<skill>/` | `configureZcode` | +| OpenCode | (handled by `collectOpenCodeTemplates`) | Uses the same `resolveBundledSkills(ctx)` output | +| Pi, Reasonix | (their own collectors) | Same `resolveBundledSkills(ctx)` output | + +Two paths exercise the same data: + +1. `configureX(cwd)` writes files during `trellis init`. +2. `collectPlatformTemplates(platformId)` (in `configurators/index.ts`) returns a `Map<filePath, content>` that `trellis update` uses to detect drift and to populate `.trellis/.template-hashes.json`. Both must produce byte-identical output, so they both call `resolveBundledSkills(ctx)` and `collectSkillTemplates(root, …, resolveBundledSkills(ctx))`. + +## Dispatch Wiring (Code Path) + +The mechanism that auto-dispatches bundled skills to platform skill roots lives in two files: + +1. `packages/cli/src/templates/common/index.ts` + - `listDirectories("bundled-skills")` enumerates the on-disk skills. + - `listBundledSkillFiles(skillDir)` walks each skill's directory recursively and returns `{relativePath, content}` for every file. + - `getBundledSkillTemplates()` returns the cached `CommonBundledSkill[]`. + +2. `packages/cli/src/configurators/shared.ts` + - `resolveBundledSkills(ctx)` flattens that list into `ResolvedSkillFile[]` with `<skill>/<relativePath>` paths and resolved placeholders. + - `writeSkills(skillsRoot, workflowSkills, bundledSkills)` writes both workflow skills and bundled skill files under `skillsRoot`. + - `collectSkillTemplates(skillsRoot, workflowSkills, bundledSkills)` returns the same shape as a `Map<filePath, content>` for the update / hash pipeline. + +Every platform configurator that supports skills imports both helpers (see `claude.ts`, `cursor.ts`, `codex.ts`, `gemini.ts`, `kiro.ts`, `qoder.ts`, `codebuddy.ts`, `copilot.ts`, `droid.ts`, `antigravity.ts`, `devin.ts`, `kilo.ts`). The `index.ts` `PLATFORM_FUNCTIONS` registry also calls `resolveBundledSkills(ctx)` inside each `collectTemplates` closure so `trellis update` tracking stays consistent. + +## Adding a New Bundled Skill + +The shape and dispatch wiring are already generic, so adding a skill requires only file changes plus distribution verification. + +1. **Create the directory tree.** + + ``` + packages/cli/src/templates/common/bundled-skills/<my-skill>/ + SKILL.md # YAML frontmatter + body + references/ # optional + <topic>.md + assets/ # optional (anything readable as utf-8) + ``` + +2. **Write a valid `SKILL.md` header.** The frontmatter must include at minimum: + + ```yaml + --- + name: <my-skill> + description: "When the AI should reach for this skill. Triggering phrases go here." + --- + ``` + + The `description` is what each platform's auto-trigger mechanism matches against, so it should describe the user-intent triggers, not the skill's internals. + +3. **Use placeholders where appropriate.** Bundled skill content runs through `resolvePlaceholders(file.content, ctx)`. Any `{{platform_name}}`, `{{python_cmd}}`, etc. token supported by `resolvePlaceholders` will be substituted per platform. + +4. **No dispatch wiring is required.** `listDirectories("bundled-skills")` discovers the new directory automatically, so all platforms receive it on the next `trellis init` or `trellis update`. + +5. **Verify the distribution path** before shipping. Skipping any of these steps has historically caused features to be documented as bundled while the published npm tarball was missing the files: + + - Source files exist on the branch being tagged. + - `pnpm --filter @mindfoldhq/trellis build` copies the asset into `dist/templates/common/bundled-skills/<skill>/`. + - `npm pack --dry-run --json` includes the expected `dist/**` paths. + - In a fresh temp project, `trellis init` writes `.claude/skills/<skill>/SKILL.md`, `.agents/skills/<skill>/SKILL.md`, `.zcode/skills/<skill>/SKILL.md`, etc. + - `.trellis/.template-hashes.json` lists the generated files. + - `trellis update --dry-run` in that temp project reports "Already up to date!". + +6. **Add a migration manifest entry** if the skill is added in a release that other projects will upgrade into. Without an explicit manifest entry the file will land via the standard "missing file" branch of `trellis update`, but a manifest makes the change visible in the changelog. + +## Overriding a Bundled Skill Locally + +There is no formal "project-local skill" mechanism (e.g. `.trellis/skills/`). Bundled skills are platform-rooted, so any override is platform-rooted too. + +The supported pattern relies on the existing template-hash diff in `trellis update`: + +1. Edit the local file directly. Example: `.claude/skills/trellis-meta/SKILL.md`. +2. The file's hash now diverges from the entry in `.trellis/.template-hashes.json`. +3. The next `trellis update` detects the user modification and leaves the file untouched (Trellis never overwrites user-modified files without an explicit `--force`). + +Caveats: + +- The override only applies to the one platform whose directory you edited. To override the same skill across, for example, Claude Code and Codex, you must edit both `.claude/skills/<name>/` and `.agents/skills/<name>/`. +- A future `trellis update --force` will overwrite local edits. Keep the override under version control so it can be reapplied if needed. +- Marketplace skills installed under the same platform skill root with a different folder name (e.g. `.claude/skills/my-custom-meta/`) are untouched by Trellis and are the cleaner option when the goal is to add behavior, not to mutate the bundled skill. +- Team-private conventions belong in `.trellis/spec/` or in a separate marketplace-style local skill, not in modifications to `trellis-meta` itself. See `customize-local/add-project-local-conventions.md`. + +## Removing a Bundled Skill From a Project + +There is no per-project opt-out flag for bundled skills. Two options: + +1. **Delete the directory in each platform skill root.** `trellis update` will see the file missing, compare against `.template-hashes.json`, and treat the deletion the same as any other user modification — it will not silently re-create the directory unless `--force` is passed. + +2. **Pin a Trellis version that did not ship the skill.** The bundled-skill set is determined at build time, so installing an older release of the CLI is the only way to permanently exclude a skill that the current release ships. + +A third option — globally disabling all bundled skills — is not supported. The dispatch is unconditional in every configurator. Adding such a flag would require changing `PLATFORM_FUNCTIONS` in `configurators/index.ts` and every `configureX` function. + +## Operating Rules + +- Treat `templates/common/bundled-skills/` as the single source of truth for what bundled skills exist. Do not hand-maintain platform-by-platform skill lists. +- Do not add platform-specific logic inside a bundled `SKILL.md`. If a behavior is platform-specific, put it in `templates/<platform>/skills/` instead. +- Do not couple bundled skills to a specific CLI binary (e.g. `trellis mem`) without surfacing the dependency in the skill's description and references — users on older releases may not have the command. +- Do not store project-private content in a bundled skill. Bundled skills are public, shipped to every user; project rules belong in `.trellis/spec/` or a local skill. 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..4a7517b --- /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/<task>/prd.md`, `design.md`, `implement.md`, `research/` | Current task requirements, design, execution plan, 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 jsonl-referenced files plus `prd.md`, `design.md` if present, and `implement.md` if present before the agent starts. +2. **agent pull**: the agent definition instructs the agent to read the active task, jsonl context, and task artifacts after startup. + +In both modes, JSONL files in the task directory are the manifest for spec/research context. Task artifacts are read separately in this order: `prd.md` -> `design.md if present` -> `implement.md if present`. + +## 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 task artifacts/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..b460224 --- /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`, `.trae/hooks.json` | Tell the platform when to run hooks or plugins. | +| agents | `.claude/agents/`, `.codex/agents/`, `.kiro/agents/`, `.zcode/agents/` | Define agents such as `trellis-research`, `trellis-implement`, and `trellis-check`. | +| skills | `.claude/skills/`, `.agents/skills/`, `.qoder/skills/`, `.zcode/skills/` | Skills that auto-trigger or can be read by AI. | +| commands/prompts/workflows | `.cursor/commands/`, `.github/prompts/`, `.devin/workflows/`, `.zcode/commands/` | 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/multi-agent-channel.md b/.claude/skills/trellis-meta/references/local-architecture/multi-agent-channel.md new file mode 100644 index 0000000..6df61eb --- /dev/null +++ b/.claude/skills/trellis-meta/references/local-architecture/multi-agent-channel.md @@ -0,0 +1,69 @@ +# Local Multi-Agent Channel Runtime + +`trellis channel` is the local multi-agent collaboration runtime shipped with the Trellis CLI. It lets the main AI session spawn peer workers (Claude Code, Codex, or any agent definition under `.trellis/agents/`), exchange durable messages through an event log, and coordinate review or brainstorm loops without hand-stitching shell pipelines. + +This reference covers how channels are wired into the user project so an AI customizing the project knows what to edit. For runtime usage (commands, forum/thread patterns, worker spawn flags), defer to the bundled `trellis-channel` capability skill. + +## Local System Model + +The channel runtime spans three local surfaces: + +1. **Storage layer** in the user's home directory: durable event logs and worker state files. +2. **Agent definitions** inside the project at `.trellis/agents/`: platform-agnostic role cards consumed by `trellis channel spawn --agent <name>`. +3. **Project configuration** in `.trellis/config.yaml`: worker guard thresholds and other channel knobs. + +## Core Paths + +| Path | Purpose | +| --- | --- | +| `~/.trellis/channels/<project>/<channel>/events.jsonl` | Per-channel append-only event log. Sequence-locked, replay-safe. | +| `~/.trellis/channels/<project>/<channel>/<channel>.lock` | Channel-level write lock. | +| `~/.trellis/channels/<project>/<channel>/<worker>.spawnlock` | Per-worker spawn lock used by the OOM guard. | +| `~/.trellis/channels/<project>/<channel>/.seq` | Sequence sidecar for ordered event assignment. | +| `~/.trellis/channels/_global/<channel>/...` | Channels created with `--scope global`. The project bucket is replaced by a shared key. | +| `.trellis/agents/check.md` | Default Check Agent role definition consumed by `--agent check`. | +| `.trellis/agents/implement.md` | Default Implement Agent role definition consumed by `--agent implement`. | +| `.trellis/config.yaml` (`channel.*` block) | Worker guard thresholds and channel defaults. | + +The project bucket name is derived from the absolute project path (slashes flattened, non-alphanumerics replaced with `-`), matching Claude Code's `~/.claude/projects/<sanitized-cwd>/` convention. Override with `TRELLIS_CHANNEL_ROOT` (root directory) or `TRELLIS_CHANNEL_PROJECT` (bucket name) for testing or sandboxing. + +## When To Reach For The Channel Runtime + +Channels are heavier than a single Bash call or a one-shot sub-agent dispatch. Use them only when at least one of these conditions holds: + +- The work needs **two or more agents to converse** through more than one turn (cross-AI brainstorm, peer review, dispatcher + worker). +- A worker should run as a **peer process** that the main session can interrupt, watch progress on, or wait for asynchronously. +- The conversation must be **durable and inspectable** later (forum/thread channels, issue boards, decision trails). +- Multiple workers must **share an event log** so each can see what the others reported. + +Prefer cheaper primitives when: + +- A single-shot Bash command or single Agent tool call is enough -> do that directly. +- The user just needs a static review against a file -> read the file and reply inline. +- The need is "remember what we discussed last week" -> use `trellis mem` instead of a channel. + +## Customization Points + +| Need | Edit location | +| --- | --- | +| Change default channel worker idle timeout | `channel.worker_guard.idle_timeout` in `.trellis/config.yaml`. Accepts `5m`, `30s`, etc. Set `0` to disable idle cleanup. | +| Change live worker budget | `channel.worker_guard.max_live_workers` in `.trellis/config.yaml`. Set `0` to disable the spawn-time budget check. | +| Override worker guard per spawn | Pass `--idle-timeout` / `--max-live-workers` on `trellis channel spawn`, or set `TRELLIS_CHANNEL_WORKER_IDLE_TIMEOUT` / `TRELLIS_CHANNEL_MAX_LIVE_WORKERS` in the environment. | +| Change what the default Check or Implement worker does | Edit `.trellis/agents/check.md` or `.trellis/agents/implement.md`. These are platform-agnostic role cards; the channel runtime injects them when `--agent check|implement` is passed. | +| Add a new role card | Drop `<name>.md` into `.trellis/agents/`. `trellis channel spawn --agent <name>` will pick it up. | +| Relocate channel storage (CI sandbox, ephemeral runs) | Set `TRELLIS_CHANNEL_ROOT=/path/to/dir`. Channel events move with it; existing channels stay at the old root. | +| Switch storage scope | Pass `--scope project` (default) or `--scope global` on every channel subcommand. The bucket directory changes; nothing else does. | + +Precedence for the worker guard is: CLI flag > environment variable > `.trellis/config.yaml` > built-in default. Built-in defaults are `idle_timeout: 5m` and `max_live_workers: 6`. + +## Relationship To Other Local Layers + +- **Workflow layer**: workflows that use channel dispatch (such as `channel-driven-subagent-dispatch`) instruct the main agent to call `trellis channel spawn --agent check` or `--agent implement` instead of a platform sub-agent. If `.trellis/agents/check.md` or `implement.md` is missing, `trellis workflow --template <id>` prints a non-blocking warning at install time. Restore them with `trellis update` if they are deleted by accident. +- **Task layer**: channel workers do not own task state. The supervising main session passes the active task path through the worker inbox; the worker resolves task artifacts from disk. +- **Spec layer**: workers read `.trellis/spec/` the same way the main session does. Channel runtime does not bypass spec context loading. +- **Platform integration layer**: channel runtime is platform-neutral. It does not depend on `.claude/`, `.codex/`, or any other platform directory. The adapters that normalize provider output (Claude `stream-json`, Codex `app-server`) live inside the Trellis CLI binary, not in the project. +- **Platform sub-agent files vs. channel workers**: editing `.claude/agents/trellis-implement.md` (and its peers in other platform `.X/agents/` directories) does NOT change channel-runtime worker behavior — channel workers load `.trellis/agents/<name>.md`. The platform-specific agent files are for direct sub-agent dispatch from the main AI session, not for channel-spawned workers. See `platform-files/agents.md` for the per-platform agent surface, and the `trellis-meta/SKILL.md` rule that codifies this split. + +## Runtime Usage + +For command syntax, forum/thread patterns, worker handles, progress inspection, and the `--kind done` / `--kind turn_finished` dispatcher wait pattern, load the bundled `trellis-channel` skill (auto-installed under each platform's skills directory after `trellis init` / `trellis update`). This reference only covers the local file layout and customization knobs; it does not duplicate command syntax that may change between releases. 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..e97cab8 --- /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/`, `.devin/`, `.reasonix/`, `.zcode/`, 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..38fdf14 --- /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, planning may write relevant specs into `implement.jsonl` / `check.jsonl` when the task needs spec or research context beyond the task artifacts: + +```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/<package>/<layer>/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..7133495 --- /dev/null +++ b/.claude/skills/trellis-meta/references/local-architecture/task-system.md @@ -0,0 +1,130 @@ +# 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 +│ ├── design.md +│ ├── implement.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, constraints, and acceptance criteria. Lightweight tasks may be PRD-only. | +| `design.md` | Technical design for complex tasks: boundaries, contracts, data flow, compatibility, tradeoffs. | +| `implement.md` | Execution plan for complex tasks: ordered checklist, validation commands, review gates, rollback points. | +| `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. | + +## Parent / Child Task Trees + +Parent/child task relationships are for work structure. A parent task groups related deliverables under one source requirement set; it is not a dependency scheduler and does not replace the child task's own planning artifacts. + +Use a parent task when a request has multiple independently verifiable deliverables. The parent owns: + +- Source requirements and user-facing scope. +- The map of child tasks and their responsibility boundaries. +- Cross-child acceptance criteria and final integration review. + +Use child tasks for deliverables that can move through planning, implementation, check, and archive independently. If one child depends on another, write that dependency in the child `prd.md` / `implement.md`; do not rely on tree position to imply ordering. + +Create new children with: + +```bash +python3 ./.trellis/scripts/task.py create "<child title>" --slug <child-slug> --parent <parent-dir> +``` + +Link or unlink existing tasks with: + +```bash +python3 ./.trellis/scripts/task.py add-subtask <parent-dir> <child-dir> +python3 ./.trellis/scripts/task.py remove-subtask <parent-dir> <child-dir> +``` + +`children` on the parent is a historical list. When a child is archived, Trellis keeps that child name in the parent so progress like `[2/3 done]` remains meaningful after completed children move to `archive/`. + +The AI should not treat phase numbers as task status. Task progress is mainly determined by `status`, artifact presence (`prd.md`, optional `design.md` / `implement.md`), whether JSONL context is configured for sub-agent mode, 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/<context-key>.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. They do not replace `implement.md`; `implement.md` is the human-readable execution plan. + +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 "<title>" --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 | Planning artifact guidance 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..26f472a --- /dev/null +++ b/.claude/skills/trellis-meta/references/platform-files/agents.md @@ -0,0 +1,83 @@ +# 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`, optional `design.md` / `implement.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` | +| Reasonix | `.reasonix/skills/trellis-*/SKILL.md` (subagent frontmatter) | +| ZCode | `.zcode/agents/trellis-*.md` | +| Kimi Code | `.kimi-code/skills/trellis-*/SKILL.md` (agent prompts as skills, dispatched to the built-in `coder`; research needs its file-editing tools to persist findings) | + +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 Devin 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` +- `implement.jsonl` or `check.jsonl` +- spec/research files referenced by JSONL +- current task `prd.md` +- `design.md` if present +- `implement.md` if present + +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, read jsonl/spec context, then read `prd.md`, `design.md` if present, and `implement.md` if present. +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..a2ff389 --- /dev/null +++ b/.claude/skills/trellis-meta/references/platform-files/hooks-and-settings.md @@ -0,0 +1,72 @@ +# 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/` | +| Trae IDE | `.trae/hooks.json` | + +Reasonix is a pull-based platform whose agent files contain prelude instructions to read context after startup. ZCode uses `.zcode/config.json` with shared hooks, including PreToolUse for sub-agent prompt injection. Kimi Code is likewise pull-based and has no project-level settings/hooks file Trellis writes (hooks live only in the user-level `~/.kimi-code/config.toml`), so its agent prompts ship as skills with the same prelude. + +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..f9b72b0 --- /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/`, `.snow/`, `.codex/`, `.cursor/`, `.opencode/`, `.kiro/`, `.gemini/`, `.qoder/`, `.codebuddy/`, `.github/`, `.factory/`, `.pi/`, `.trae/`, `.kilocode/`, `.agent/`, `.devin/`, `.reasonix/`, `.zcode/`, `.kimi-code/`, 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`, `.trae/hooks.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/`, `.zcode/agents/` | Define `trellis-research`, `trellis-implement`, and `trellis-check`. | +| skills | `.claude/skills/`, `.agents/skills/`, `.qoder/skills/`, `.zcode/skills/` | Capability descriptions that auto-trigger or can be read on demand. | +| commands/prompts/workflows | `.cursor/commands/`, `.github/prompts/`, `.devin/workflows/`, `.zcode/commands/` | 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..73b113a --- /dev/null +++ b/.claude/skills/trellis-meta/references/platform-files/platform-map.md @@ -0,0 +1,111 @@ +# 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/` | +| Devin | `--devin` | `.devin/` | `.devin/skills/` | Usually none | `.devin/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/` | `.agents/skills/` | `.pi/agents/` | `.pi/extensions/trellis/` (native `trellis_subagent` tool) + `.pi/settings.json` | +| Trae IDE | `--trae` | `.trae/` | `.trae/skills/` | `.trae/agents/` | `.trae/hooks/` + `.trae/hooks.json` | +| Reasonix | `--reasonix` | `.reasonix/` | `.reasonix/skills/` | None — sub-agents are skills with `runAs: subagent` frontmatter | None | +| ZCode | `--zcode` | `.zcode/` | `.zcode/skills/` | `.zcode/agents/` | `.zcode/hooks/` + `.zcode/config.json` (SessionStart + UserPromptSubmit + PreToolUse Agent/Task); sub-agents use hook-injected context | +| Grok Build | `--grok` | `.grok/` | `.grok/skills/` | `.grok/agents/` | pull-based prelude (no hooks; flat `.grok/commands/trellis-*.md`) | +| Kimi Code | `--kimi` | `.kimi-code/` | `.agents/skills/` (shared) + `.kimi-code/skills/` | None — agent prompts are skills under `.kimi-code/skills/` and dispatch to the built-in `coder` | None (pull-based prelude; no project hooks/settings) | +| Snow CLI | `--snow` | `.snow/` | `.snow/skills/` | `.snow/agents/` (auto-discovered; primary path) | class-1: auto inject + project agents + `beforeSubAgentStart` (`.snow/hooks/` `session`/`user`/`subagent` modes -> `additionalContext` JSON); no legacy sub-agent JSON; commands `.snow/commands/trellis-*.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 +- Trae IDE +- Reasonix (delivered as skills with `runAs: subagent` under `.reasonix/skills/`, not as a separate `agents/` directory) +- ZCode +- Grok Build (`.grok/agents/`; dispatch via `spawn_subagent` with `subagent_type`) +- Kimi Code (delivered as skills under `.kimi-code/skills/`; dispatched to the built-in `coder`, including research because it must persist files) +- Snow CLI (`.snow/agents/`; auto-discovered project agents + class-1 hooks) + +When changing implementation/check/research behavior, look for the corresponding platform agent files first. + +### Native Trellis Sub-Agent Tool + +Some platforms expose a first-class tool that the host runtime understands. The model calls it like any other tool and the host renders progress cards, validates the agent name against `.<platform>/agents/`, and enforces dispatch modes. + +- Pi Agent — `trellis_subagent` tool, defined in `.pi/extensions/trellis/index.ts`. Supports `single` / `parallel` / `chain` dispatch modes and emits live `trellis-subagent-progress` events. + +When changing sub-agent dispatch behavior on these platforms, edit the extension file, **not** the agent markdown — the agent markdown defines responsibilities, but the host extension owns dispatch, validation, and progress rendering. + +### Main-Session Workflow Platforms + +These platforms rely more on workflows/skills to guide the main session: + +- Kilo +- Antigravity +- Devin + +When changing behavior, inspect workflows and skills first. Do not assume Trellis sub-agents exist. + +### Shared `.agents/skills/` + +Codex, Gemini CLI, Pi Agent, and Kimi Code write 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. ZCode keeps Trellis-managed skills under `.zcode/skills/`. + +## 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. + +### `.omp/` — Oh My Pi (OMP) + +Extension-backed platform. OMP native provider auto-discovers all subdirectories. + +```text +.omp/ +├── commands/ # Slash commands (flat .md) +├── skills/ # Auto-triggered skills (SKILL.md per dir) +├── agents/ # Agent definitions (.md) +└── extensions/ + └── trellis/ + └── index.ts # Trellis extension (context injection) +``` + +No `settings.json` — OMP scans `.omp/` subdirectories automatically. +No Python hooks — hook-equivalent behavior lives in the TypeScript extension. 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..89f15ce --- /dev/null +++ b/.claude/skills/trellis-meta/references/platform-files/skills-and-commands.md @@ -0,0 +1,86 @@ +# 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/` | +| Devin | `.devin/skills/`, `.devin/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 | `.agents/skills/` | +| Reasonix | `.reasonix/skills/` | +| ZCode | `.zcode/skills/`, `.zcode/commands/` | +| Kimi Code | `.agents/skills/`, `.kimi-code/skills/` (commands delivered as `/skill:trellis-*` 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/.claude/skills/trellis-session-insight/SKILL.md b/.claude/skills/trellis-session-insight/SKILL.md new file mode 100644 index 0000000..1d9f4ed --- /dev/null +++ b/.claude/skills/trellis-session-insight/SKILL.md @@ -0,0 +1,81 @@ +--- +name: trellis-session-insight +description: "Reach into past AI conversation history through the `trellis mem` CLI. Use whenever the user asks 'how did we solve X last time', 'have we discussed this before', 'what was the decision on X', 'remind me what we did in this task', '上次怎么解的', '之前讨论过吗', '想起一段对话', or when starting a brainstorm that overlaps prior work, debugging a familiar bug, continuing a task across sessions, or doing a finish-work review. Returns raw past dialogue; decide for the moment whether to update spec, append to task notes, quote inline in the answer, or just internalize." +--- + +# Trellis Session Insight + +This skill teaches an AI **how to call `trellis mem`** — the project's cross-session memory feedstock — and **when reaching for it is the right move**. + +It is intentionally a **capability skill, not a workflow**. There is no fixed output file, no required write-back step, no "always run after finish-work" rule. What to do with what `mem` returns is a judgement call made in the moment of the conversation. The skill exists so the AI knows the capability is there and can decide. + +## What `trellis mem` is + +A local CLI that indexes the user's past Claude Code, Codex, Pi Agent, and ZCode conversation logs and lets you list, search, slice by Trellis task boundaries, and dump cleaned dialogue from them. Claude and Codex use `~/.claude/projects/` and `~/.codex/sessions/`. Pi uses its default or environment-configured session root, global `~/.pi/agent/settings.json`, and the scoped project's `.pi/settings.json`; relative `sessionDir` values resolve from the settings file directory. Project-local Pi settings require project-scoped lookup through the current cwd or `--cwd`. ZCode uses `~/.zcode/cli/db/db.sqlite`. OpenCode logs are not yet indexable (provider adapter pending) — when an OpenCode session is the obvious target, surface that limitation rather than guessing. + +Nothing in `mem` is uploaded. All reads are local. + +## When to reach for it + +The bar is "would a senior teammate ask 'didn't we already talk about this?'" — those are the moments. Some concrete patterns: + +- **Brainstorm rerun risk.** Starting a new task that touches an area the user has been in before, and you want to check whether a decision was already made — before re-asking the user. +- **Familiar-bug debugging.** The current bug pattern feels like one the user reported / fixed before. Pulling the relevant past session can save a full debugging loop. +- **Cross-session continuation.** The user resumes work after a gap and says "where were we" / "继续上次的" without being specific. +- **Decision retrieval.** The user references "the decision we made about X" but the decision lives in an old brainstorm, not in any `prd.md` / `spec/`. +- **Finish-work retrospective.** When the user explicitly asks for a wrap-up of what was decided / what hurt / what surprised them in this task — not as a forced step on every finish-work. +- **Pattern-spotting across past work.** The user asks "do I keep making the same mistake on X" / "我每次都踩这个坑吗" — search across sessions answers that. + +If none of these apply, don't call `mem`. It is a tool, not a ceremony. + +## When NOT to reach for it + +- The relevant context is already in the current turn, `prd.md`, `design.md`, recent `git log`, or the open files. `mem` is for stuff that has fallen out of immediate reach. +- The user is asking about a fact in the code, not a fact from a past conversation. `git log -p` / `grep` / reading the file directly is faster and more authoritative. +- You are in a sub-agent (`trellis-implement` / `trellis-check`) whose dispatch prompt already includes the curated `implement.jsonl` / `check.jsonl` context. Adding `mem` on top usually just clutters. +- The user has explicitly said "don't dig through history, just answer what I asked". + +## What to do with what `mem` returns + +Treat the output as **raw material**, not a deliverable. Once you have it, decide based on the live conversation: + +- **Quote inline in your reply** if a specific past exchange answers the user's current question — and cite the session-id / phase so the user can verify. +- **Update `<task>/prd.md` or `<task>/design.md`** if `mem` surfaced a load-bearing decision that should have been written down but wasn't. Surface the proposed edit to the user first. +- **Append to a task-local notes file** (e.g. `<task>/notes.md` or extending an existing one) if the finding belongs to the current task's record but doesn't fit the PRD. +- **Update `.trellis/spec/`** if the finding is a project-wide convention or gotcha that would help future tasks. Run the `trellis-update-spec` skill for that — `session-insight` ends at the discovery. +- **Just absorb it** for the next few turns and answer better, without writing anything. This is often the right move for one-off recall. + +Trellis does not prescribe a single destination. Forcing every recall into a fixed file makes the file grow into noise. Let the situation decide. + +## How to call it + +Full CLI reference is in `references/cli-quick-reference.md`. The 80% case is one of: + +```bash +# Find sessions whose contents mention a keyword (project-scope is default; +# add --global to search every project on this machine). +trellis mem search "<keyword>" + +# Dump dialogue from one session, optionally filtered by phase or keyword. +trellis mem extract <session-id> --phase brainstorm +trellis mem extract <session-id> --grep "<keyword>" + +# Drill into a session: top-N hit turns + surrounding context. +trellis mem context <session-id> --turns 3 --around 2 + +# When you do not know the session id yet, start with list + filter. +trellis mem list --cwd <project-path> +trellis mem projects # → list active project cwds, then narrow +``` + +Phase slicing (`--phase brainstorm|implement|all`) cuts the session at `task.py create` and `task.py start` boundaries. For a finish-work review of the current task, `--phase brainstorm` recovers the planning discussion and `--phase implement` recovers the execution loop. Default is `all`. + +## Triggering patterns + +`references/triggering-patterns.md` lists more verbatim user phrasings (English + Chinese) that should make you think "reach for `mem`" — keep that handy when training instinct. + +## Out of scope + +- `mem` does not edit code or update files. Any write-back is your decision in the moment. +- `mem` is read-only on the platform JSONL stores. It does not push or sync to remote. +- This skill does not replace `trellis-update-spec` (which is the right tool for promoting a finding into project-wide guidance) or the platform-native task / spec workflow. diff --git a/.claude/skills/trellis-session-insight/references/cli-quick-reference.md b/.claude/skills/trellis-session-insight/references/cli-quick-reference.md new file mode 100644 index 0000000..78540f2 --- /dev/null +++ b/.claude/skills/trellis-session-insight/references/cli-quick-reference.md @@ -0,0 +1,65 @@ +# `trellis mem` CLI Reference + +Full flag reference for the five subcommands. Pin this as the authoritative source — `trellis mem help` prints the same content at runtime, so anything here that drifts is a bug. + +## Subcommands + +| Command | Purpose | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `list` | List sessions. Default subcommand when none is given. | +| `search <keyword>` | Find sessions whose contents match a keyword. | +| `context <session-id>` | Drill into one session: top-N hit turns + surrounding context. Pair with `--grep` for keyword anchoring. | +| `extract <session-id>` | Dump cleaned dialogue. Combine with `--phase` / `--grep` to slice. | +| `projects` | List active project `cwd` values with session counts. Use this to discover which `--cwd` to pass to other subcommands. | + +## Flags (apply where meaningful) + +| Flag | Subcommands | Meaning | +| --------------------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--platform claude\|codex\|opencode\|pi\|all` | all | Default `all`. OpenCode adapter is currently a stub on `0.6.0-beta.*` — see "Caveats" below. | +| `--since YYYY-MM-DD` | list / search | Inclusive lower date bound. | +| `--until YYYY-MM-DD` | list / search | Inclusive upper date bound. | +| `--global` | list / search | Include sessions from every project on this machine. Default is the current project `cwd`. | +| `--cwd <path>` | list / search | Force a specific project cwd instead of inferring from where you are. | +| `--limit N` | list / search | Cap output rows. Default `50`. | +| `--grep KW` | extract / context | Filter turns by keyword. Multi-token AND when whitespace-separated. | +| `--phase brainstorm\|implement\|all` | extract | Slice session by Trellis task boundaries. `brainstorm` = `[task.py create, task.py start)`. `implement` = turns outside brainstorm windows. Default `all`. | +| `--turns N` | context | Number of hit turns to return. Default `3`. | +| `--around N` | context | Surrounding turns to include per hit. Default `1`. | +| `--max-chars N` | context | Total character budget. Default `6000` (~1500 tokens). | +| `--include-children` | search / context | Merge OpenCode sub-agent sessions into their parent session. | +| `--json` | all | Emit machine-parseable JSON instead of human-readable output. | + +## Common one-liners + +```bash +# What past sessions discussed "deadlock" anywhere on this machine? +trellis mem search "deadlock" --global --limit 20 + +# Inside a specific session, surface the top 5 turns that mention "lock contention" +# plus 2 turns of surrounding context. +trellis mem context 5842592d --grep "lock contention" --turns 5 --around 2 + +# Recover the brainstorm window for a session — useful when continuing a task +# the user started a week ago. +trellis mem extract 5842592d --phase brainstorm + +# List every project this machine has Trellis sessions for, with counts. +trellis mem projects +``` + +## Output shapes + +- **Default human output** (no `--json`): wrapped to a terminal, with session ids highlighted and turn markers visible. Suitable to read inline but messy to paste into a markdown file. +- **`--json`**: stable schema, safe to parse and process. When piping `mem` output into a follow-up step (e.g. summarizing for a Lessons section), prefer `--json`. + +## Caveats + +- **OpenCode adapter is a stub on `0.6.0-beta.*`.** When `--platform` resolves to OpenCode (or `all` and OpenCode would be included), `mem` prints a one-line "reader unavailable" notice and continues with the other platforms. Don't promise OpenCode coverage in your reply until the adapter ships. +- **`--phase` slicing depends on `task.py create` / `task.py start` invocations appearing in the recorded bash calls of the session.** Sessions where the user ran `task.py` from a different terminal — outside the recorded AI loop — will not have phase boundaries. `--phase all` is the safe fallback. +- **`mem` indexes platform JSONL files directly.** If the user has cleared their Claude / Codex / Pi session storage, `mem` cannot recover what is no longer on disk. +- **`mem` is read-only.** No remote sync, no edits to platform JSONL. Any write you do based on `mem` findings is your own follow-up call into the editing tools available to you. + +## When you need more than this reference + +Run `trellis mem help` in the user's shell. The runtime help is authoritative and will be ahead of this reference during fast-moving beta releases. diff --git a/.claude/skills/trellis-session-insight/references/triggering-patterns.md b/.claude/skills/trellis-session-insight/references/triggering-patterns.md new file mode 100644 index 0000000..66021ca --- /dev/null +++ b/.claude/skills/trellis-session-insight/references/triggering-patterns.md @@ -0,0 +1,93 @@ +# Triggering Patterns + +Verbatim user phrasings that should make an AI reach for `trellis mem`. Calibrate instinct against these — if a user message hits one of these patterns and you do not reach for `mem`, you probably missed an obvious recall. + +Patterns are grouped by the *intent* behind the phrasing, not the surface words. The same intent shows up in different languages and registers. + +## Past-solution recall + +The user is asking "how did we (or I) solve this before". Past dialogue holds the answer; the codebase shows the result but not the reasoning. + +- "How did we solve this last time?" +- "What did we end up doing about X?" +- "We dealt with this once already, didn't we?" +- "上次怎么解的?" +- "之前是怎么搞定 X 的?" +- "我记得以前修过类似的" + +Reach: `trellis mem search "<symptom keyword>" --global --limit 10`, then `context` into the hit that looks closest. + +## Decision retrieval + +The user is referencing a decision that lives in old dialogue, not in any committed file. Look in brainstorm windows. + +- "What was the decision on X?" +- "Did we decide to use Postgres or SQLite?" +- "The rationale for choosing X over Y was…?" +- "我们当时为啥选了 X 而不是 Y?" +- "关于 X 我们之前是怎么定的?" +- "之前讨论过 X 的方案吗?" + +Reach: `trellis mem search "<decision keyword>"` to find the session, then `extract <id> --phase brainstorm` to recover the discussion. + +## Cross-session continuation + +The user resumed work after a gap and the context is implicit. + +- "Where were we?" +- "Continue from last time." +- "Pick up where we left off." +- "继续上次的" +- "我们上次做到哪了" +- "接着昨天那个任务" + +Reach: `trellis mem list --task <current-task-dir>` to find the most recent sessions tied to the active task, then `extract` the last one. + +## Familiar-bug debugging + +The current bug feels like one already seen. Past sessions probably hold the resolution path. + +- "I feel like I've hit this before." +- "Doesn't this look like that bug from last month?" +- "Same kind of timeout I had in X." +- "这个错好像之前见过" +- "这个 bug 是不是上次那个?" +- "怎么又是这个 error?" + +Reach: `trellis mem search "<error message fragment>" --global`. Anchor on a short, distinctive token from the actual error string. + +## Self-pattern spotting + +The user is asking whether they keep repeating the same kind of mistake or decision. + +- "Do I always make this mistake?" +- "How often have I run into X?" +- "Is this a recurring thing for me?" +- "我每次都踩这个坑吗?" +- "我老犯这个错?" +- "这类问题之前出现过几次?" + +Reach: `trellis mem search "<topic>" --global --limit 50` and scan the dates / projects in the listing. Optionally `extract` two or three for comparison. + +## Finish-work retrospective (on demand) + +The user explicitly wants to look back at this task — not as a forced step, only when they ask. + +- "Summarize what we did in this task." +- "What were the key decisions / surprises?" +- "Write up the lessons from this round." +- "总结一下这次的经验" +- "记一下这次踩的坑" +- "复盘下这个任务" + +Reach: identify the current task's session id (from `.trellis/.runtime/sessions/*.json` or `mem list --task <task-dir>`), then `extract <id> --phase brainstorm` and `--phase implement`. Present a summary — surface concrete file:line citations where possible. Whether to also write the summary somewhere (PRD, spec, notes file) is the user's call; offer, don't auto-write. + +## Anti-patterns: do NOT reach for `mem` here + +- "What does this function do?" → read the file. +- "Why is this test failing?" → read the test output and the file. +- "What's the right pattern for X in our codebase?" → grep / read spec files. +- "What's the latest npm version of Y?" → call `npm view`. +- "Fix this bug." → debug. Reach for `mem` only if you suspect prior context exists; otherwise it is noise. + +The bar stays: would a senior teammate ask "didn't we already talk about this?" before answering? If yes, reach for `mem`. If no, don't. diff --git a/.claude/skills/trellis-spec-bootstrap/SKILL.md b/.claude/skills/trellis-spec-bootstrap/SKILL.md new file mode 100644 index 0000000..e1650df --- /dev/null +++ b/.claude/skills/trellis-spec-bootstrap/SKILL.md @@ -0,0 +1,41 @@ +--- +name: trellis-spec-bootstrap +description: "Bootstrap project-specific Trellis coding specs with a platform-neutral single-agent workflow. Use when creating or refreshing .trellis/spec guidelines, analyzing a codebase with GitNexus, ABCoder, or source inspection, decomposing package/layer spec work, and writing real codebase-backed spec docs without placeholder text." +--- + +# Trellis Spec Bootstrap + +Use this skill to create or refresh `.trellis/spec/` guidelines from the real codebase. One capable agent owns the full loop: analyze the repository, choose the spec boundaries, write the docs, and verify the result. The workflow does not depend on a specific host, CLI, or agent brand. + +## Workflow + +1. Confirm Trellis is initialized and inspect the current `.trellis/spec/` tree. +2. Analyze the repository architecture with the best available tools: GitNexus, ABCoder, language tooling, and direct source reads. +3. Decompose the spec work by package and layer only when that reflects the actual codebase. +4. Fill or reshape the spec files with concrete patterns, file paths, examples, and anti-patterns from the project. +5. Verify that the final specs are internally consistent and contain no template placeholders. + +## Reference Routing + +| Need | Read | +|------|------| +| Repository architecture analysis | [references/repository-analysis.md](references/repository-analysis.md) | +| Spec work decomposition and task planning | [references/spec-task-planning.md](references/spec-task-planning.md) | +| Writing high-signal Trellis spec files | [references/spec-writing.md](references/spec-writing.md) | +| GitNexus and ABCoder MCP setup | [references/mcp-setup.md](references/mcp-setup.md) | + +## Operating Rules + +- Treat templates as starting points, not contracts. Delete, rename, split, or add spec files when the repository calls for it. +- Prefer source-backed rules over generic advice. Every important recommendation should point at a real file or repeated local pattern. +- Keep execution single-owner by default. Optional helper agents are an implementation detail, not a requirement or user-visible dependency. +- Do not write platform-specific instructions unless the target project already standardizes on that platform. +- Do not leave placeholder text, empty headings, or copied boilerplate in `.trellis/spec/`. + +## Done Criteria + +- `.trellis/spec/` describes the project as it exists now. +- Each relevant package or layer has practical coding guidance with real examples. +- Non-applicable template sections are removed. +- `index.md` files match the final spec file set. +- Any required setup or analysis assumptions are documented in the relevant spec or task notes. diff --git a/.claude/skills/trellis-spec-bootstrap/references/mcp-setup.md b/.claude/skills/trellis-spec-bootstrap/references/mcp-setup.md new file mode 100644 index 0000000..629fcbd --- /dev/null +++ b/.claude/skills/trellis-spec-bootstrap/references/mcp-setup.md @@ -0,0 +1,90 @@ +# MCP Setup + +GitNexus and ABCoder are recommended when bootstrapping Trellis specs because they expose architecture and AST context to the agent. They are tool choices, not platform requirements. Configure them through whatever MCP mechanism your agent host provides. + +## GitNexus + +GitNexus builds a code knowledge graph from the repository. Use it for module boundaries, execution flows, dependency relationships, blast radius, and graph queries. + +### Install and Index + +```bash +# Run from the repository root. +npx gitnexus analyze + +# Check index status. +npx gitnexus status + +# Re-index after code changes when the analysis is stale. +npx gitnexus analyze +``` + +The index is written to `.gitnexus/`. Keep embeddings only if the project already uses them; otherwise a normal index is enough for spec bootstrapping. + +### MCP Server Command + +Use this server command in the host's MCP configuration: + +```bash +npx -y gitnexus mcp +``` + +### Useful Tools + +| Tool | Purpose | +|------|---------| +| `gitnexus_query` | Find execution flows and functional areas by concept | +| `gitnexus_context` | Inspect callers, callees, references, and process participation for a symbol | +| `gitnexus_impact` | Understand blast radius before changing a symbol | +| `gitnexus_detect_changes` | Check changed symbols and affected flows before finishing | +| `gitnexus_cypher` | Run direct graph queries | +| `gitnexus_list_repos` | List indexed repositories | + +## ABCoder + +ABCoder parses code into UniAST and gives precise package, file, and node-level structure. Use it for signatures, type shapes, implementations, dependencies, and reverse references. + +### Install + +```bash +go install github.com/cloudwego/abcoder@latest +abcoder --help +``` + +### Parse Repositories + +```bash +abcoder parse /absolute/path/to/package \ + --lang typescript \ + --name package-name \ + --output ~/abcoder-asts +``` + +For monorepos, parse each package with a stable `--name` so task notes can reference the same repository names. + +### MCP Server Command + +Use this server command in the host's MCP configuration: + +```bash +abcoder mcp ~/abcoder-asts +``` + +### Useful Tools + +| Tool | Layer | Purpose | +|------|-------|---------| +| `list_repos` | 1 | List parsed repositories | +| `get_repo_structure` | 2 | Inspect packages and files | +| `get_package_structure` | 3 | Inspect nodes within a package | +| `get_file_structure` | 3 | Inspect functions, classes, types, and signatures in a file | +| `get_ast_node` | 4 | Retrieve code, dependencies, references, and implementations | + +## Verification + +After configuration, verify from the agent host that both MCP servers are visible. Then run one simple query against each server before starting the spec writing pass. + +```bash +ls .gitnexus/meta.json +ls ~/abcoder-asts/*.json +``` diff --git a/.claude/skills/trellis-spec-bootstrap/references/repository-analysis.md b/.claude/skills/trellis-spec-bootstrap/references/repository-analysis.md new file mode 100644 index 0000000..1309d29 --- /dev/null +++ b/.claude/skills/trellis-spec-bootstrap/references/repository-analysis.md @@ -0,0 +1,59 @@ +# Repository Analysis + +The goal is to discover the project's real architecture before writing rules. Do not start from generic spec templates and fill blanks. Start from the code, then let the spec structure follow. + +## Analysis Order + +1. Read the existing `.trellis/spec/` tree and note which files are templates, outdated, or already project-specific. +2. Inspect package manifests, build scripts, workspace config, and top-level documentation to identify packages and runtime layers. +3. Use GitNexus for execution flows, module clusters, dependency hubs, and impact-sensitive areas. +4. Use ABCoder or language-native tooling for exact signatures, types, class boundaries, and implementation examples. +5. Read representative source and test files directly before turning any finding into a spec rule. + +## What To Capture + +| Area | Questions | +|------|-----------| +| Package boundaries | What does each package own? What imports cross boundaries? | +| Runtime layers | Which code is CLI, backend, frontend, worker, shared library, test-only, or tooling? | +| Core abstractions | Which types, services, stores, commands, routes, or adapters define the system shape? | +| Data flow | Where does user input enter, how is it validated, and where does state persist? | +| Error handling | How are failures represented, logged, surfaced, and tested? | +| Configuration | Where do defaults, environment config, generated files, and templates live? | +| Tests | Which test styles are trusted examples for new work? | + +## GitNexus Usage + +Start broad, then inspect specific symbols: + +```text +gitnexus_query({query: "CLI command execution flow"}) +gitnexus_query({query: "template generation and migration"}) +gitnexus_context({name: "SymbolName"}) +gitnexus_cypher({query: "MATCH (n)-[r]->(m) RETURN n.name, type(r), m.name LIMIT 30"}) +``` + +Use GitNexus results to find important files and flows. Do not quote graph output as the final authority until you have checked the relevant source files. + +## ABCoder Usage + +Use ABCoder when the spec needs exact code shapes: + +```text +list_repos() +get_repo_structure({repo_name: "package-name"}) +get_file_structure({repo_name: "package-name", file_path: "src/example.ts"}) +get_ast_node({repo_name: "package-name", node_ids: [{mod_path: "...", pkg_path: "...", name: "SymbolName"}]}) +``` + +ABCoder is most valuable for documenting constructor patterns, function signatures, type contracts, and reference chains. + +## Analysis Notes + +Keep short notes while analyzing. The notes should include: + +- Package or layer name. +- Files that define the local pattern. +- Rules the spec should teach. +- Anti-patterns found in old code, comments, tests, or migration paths. +- Spec files that should be created, deleted, renamed, or merged. diff --git a/.claude/skills/trellis-spec-bootstrap/references/spec-task-planning.md b/.claude/skills/trellis-spec-bootstrap/references/spec-task-planning.md new file mode 100644 index 0000000..dca2687 --- /dev/null +++ b/.claude/skills/trellis-spec-bootstrap/references/spec-task-planning.md @@ -0,0 +1,61 @@ +# Spec Task Planning + +Use a single agent as the default execution model. The agent may create Trellis tasks for traceability, but the skill should not require a specific platform, CLI, or parallel worker model. + +## Decomposition + +Create spec work units around real ownership boundaries: + +- One package when a package has its own conventions. +- One layer when the same package has distinct frontend, backend, CLI, worker, or shared-library rules. +- One cross-cutting guide when a pattern spans packages and is not owned by one layer. + +Avoid artificial decomposition. A small library usually needs one focused spec pass, not several tasks. + +## Task Shape + +When a Trellis task is useful, write a concise PRD with these sections: + +```markdown +# Fill <package-or-layer> Trellis Specs + +## Goal +Write project-specific `.trellis/spec/` guidance for <scope>. + +## Scope +- Spec directory: +- Source directories to inspect: +- Tests to inspect: +- Out of scope: + +## Architecture Context +Summarize the concrete findings from repository analysis. + +## Files To Create Or Update +- `.trellis/spec/.../index.md` +- `.trellis/spec/.../<topic>.md` + +## Rules +- Adapt the spec file set to the real codebase. +- Use real source examples with file paths. +- Remove template-only sections that do not apply. +- Do not modify product source code unless the task explicitly asks for it. + +## Acceptance Criteria +- [ ] Specs contain concrete examples and anti-patterns from the repository. +- [ ] No placeholder text remains. +- [ ] Index files match the final spec files. +- [ ] Claims are backed by source files, tests, or project docs. +``` + +## Optional Helper Agents + +If the host supports subagents, helpers can inspect independent packages or run verification. They are optional. The main agent still owns integration and final quality. + +Helper tasks must have clear ownership: + +- Read-only research tasks may inspect any source needed for the assigned scope. +- Write tasks should own disjoint spec directories. +- Verification tasks should check placeholder removal, broken links, and consistency. + +Do not encode helper-agent names, vendor-specific commands, or platform-specific routing in the skill. Put only the required work and acceptance criteria in the task. diff --git a/.claude/skills/trellis-spec-bootstrap/references/spec-writing.md b/.claude/skills/trellis-spec-bootstrap/references/spec-writing.md new file mode 100644 index 0000000..6bc7dec --- /dev/null +++ b/.claude/skills/trellis-spec-bootstrap/references/spec-writing.md @@ -0,0 +1,70 @@ +# Spec Writing + +Trellis specs are coding guidance for future agents. They should explain how to work in this repository, not how a generic project might be organized. + +## Write From Evidence + +Each important rule should be backed by one of these: + +- A source file that demonstrates the preferred pattern. +- A test file that shows expected behavior. +- A project document that defines the convention. +- A repeated pattern across multiple files. + +Use short snippets only when they make the rule clearer. Prefer linking to the file path and naming the symbol or behavior. + +## File Structure + +Keep the spec tree aligned with the project: + +- Keep `index.md` as the navigation file for the spec directory. +- Split topics when developers would look for them independently. +- Merge topics when separate files would repeat the same rule. +- Delete template files that do not apply. +- Add new files for important local patterns the template missed. + +## Content Standards + +Good spec sections include: + +- When the rule applies. +- The local pattern to follow. +- The source or test files that prove the pattern. +- Common mistakes or anti-patterns. +- Verification commands or checks when they are specific and reliable. + +Avoid: + +- Placeholder prose. +- Generic framework advice. +- Tool instructions that only work in one agent host. +- Long copied code blocks. +- Rules based on a single accidental implementation detail. + +## Example Shape + +```markdown +## Command Handlers + +Command handlers should keep argument parsing, validation, and side effects separate. The local pattern is: + +- Parse CLI flags at the command boundary. +- Convert raw inputs into typed task options before invoking core logic. +- Keep filesystem writes in the command or service layer, not in template helpers. + +Reference files: +- `packages/cli/src/commands/example.ts` +- `packages/cli/test/commands/example.test.ts` + +Avoid passing raw `process.argv` or unvalidated config objects into shared helpers. +``` + +## Final Pass + +Before finishing: + +```bash +grep -R "To be filled\\|TODO: fill\\|placeholder" .trellis/spec +``` + +Also check links, index files, and whether any spec still describes a template rather than this repository. diff --git a/.claude/skills/trellis-update-spec/SKILL.md b/.claude/skills/trellis-update-spec/SKILL.md new file mode 100644 index 0000000..557bc4e --- /dev/null +++ b/.claude/skills/trellis-update-spec/SKILL.md @@ -0,0 +1,356 @@ +--- +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 to update the relevant code-spec documents. + +**Timing**: After completing a task, fixing a bug, or discovering a new pattern + +--- + +## Code-Spec First Rule (CRITICAL) + +In this project, "spec" for implementation work means **code-spec**: +- Executable contracts (not principle-only text) +- Concrete signatures, payload fields, env keys, and boundary behavior +- Testable validation/error behavior + +If the change touches infra or cross-layer contracts, code-spec depth is mandatory. + +### Mandatory Triggers + +Apply code-spec depth when the change includes any of: +- New/changed command or API signature +- Cross-layer request/response contract change +- Database schema/migration change +- Infra integration (storage, queue, cache, secrets, env wiring) + +### Mandatory Output (7 Sections) + +For triggered tasks, include all sections below: +1. Scope / Trigger +2. Signatures (command/API/DB) +3. Contracts (request/response/env) +4. Validation & Error Matrix +5. Good/Base/Bad Cases +6. Tests Required (with assertion points) +7. Wrong vs Correct (at least one pair) + +--- + +## When to Update Code-Specs + +| Trigger | Example | Target Spec | +|---------|---------|-------------| +| **Implemented a feature** | Added 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. + +--- + +## Spec Structure Overview + +``` +.trellis/spec/ +├── <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!) + ├── index.md # Guide index + └── *.md # Topic-specific guides +``` + +### CRITICAL: Code-Spec vs Guide - Know the Difference + +| Type | Location | Purpose | Content Style | +|------|----------|---------|---------------| +| **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 a spec layer directory +- "This is **what to consider** before writing" → Put in `guides/` + +**Example**: + +| Learning | Wrong Location | Correct Location | +|----------|----------------|------------------| +| "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. + +--- + +## Update Process + +### Step 1: Identify What You Learned + +Answer these questions: + +1. **What did you learn?** (Be specific) +2. **Why is it important?** (What problem does it prevent?) +3. **Where does it belong?** (Which spec file?) + +### Step 2: Classify the Update Type + +| Type | Description | Action | +|------|-------------|--------| +| **Design Decision** | Why we chose approach X over Y | Add to "Design Decisions" section | +| **Project Convention** | How we do X in this project | Add to relevant section with examples | +| **New Pattern** | A reusable approach discovered | Add to "Patterns" section | +| **Forbidden Pattern** | Something that causes problems | Add to "Anti-patterns" or "Don't" section | +| **Common Mistake** | Easy-to-make error | Add to "Common Mistakes" section | +| **Convention** | Agreed-upon standard | Add to relevant section | +| **Gotcha** | Non-obvious behavior | Add warning callout | + +### Step 3: Read the Target Code-Spec + +Before editing, read the current code-spec to: +- Understand existing structure +- Avoid duplicating content +- Find the right section for your update + +```bash +cat .trellis/spec/<category>/<file>.md +``` + +### Step 4: Make the Update + +Follow these principles: + +1. **Be Specific**: Include concrete examples, not just abstract rules +2. **Explain Why**: State the problem this prevents +3. **Show Contracts**: Add signatures, payload fields, and error behavior +4. **Show Code**: Add code snippets for key patterns +5. **Keep it Short**: One concept per section + +### Step 5: Update the Index (if needed) + +If you added a new section or the code-spec status changed, update the category's `index.md`. + +--- + +## Update Templates + +### Mandatory Template for Infra/Cross-Layer Work + +```markdown +## Scenario: <name> + +### 1. Scope / Trigger +- Trigger: <why this requires code-spec depth> + +### 2. Signatures +- Backend command/API/DB signature(s) + +### 3. Contracts +- Request fields (name, type, constraints) +- Response fields (name, type, constraints) +- Environment keys (required/optional) + +### 4. Validation & Error Matrix +- <condition> -> <error> + +### 5. Good/Base/Bad Cases +- Good: ... +- Base: ... +- Bad: ... + +### 6. Tests Required +- Unit/Integration/E2E with assertion points + +### 7. Wrong vs Correct +#### Wrong +... +#### Correct +... +``` + +### Adding a Design Decision + +```markdown +### Design Decision: [Decision Name] + +**Context**: What problem were we solving? + +**Options Considered**: +1. Option A - brief description +2. Option B - brief description + +**Decision**: We chose Option X because... + +**Example**: +\`\`\`typescript +// How it's implemented +code example +\`\`\` + +**Extensibility**: How to extend this in the future... +``` + +### Adding a Project Convention + +```markdown +### Convention: [Convention Name] + +**What**: Brief description of the convention. + +**Why**: Why we do it this way in this project. + +**Example**: +\`\`\`typescript +// How to follow this convention +code example +\`\`\` + +**Related**: Links to related conventions or specs. +``` + +### Adding a New Pattern + +```markdown +### Pattern Name + +**Problem**: What problem does this solve? + +**Solution**: Brief description of the approach. + +**Example**: +\`\`\` +// Good +code example + +// Bad +code example +\`\`\` + +**Why**: Explanation of why this works better. +``` + +### Adding a Forbidden Pattern + +```markdown +### Don't: Pattern Name + +**Problem**: +\`\`\` +// Don't do this +bad code example +\`\`\` + +**Why it's bad**: Explanation of the issue. + +**Instead**: +\`\`\` +// Do this instead +good code example +\`\`\` +``` + +### Adding a Common Mistake + +```markdown +### Common Mistake: Description + +**Symptom**: What goes wrong + +**Cause**: Why this happens + +**Fix**: How to correct it + +**Prevention**: How to avoid it in the future +``` + +### Adding a Gotcha + +```markdown +> **Warning**: Brief description of the non-obvious behavior. +> +> Details about when this happens and how to handle it. +``` + +--- + +## Interactive Mode + +If you're unsure what to update, answer these prompts: + +1. **What did you just finish?** + - [ ] Fixed a bug + - [ ] Implemented a feature + - [ ] Refactored code + - [ ] Had a discussion about approach + +2. **What did you learn or decide?** + - Design decision (why X over Y) + - Project convention (how we do X) + - Non-obvious behavior (gotcha) + - Better approach (pattern) + +3. **Would future AI/developers need to know this?** + - To understand how the code works → Yes, update spec + - To maintain or extend the feature → Yes, update spec + - To avoid repeating mistakes → Yes, update spec + - Purely one-off implementation detail → Maybe skip + +4. **Which area does it relate to?** + - [ ] Backend code + - [ ] Frontend code + - [ ] Cross-layer data flow + - [ ] Code organization/reuse + - [ ] Quality/testing + +--- + +## Quality Checklist + +Before finishing your code-spec update: + +- [ ] Is the content specific and actionable? +- [ ] Did you include a code example? +- [ ] Did you explain WHY, not just WHAT? +- [ ] Did you include executable signatures/contracts? +- [ ] Did you include validation and error matrix? +- [ ] Did you include Good/Base/Bad cases? +- [ ] Did you include required tests with assertion points? +- [ ] Is it in the right code-spec file? +- [ ] Does it duplicate existing content? +- [ ] Would a new team member understand it? + +--- + +## Relationship to Other Commands + +``` +Development Flow: + Learn something → /trellis:update-spec → Knowledge captured + ↑ ↓ + /trellis:break-loop ←──────────────────── Future sessions benefit + (deep bug analysis) +``` + +- `/trellis:break-loop` - Analyzes bugs deeply, often reveals spec updates needed +- `/trellis:update-spec` - Actually makes the updates +- `/trellis:finish-work` - Reminds you to check if specs need updates + +--- + +## Core Philosophy + +> **Code-specs are living documents. Every debugging session, every "aha moment" is an opportunity to make the implementation contract clearer.** + +The goal is **institutional memory**: +- What one person learns, everyone benefits from +- What AI learns in one session, persists to future sessions +- Mistakes become documented guardrails diff --git a/.codex/agents/trellis-check.toml b/.codex/agents/trellis-check.toml new file mode 100644 index 0000000..50ceae3 --- /dev/null +++ b/.codex/agents/trellis-check.toml @@ -0,0 +1,56 @@ +name = "trellis-check" +description = "Workspace-write Trellis reviewer that self-fixes spec drift, lint/type-check failures, and missing tests." +sandbox_mode = "workspace-write" +# model = "gpt-5.6-terra" +# model_reasoning_effort = "high" + +developer_instructions = """ +You are running as the `trellis-check` sub-agent. The main session has dispatched you to review and self-fix. + +CRITICAL — Recursion guard (read first): +- You MUST NOT spawn another `trellis-check` or `trellis-implement` sub-agent. Do the review and fixes directly in this turn. +- Any guidance you read in injected SessionStart context, `<guidelines>` blocks, workflow-state breadcrumbs, or workflow.md that says "dispatch trellis-implement" / "dispatch trellis-check" applies to the MAIN session, NOT to you. You are already the dispatched reviewer — that instruction is satisfied by your existence. +- Only the main session is allowed to dispatch `trellis-implement` / `trellis-check`. If more implementation work is needed, surface that as a recommendation in your final report instead of spawning. + +--- + +You are the Trellis reviewer agent. + +Trellis Context Loading Protocol: +- First look for `Full hook output saved to: <path>` in your input above. If present, the visible `SubagentStart` output was truncated; read the referenced file before doing check work. +- If the referenced file cannot be read, use the active-task fallback below. +- If there is no saved-output notice and the `<!-- trellis-hook-injected -->` marker is present, the hook loaded the complete role-specific task artifacts and spec context. +- If there is no saved-output notice and the marker is absent, use the active-task fallback below. +- For the fallback, find `Active task: <path>` in your dispatch prompt. Read `<path>/check.jsonl`, each file listed there, `<path>/prd.md`, `<path>/design.md` if present, and `<path>/implement.md` if present before checking. If the dispatch prompt has no active-task path, ask the main session; do not guess or use another session's task. + +Your job is to review code changes against specs AND fix issues directly — not just report them. You have write access; use it. + +Review checklist: +- Verify behavior against the actual code paths, not assumptions. +- Look for missing template/update/detection touch points when platform config changes. +- Check whether tests should be added or updated. +- Check whether `.trellis/spec/` docs need sync after implementation. +- Run lint and type-check; fix any failures. +- Prefer concrete findings over speculative warnings. + +When you find an issue: +1. Fix it directly using edit/write tools. +2. Re-run lint and type-check until green. +3. Record what you changed and why. + +Output format: +## Findings (fixed) +- File: <path> +- Issue: <what was wrong> +- Fix: <what you changed> + +## Findings (not fixed) +Only list issues you could not self-fix (e.g. missing product decision, out-of-scope). Explain why. + +## Verification +- Lint: pass/fail +- TypeCheck: pass/fail +- Tests: pass/fail (if applicable) + +If no issues are found, say so explicitly after verifying lint/type-check pass. +""" diff --git a/.codex/agents/trellis-implement.toml b/.codex/agents/trellis-implement.toml new file mode 100644 index 0000000..d0cc347 --- /dev/null +++ b/.codex/agents/trellis-implement.toml @@ -0,0 +1,37 @@ +name = "trellis-implement" +description = "Workspace-write Trellis implementer that follows specs and keeps generated templates in sync." +sandbox_mode = "workspace-write" +# model = "gpt-5.6-terra" +# model_reasoning_effort = "high" + +developer_instructions = """ +You are running as the `trellis-implement` sub-agent. The main session has dispatched you to do the work. + +CRITICAL — Recursion guard (read first): +- You MUST NOT spawn another `trellis-implement` or `trellis-check` sub-agent. Do the implementation work directly in this turn. +- Any guidance you read in injected SessionStart context, `<guidelines>` blocks, workflow-state breadcrumbs, or workflow.md that says "dispatch trellis-implement" / "dispatch trellis-check" applies to the MAIN session, NOT to you. You are already the dispatched implementer — that instruction is satisfied by your existence. +- Only the main session is allowed to dispatch `trellis-implement` / `trellis-check`. If more parallel work is needed, surface that as a recommendation in your final report instead of spawning. + +--- + +You are the Trellis implementer agent. + +Trellis Context Loading Protocol: +- First look for `Full hook output saved to: <path>` in your input above. If present, the visible `SubagentStart` output was truncated; read the referenced file before doing implementation work. +- If the referenced file cannot be read, use the active-task fallback below. +- If there is no saved-output notice and the `<!-- trellis-hook-injected -->` marker is present, the hook loaded the complete role-specific task artifacts and spec context. +- If there is no saved-output notice and the marker is absent, use the active-task fallback below. +- For the fallback, find `Active task: <path>` in your dispatch prompt. Read `<path>/implement.jsonl`, each file listed there, `<path>/prd.md`, `<path>/design.md` if present, and `<path>/implement.md` if present before doing the work. If the dispatch prompt has no active-task path, ask the main session; do not guess or use another session's task. + +Rules: +- Read before write. Follow `.trellis/spec/` guidance relevant to the task. +- Keep changes focused on the requested scope. +- When touching platform registries or template lists, search first so you do not miss mirrored update paths. +- If you modify `.trellis/scripts/`, keep `packages/cli/src/templates/trellis/scripts/` in sync. +- Do not make destructive git changes unless explicitly asked. + +Before finishing, summarize: +- Files changed +- Tests/checks run +- Remaining risks or follow-ups +""" diff --git a/.codex/agents/trellis-research.toml b/.codex/agents/trellis-research.toml new file mode 100644 index 0000000..b0b2fef --- /dev/null +++ b/.codex/agents/trellis-research.toml @@ -0,0 +1,78 @@ +name = "trellis-research" +description = "Trellis researcher for specs, code patterns, and affected files. Writes findings into {TASK_DIR}/research/ — read-only elsewhere." +sandbox_mode = "workspace-write" +# model = "gpt-5.6-terra" +# model_reasoning_effort = "high" + +developer_instructions = """ +You are the Trellis researcher agent. + +## Core principle + +Conversations get compacted; files don't. Every research topic MUST be +persisted to `{TASK_DIR}/research/<topic>.md`. Returning findings only +through the chat reply is a failure. + +## Trellis Context Loading Protocol + +- First look for `Full hook output saved to: <path>` in your input above. If + present, the visible `SubagentStart` output was truncated; read the referenced + file before doing research work. +- If the referenced file cannot be read, use the active-task fallback below. +- If there is no saved-output notice and the + `<!-- trellis-hook-injected -->` marker is present, the hook supplied the + complete `Active task: <path>` header and research-only context. +- If there is no saved-output notice and the marker is absent, use the + active-task fallback below. +- For the fallback, find `Active task: <path>` in your dispatch prompt. If it + is absent too, ask the main session for the path; do not run + `task.py current`, guess, or use another session's task. +- Do not load `implement.jsonl` or `check.jsonl`; research is role-isolated. + +## Workflow + +1. Use the active task path supplied by the context-loading protocol. If no + path is supplied, ask the user where to write output; do not guess. +2. Run `mkdir -p <TASK_DIR>/research` to ensure the directory exists. +3. Read `.trellis/workflow.md`, relevant `.trellis/spec/` files, and + target code before forming an opinion. +4. For each research topic, write `<TASK_DIR>/research/<slug>.md` with: + - Query, scope, date + - Files found (path + one-line description) + - Code patterns (cite file:line) + - External references (docs, versions) + - Related specs + - Caveats / not-found notes +5. Reply with only: list of files written, one-line summary per file, + any critical caveats. Do not paste full research into the reply. + +## Scope limits + +Write allowed ONLY in `{TASK_DIR}/research/`. + +Write forbidden everywhere else: +- Code files (`src/`, `lib/`, …) +- Spec files (`.trellis/spec/`) — use `update-spec` skill instead +- `.trellis/scripts/`, `.trellis/workflow.md`, platform config +- Other task directories +- Any git operation + +If the user asks you to edit code, decline and tell them to spawn the +`implement` agent. + +## Output format for each research file + +``` +# Research: <topic> + +- Query: ... +- Scope: internal / external / mixed +- Date: YYYY-MM-DD + +## Findings +... + +## Caveats / Not Found +... +``` +""" diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..e1feee3 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,39 @@ +# Project-scoped Codex defaults for Trellis workflows. +# Codex merges this layer after the user-level config when the project +# is marked as a trusted project. To trust this project, add it under +# `[projects]` in ~/.codex/config.toml, e.g.: +# +# [projects."/abs/path/to/this/repo"] +# trust_level = "trusted" + +# Keep AGENTS.md as the primary project instruction file. +project_doc_fallback_filenames = ["AGENTS.md"] + +# Codex hooks (`hooks.json` in this directory) only fire when the user +# has enabled them in their USER-level config: `[features].hooks = true` +# in ~/.codex/config.toml (Codex 0.129+; legacy name: `codex_hooks = true`, +# still works but emits a deprecation warning on 0.129+). Project-level +# config.toml cannot set feature flags; they must be user-level. +# Codex 0.129+ additionally gates each installed hook behind a one-time +# `/hooks` TUI review; until the user approves it, the hook stays inactive. + +# NOTE: Trellis intentionally does NOT write a [features.multi_agent_v2] +# block here. Codex CLI changed `features` deserialization between 0.130 +# and 0.131: the structured table form (with max_concurrent_threads_per_session +# / *_wait_timeout_ms) is only accepted by 0.131+. On 0.130 and earlier — +# including the codex CLI bundled inside the Codex desktop app — it fails +# with `data did not match any variant of untagged enum FeatureToml`, which +# aborts the entire config load and blocks Codex from starting. Codex's own +# default for multi_agent_v2 is used instead; tune it in your user-level +# config if needed. + +# Pin the subagent recursion depth explicitly instead of relying on Codex's +# default. #445 removed the per-agent `[features] multi_agent = false` guard +# (the #240/#241 wait_agent-deadlock structural fix) because native subagent +# dispatch already caps recursion via `agents.max_depth` — but that key is +# global/user-level, not settable inside an individual agent's .toml. Pinning +# it here means an upstream default change, or a user's own global override, +# can't silently reopen the recursion the #240/#241 fix closed. Project config +# (this file) takes precedence over user-level `~/.codex/config.toml`. +[agents] +max_depth = 1 diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..dc04480 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,27 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 -X utf8 .codex/hooks/inject-workflow-state.py", + "timeout": 15 + } + ] + } + ], + "SubagentStart": [ + { + "matcher": "^(?:trellis-implement|trellis-check|trellis-research)$", + "hooks": [ + { + "type": "command", + "command": "python3 -X utf8 .codex/hooks/inject-subagent-context.py", + "timeout": 15 + } + ] + } + ] + } +} diff --git a/.codex/hooks/inject-subagent-context.py b/.codex/hooks/inject-subagent-context.py new file mode 100644 index 0000000..cfe7b15 --- /dev/null +++ b/.codex/hooks/inject-subagent-context.py @@ -0,0 +1,1141 @@ +#!/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 +- design.md - Technical design for complex tasks +- implement.md - Execution plan for complex tasks +- 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 + +# Hook hosts send UTF-8 JSON regardless of the process locale. +_stdin_reconfigure = getattr(sys.stdin, "reconfigure", None) +if callable(_stdin_reconfigure): + try: + _stdin_reconfigure(encoding="utf-8", errors="replace") + except (OSError, ValueError): + pass + +# 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 _hook_event_name(input_data) == "SubagentStart": + return "codex" + if isinstance(input_data.get("cursor_version"), str): + return "cursor" + env_map = { + "ZCODE_PROJECT_DIR": "zcode", + "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" + if ".zcode" in script_parts: + return "zcode" + return None + + +def get_current_task( + repo_root: str, + input_data: dict, + *, + platform: str | None = None, + allow_single_session_fallback: bool = True, + allow_environment_context: bool = True, + require_existing: bool = False, +) -> 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=platform or _detect_platform(input_data), + allow_single_session_fallback=allow_single_session_fallback, + allow_environment_context=allow_environment_context, + ) + if require_existing and active.stale: + return None + return active.task_path + + +# ============================================================================= +# Context Injection Limits (issue #441) +# +# Notice text and behavior mirrored byte-for-byte in the Pi TS extension +# (templates/pi/extensions/trellis/index.ts.txt). Changing wording here +# requires changing it there too. +# ============================================================================= + +DEFAULT_MAX_FILE_BYTES = 32768 +DEFAULT_MAX_ARTIFACT_BYTES = 65536 +DEFAULT_MAX_TOTAL_BYTES = 131072 + +DEFAULT_LIMITS: dict[str, int] = { + "max_file_bytes": DEFAULT_MAX_FILE_BYTES, + "max_artifact_bytes": DEFAULT_MAX_ARTIFACT_BYTES, + "max_total_bytes": DEFAULT_MAX_TOTAL_BYTES, +} + + +def _get_limits(repo_root: str) -> dict[str, int]: + """Load context-injection byte limits from config.yaml, with safe fallback.""" + 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.config import get_context_injection_limits # type: ignore[import-not-found] + + return get_context_injection_limits(Path(repo_root)) + except Exception: + return dict(DEFAULT_LIMITS) + + +def truncate_utf8(data: bytes, cap: int) -> bytes: + """Truncate ``data`` to at most ``cap`` bytes without splitting a UTF-8 + multi-byte sequence. + + ``cap <= 0`` means "no limit" — returns ``data`` unchanged. + """ + if cap <= 0 or len(data) <= cap: + return data + + truncated = data[:cap] + i = len(truncated) + # Back off over continuation bytes (10xxxxxx) to find the lead byte. + while i > 0 and (truncated[i - 1] & 0xC0) == 0x80: + i -= 1 + if i == 0: + return b"" + + lead = truncated[i - 1] + if lead & 0x80: + if (lead & 0xE0) == 0xC0: + seq_len = 2 + elif (lead & 0xF0) == 0xE0: + seq_len = 3 + elif (lead & 0xF8) == 0xF0: + seq_len = 4 + else: + seq_len = 1 + # Drop the lead byte too if its full sequence didn't fit. + if (i - 1) + seq_len > len(truncated): + i -= 1 + + return truncated[:i] + + +class _Budget: + """Tracks the running total of bytes emitted into the sub-agent context.""" + + def __init__(self, max_total_bytes: int) -> None: + self.max_total_bytes = max_total_bytes + self.used = 0 + + def has_room(self, size: int) -> bool: + if self.max_total_bytes <= 0: + return True + return self.used + size <= self.max_total_bytes + + def add(self, size: int) -> None: + self.used += size + + +def _read_file_bytes(base_path: str, file_path: str) -> bytes | None: + """Read raw file bytes, 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, "rb") as f: + return f.read() + except Exception: + return None + return None + + +def _truncate_notice(path: str, cap: int) -> str: + return f"\n[Trellis: truncated at {cap} bytes — read {path} for the full content]" + + +def _is_binary_content(data: bytes) -> bool: + """Return True when raw bytes should not be decoded into model context.""" + if b"\x00" in data: + return True + try: + data.decode("utf-8", errors="strict") + except UnicodeDecodeError: + return True + return False + + +def _binary_notice(path: str, size: int, reason: str) -> str: + return ( + f"[Trellis: not inlined (binary file) — " + f"{path} ({size} bytes): {reason}]" + ) + + +def _index_notice(path: str, size: int, reason: str) -> str: + return ( + f"[Trellis: not inlined (total context limit reached) — " + f"{path} ({size} bytes): {reason}]" + ) + + +def _budgeted_block( + budget: _Budget, + header: str, + plain_path: str, + content: str, + reason: str, + size_for_index: int, +) -> str: + """Return an inlined ``=== header ===`` block, or degrade to an index + notice once the total context budget is exhausted.""" + block = f"=== {header} ===\n{content}" + block_bytes = len(block.encode("utf-8")) + if not budget.has_room(block_bytes): + notice = _index_notice(plain_path, size_for_index, reason) + budget.add(len(notice.encode("utf-8"))) + return notice + budget.add(block_bytes) + return block + + +def _materialize_file( + base_path: str, + file_path: str, + reason: str, + limits: dict[str, int], + budget: _Budget, +) -> str | None: + """Read a JSONL-referenced file, apply the per-file cap, then budget it.""" + data = _read_file_bytes(base_path, file_path) + if data is None: + return None + + size = len(data) + if _is_binary_content(data): + notice = _binary_notice(file_path, size, reason) + budget.add(len(notice.encode("utf-8"))) + return notice + + cap = limits["max_file_bytes"] + truncated_bytes = truncate_utf8(data, cap) + content = truncated_bytes.decode("utf-8", errors="replace") + if len(truncated_bytes) < size: + content += _truncate_notice(file_path, cap) + + return _budgeted_block(budget, file_path, file_path, content, reason, size) + + +def _materialize_directory( + base_path: str, + dir_path: str, + reason: str, + limits: dict[str, int], + budget: _Budget, + max_files: int = 20, +) -> list[str]: + """Read all .md files in a directory, applying the same per-file and + total caps as a single-file JSONL entry.""" + full_path = os.path.join(base_path, dir_path) + if not os.path.exists(full_path) or not os.path.isdir(full_path): + return [] + + blocks: list[str] = [] + try: + 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]: + relative_path = os.path.join(dir_path, filename) + block = _materialize_file(base_path, relative_path, reason, limits, budget) + if block: + blocks.append(block) + except Exception: + pass + + return blocks + + +def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[dict]: + """ + Parse all file/directory entries referenced in a jsonl context 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: + [{"file": path, "type": "file" | "directory", "reason": reason}, ...] + """ + 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 task artifacts", + file=sys.stderr, + ) + return [] + + entries: list[dict] = [] + 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") + + if not file_path: + # Seed / comment row — skip silently + continue + + saw_real_entry = True + entries.append( + { + "file": file_path, + "type": item.get("type", "file"), + "reason": item.get("reason") or "-", + } + ) + 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"task artifacts. See workflow.md planning artifact guidance.", + file=sys.stderr, + ) + + return entries + + +def _materialize_jsonl_entries( + base_path: str, jsonl_path: str, limits: dict[str, int], budget: _Budget +) -> list[str]: + """Materialize every entry in a jsonl context file into context blocks, + applying per-file and total budget caps.""" + blocks: list[str] = [] + for entry in read_jsonl_entries(base_path, jsonl_path): + if entry["type"] == "directory": + blocks.extend( + _materialize_directory( + base_path, entry["file"], entry["reason"], limits, budget + ) + ) + else: + block = _materialize_file( + base_path, entry["file"], entry["reason"], limits, budget + ) + if block: + blocks.append(block) + return blocks + + +def get_agent_context( + repo_root: str, + task_dir: str, + agent_type: str, + limits: dict[str, int], + budget: _Budget, +) -> 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). + """ + agent_jsonl = f"{task_dir}/{agent_type}.jsonl" + blocks = _materialize_jsonl_entries(repo_root, agent_jsonl, limits, budget) + return "\n\n".join(blocks) + + +def _materialize_artifact( + base_path: str, + file_path: str, + header_label: str, + reason: str, + limits: dict[str, int], + budget: _Budget, +) -> str | None: + """Read a task artifact (prd/design/implement.md), apply the per-artifact + cap, then budget it.""" + data = _read_file_bytes(base_path, file_path) + if data is None: + return None + + size = len(data) + cap = limits["max_artifact_bytes"] + truncated_bytes = truncate_utf8(data, cap) + content = truncated_bytes.decode("utf-8", errors="replace") + if len(truncated_bytes) < size: + content += _truncate_notice(file_path, cap) + + return _budgeted_block(budget, header_label, file_path, content, reason, size) + + +def get_implement_context(repo_root: str, task_dir: str) -> str: + """ + Complete context for Implement Agent + + Read order: + 1. All files in implement.jsonl (spec/research manifests) + 2. prd.md (requirements) + 3. design.md if present (technical design) + 4. implement.md if present (execution plan) + """ + limits = _get_limits(repo_root) + budget = _Budget(limits["max_total_bytes"]) + context_parts = [] + + # 1. Read implement.jsonl + base_context = get_agent_context(repo_root, task_dir, "implement", limits, budget) + if base_context: + context_parts.append(base_context) + + # 2. Requirements document + prd_block = _materialize_artifact( + repo_root, + f"{task_dir}/prd.md", + f"{task_dir}/prd.md (Requirements)", + "Requirements document", + limits, + budget, + ) + if prd_block: + context_parts.append(prd_block) + + # 3. Technical design for complex tasks + design_block = _materialize_artifact( + repo_root, + f"{task_dir}/design.md", + f"{task_dir}/design.md (Technical Design)", + "Technical design document", + limits, + budget, + ) + if design_block: + context_parts.append(design_block) + + # 4. Execution plan for complex tasks + implement_plan_block = _materialize_artifact( + repo_root, + f"{task_dir}/implement.md", + f"{task_dir}/implement.md (Execution Plan)", + "Execution plan document", + limits, + budget, + ) + if implement_plan_block: + context_parts.append(implement_plan_block) + + return "\n\n".join(context_parts) + + +def get_check_context(repo_root: str, task_dir: str) -> str: + """ + Context for Check Agent: check.jsonl + task artifacts. + """ + limits = _get_limits(repo_root) + budget = _Budget(limits["max_total_bytes"]) + context_parts = [] + + base_context = get_agent_context(repo_root, task_dir, "check", limits, budget) + if base_context: + context_parts.append(base_context) + + prd_block = _materialize_artifact( + repo_root, + f"{task_dir}/prd.md", + f"{task_dir}/prd.md (Requirements)", + "Requirements document", + limits, + budget, + ) + if prd_block: + context_parts.append(prd_block) + + design_block = _materialize_artifact( + repo_root, + f"{task_dir}/design.md", + f"{task_dir}/design.md (Technical Design)", + "Technical design document", + limits, + budget, + ) + if design_block: + context_parts.append(design_block) + + implement_plan_block = _materialize_artifact( + repo_root, + f"{task_dir}/implement.md", + f"{task_dir}/implement.md (Execution Plan)", + "Execution plan document", + limits, + budget, + ) + if implement_plan_block: + context_parts.append(implement_plan_block) + + 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"""<!-- trellis-hook-injected --> +# 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 task artifacts** - Read requirements, technical design if present, and execution plan if present + 3. **Implement feature** - Implement following specs and task artifacts +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"""<!-- trellis-hook-injected --> +# 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"""<!-- trellis-hook-injected --> +# 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 task artifacts** - Check requirements in prd.md and, when present, design.md / implement.md +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 +- Verify design.md and implement.md constraints when those files are present""" + + + +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 _hook_event_name(input_data: dict) -> str: + """Return a hook event name from the documented snake/camel-case fields.""" + return _string_value( + input_data.get("hook_event_name") or input_data.get("hookEventName") + ) + + +def _codex_subagent_type(input_data: dict) -> str: + """Return a Trellis Codex agent type only for a native start event.""" + if _hook_event_name(input_data) != "SubagentStart": + return "" + agent_type = _string_value( + input_data.get("agent_type") or input_data.get("agentType") + ) + return agent_type if agent_type in AGENTS_ALL else "" + + +def build_codex_subagent_context( + subagent_type: str, + task_dir: str, + context: str, +) -> str: + """Build developer context for a native, already-dispatched Codex role.""" + role = subagent_type.removeprefix("trellis-") + return f"""<!-- trellis-hook-injected --> +# Trellis Native {role.title()} Subagent + +You are the dispatched `{subagent_type}` role for this task. Perform that role +directly; do not follow main-session dispatch or wait instructions, and do not +spawn another Trellis subagent. + +Active task: {task_dir} + +## Curated Context + +{context}""" + + +def _handle_codex_subagent_start(input_data: dict) -> None: + """Emit Codex developer context for a recognised native Trellis subagent. + + The event supplies the parent session id. Disabling the generic + single-session fallback is essential here: native starts must never borrow + a task from another Codex window when that parent id is absent or stale. + """ + subagent_type = _codex_subagent_type(input_data) + parent_session_id = _string_value(input_data.get("session_id")) + if not subagent_type or not parent_session_id: + return + + cwd = _string_value(input_data.get("cwd")) or os.getcwd() + repo_root = find_repo_root(cwd) + if not repo_root: + return + + task_dir = get_current_task( + repo_root, + {"session_id": parent_session_id}, + platform="codex", + allow_single_session_fallback=False, + allow_environment_context=False, + require_existing=True, + ) + if not task_dir: + return + + if subagent_type in AGENTS_REQUIRE_TASK: + task_dir_full = Path(repo_root) / task_dir + if not task_dir_full.is_dir(): + return + + if subagent_type == AGENT_IMPLEMENT: + context = get_implement_context(repo_root, task_dir) + elif subagent_type == AGENT_CHECK: + context = get_check_context(repo_root, task_dir) + else: + context = get_research_context(repo_root, task_dir) + + if not context: + return + + output = { + "hookSpecificOutput": { + "hookEventName": "SubagentStart", + "additionalContext": build_codex_subagent_context( + subagent_type, task_dir, context + ), + } + } + print(json.dumps(output, ensure_ascii=False)) + + +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) + - ZCode: toolName=Agent, toolInput/tool_input.subagent_type + - 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", {}) + if not isinstance(tool_input, dict): + tool_input = input_data.get("toolInput", {}) + if not isinstance(tool_input, dict): + 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) + if not isinstance(input_data, dict): + sys.exit(0) + + if _hook_event_name(input_data) == "SubagentStart": + try: + _handle_codex_subagent_start(input_data) + except Exception: + # A native context hook must never prevent Codex from spawning the + # requested child when its runtime state is unavailable or stale. + pass + 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. Most platforms ignore unrecognized fields, so we + # include multiple formats. ZCode is stricter; live probing confirmed the + # nested Claude-compatible shape below reaches the sub-agent prompt. + updated = {**tool_input, "prompt": new_prompt} + if _detect_platform(input_data) == "zcode": + output = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "updatedInput": updated, + } + } + else: + 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/.codex/hooks/inject-workflow-state.py b/.codex/hooks/inject-workflow-state.py new file mode 100644 index 0000000..ab8e276 --- /dev/null +++ b/.codex/hooks/inject-workflow-state.py @@ -0,0 +1,465 @@ +#!/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 <workflow-state> +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). Kiro wires this via the CLI +custom agent's ``hooks.userPromptSubmit`` and the IDE ``.kiro.hook`` +``promptSubmit`` event; its output branch emits a plain-text breadcrumb +(Kiro adds hook stdout directly to the conversation context). 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 +import queue +import threading +from pathlib import Path + +# Force UTF-8 on stdin/stdout/stderr on Windows. Default codepage there is +# cp936 / cp1252 / etc. — non-ASCII content (Chinese task names, prd snippets) +# both in stdin (hook payload from host CLI) and stdout (our emitted blocks) +# raises UnicodeDecodeError / UnicodeEncodeError. Equivalent to `python -X utf8` +# but applied per-stream so we don't depend on host CLI's command wiring. +if sys.platform.startswith("win"): + import io as _io + for _stream_name in ("stdin", "stdout", "stderr"): + _stream = getattr(sys, _stream_name, None) + if _stream is None: + continue + if hasattr(_stream, "reconfigure"): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + except Exception: + pass # Optional Windows stream setup; keep hook startup non-fatal. + elif hasattr(_stream, "detach"): + try: + setattr(sys, _stream_name, _io.TextIOWrapper(_stream.detach(), encoding="utf-8", errors="replace")) + except Exception: + pass # Optional Windows stream setup; keep hook startup non-fatal. +from typing import Optional + + +# Bootstrap notice for Codex while the session has no active task. Codex does not +# get the full SessionStart overview; this short reminder points the main session +# at the start skill once and leaves the per-turn state block compact. +CODEX_NO_TASK_BOOTSTRAP_NOTICE = """<trellis-bootstrap> +If you have not already loaded Trellis context this session, read the `trellis-start` skill once. +</trellis-bootstrap>""" + + +# --------------------------------------------------------------------------- +# 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 = { + # ZCode may set both ZCODE_PROJECT_DIR and CLAUDE_PROJECT_DIR; check + # ZCODE first so ZCode sessions aren't misdetected as claude. + "ZCODE_PROJECT_DIR": "zcode", + "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", + "TRAE_PROJECT_DIR": "trae", + } + 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" + if ".trae" in script_parts: + return "trae" + if ".zcode" in script_parts: + return "zcode" + 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 _read_trellis_config(root: Path) -> dict: + """Load .trellis/config.yaml via the bundled trellis_config helper. + + The helper lives in .trellis/scripts/common; the hook lives outside the + scripts tree, so we extend sys.path before importing. + """ + scripts_dir = root / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.trellis_config import read_trellis_config # type: ignore[import-not-found] + except Exception: + return {} + try: + return read_trellis_config(root) + except Exception: + return {} + + +DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD = "no-trellis" + + +def _resolve_skip_keyword(config: dict) -> str: + """Read `prompt_injection.skip_keyword` from parsed .trellis/config.yaml. + + Mirrors `common.config.get_prompt_injection_config()`. Defaults to + "no-trellis"; "" disables the escape hatch entirely. A non-string value + falls back to the default. + """ + if isinstance(config, dict): + section = config.get("prompt_injection") + if isinstance(section, dict): + raw = section.get("skip_keyword", DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD) + if isinstance(raw, str): + return raw + return DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD + + +def prompt_has_skip_keyword(prompt: str, keyword: str) -> bool: + """Case-insensitive, word-boundary match of `keyword` in `prompt`. + + Hyphen counts as a word char so "no-trellisx" / "xno-trellis" / + "foo-no-trellis" don't match, but punctuation/whitespace boundaries do. + Empty keyword never matches (disables the escape hatch). + """ + if not keyword or not isinstance(prompt, str): + return False + pattern = r"(?<![\w-])" + re.escape(keyword) + r"(?![\w-])" + return re.search(pattern, prompt, re.IGNORECASE) is not None + + +def _resolve_codex_dispatch_mode(config: dict) -> str: + """Normalize `codex.dispatch_mode` from .trellis/config.yaml to "auto" or "inline". + + Defaults to `auto`. The legacy `sub-agent` value is an alias for `auto`. + Any other explicit value (including invalid ones) falls back to `inline` + without per-turn warnings. Shared by `_codex_mode_banner` (the per-turn + banner) and `resolve_breadcrumb_key` (the breadcrumb tag key) so the two + stay in lockstep. + """ + mode = "auto" + if isinstance(config, dict): + codex_cfg = config.get("codex") + if isinstance(codex_cfg, dict): + cfg_mode = str(codex_cfg.get("dispatch_mode", mode)).strip().lower() + if cfg_mode == "inline": + mode = "inline" + elif cfg_mode in ("auto", "sub-agent"): + mode = "auto" + else: + mode = "inline" + return mode + + +def _codex_mode_banner(config: dict) -> str: + """Emit a `<codex-mode>` banner for the additionalContext payload. + + Reads `codex.dispatch_mode` from .trellis/config.yaml; defaults to + `auto`, which dispatches Trellis sub-agents using native Codex context + injection with a child-side fallback. This does not rely on inherited + parent transcripts: `fork_turns` remains caller-controlled, and + fresh-history sub-agents still receive their explicit delegated task and + inherited session configuration. `inline` is an explicit opt-out; the + legacy `sub-agent` value is an alias for `auto`. Invalid explicit values + fall back to `inline` without per-turn warnings. The banner makes the + active mode explicit to Codex AI per turn, complementing the workflow-state + body which is per-status. Mode tells AI which dispatch protocol to follow; + workflow-state tells AI what step it's at. + """ + mode = _resolve_codex_dispatch_mode(config) + if mode == "auto": + meaning = ( + "auto: implement/check work defaults to Trellis sub-agents; native Codex " + "context injection is preferred and child-side loading is the fallback. " + "The main session still coordinates, clarifies, updates specs, commits, and finishes." + ) + else: + meaning = ( + "inline: the main session implements/checks directly; " + "do not dispatch implement/check sub-agents." + ) + return f"<codex-mode>{meaning}</codex-mode>" + + +def resolve_breadcrumb_key( + status: str, platform: str | None, config: dict +) -> str: + """Pick the breadcrumb tag key based on Codex dispatch_mode. + + Codex defaults to ``auto`` and therefore uses the ordinary ``<status>`` + breadcrumb for native SubagentStart dispatch with child-side fallback; + it does not depend on an inherited parent transcript. ``inline`` selects + the parallel ``<status>-inline`` tag; ``sub-agent`` remains an alias for + ``auto``. Invalid explicit values fall back to inline without per-turn + warnings. + + Non-codex platforms return the plain status unchanged. + """ + if platform == "codex": + mode = _resolve_codex_dispatch_mode(config) + return f"{status}-inline" if mode == "inline" else status + return status + + +def build_breadcrumb( + task_id: Optional[str], + status: str, + templates: dict[str, str], + source: str | None = None, + breadcrumb_key: str | None = None, +) -> str: + """Build the <workflow-state>...</workflow-state> 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 + """ + lookup_key = breadcrumb_key or status + body = templates.get(lookup_key) + if body is None and lookup_key != status: + 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})" + return f"<workflow-state>\n{header}\n{body}\n</workflow-state>" + + +# --------------------------------------------------------------------------- +# Entry +# --------------------------------------------------------------------------- + +def _load_hook_input() -> dict: + """Read hook JSON without trusting host runners to close stdin. + + Kiro IDE `runCommand` and similar hook runners can leave stdin open while + sending no payload. A plain `json.load(sys.stdin)` then blocks forever. + Normal hook runners write the complete JSON payload and close stdin, so the + short daemon read preserves that path while failing closed to `{}` for + non-piping hosts. + """ + result_queue: "queue.Queue[str | Exception]" = queue.Queue(maxsize=1) + + def _read() -> None: + try: + result_queue.put(sys.stdin.read()) + except Exception as exc: + result_queue.put(exc) + + reader = threading.Thread(target=_read, daemon=True) + reader.start() + try: + raw = result_queue.get(timeout=0.2) + except queue.Empty: + return {} + + if isinstance(raw, Exception): + return {} + try: + data = json.loads(raw) if raw.strip() else {} + except (json.JSONDecodeError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def main() -> int: + if os.environ.get("TRELLIS_HOOKS") == "0" or os.environ.get("TRELLIS_DISABLE_HOOKS") == "1": + return 0 + + data = _load_hook_input() + + 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 + + config = _read_trellis_config(root) + if prompt_has_skip_keyword(data.get("prompt", ""), _resolve_skip_keyword(config)): + return 0 # user opted out of the per-turn breadcrumb for this turn + + templates = load_breadcrumbs(root) + platform = _detect_platform(data) + 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. + no_task_key = resolve_breadcrumb_key("no_task", platform, config) + breadcrumb = build_breadcrumb( + None, "no_task", templates, breadcrumb_key=no_task_key + ) + else: + task_id, status, source = task + status_key = resolve_breadcrumb_key(status, platform, config) + source_for_breadcrumb = None if platform == "codex" else source + breadcrumb = build_breadcrumb( + task_id, status, templates, source_for_breadcrumb, breadcrumb_key=status_key + ) + if platform == "codex": + parts: list[str] = [] + if task is None: + parts.append(CODEX_NO_TASK_BOOTSTRAP_NOTICE) + parts.append(_codex_mode_banner(config)) + parts.append(breadcrumb) + breadcrumb = "\n\n".join(parts) + + # Kiro (CLI userPromptSubmit / IDE promptSubmit) adds a hook's stdout + # directly to the conversation context — no JSON envelope. Emit the bare + # breadcrumb text. Conditionally isolated: all other platforms keep the + # hookSpecificOutput JSON path below unchanged. + if platform == "kiro": + print(breadcrumb) + return 0 + + # 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 platform == "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/.codex/hooks/session-start.py b/.codex/hooks/session-start.py new file mode 100644 index 0000000..ca5608f --- /dev/null +++ b/.codex/hooks/session-start.py @@ -0,0 +1,551 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Codex Session Start Hook - Inject Trellis context into Codex sessions. + +Output format follows Codex hook protocol: + stdout JSON → { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: "..." } } +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import warnings +from io import StringIO +from pathlib import Path + +# Force UTF-8 on stdin/stdout/stderr on Windows. Default codepage there is +# cp936 / cp1252 / etc. — non-ASCII content (Chinese task names, prd snippets) +# both in stdin (hook payload from host CLI) and stdout (our emitted blocks) +# raises UnicodeDecodeError / UnicodeEncodeError. Equivalent to `python -X utf8` +# but applied per-stream so we don't depend on host CLI's command wiring. +if sys.platform.startswith("win"): + import io as _io + for _stream_name in ("stdin", "stdout", "stderr"): + _stream = getattr(sys, _stream_name, None) + if _stream is None: + continue + if hasattr(_stream, "reconfigure"): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + except Exception: + pass # Optional Windows stream setup; keep hook startup non-fatal. + elif hasattr(_stream, "detach"): + try: + setattr(sys, _stream_name, _io.TextIOWrapper(_stream.detach(), encoding="utf-8", errors="replace")) + except Exception: + pass # Optional Windows stream setup; keep hook startup non-fatal. + + +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) + rest = rest.replace('/', '\\') + return f"{drive}:\\{rest}" + + # 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) + rest = rest.replace('/', '\\') + return f"{drive}:\\{rest}" + + # 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) + rest = rest.replace('/', '\\') + return f"{drive}:\\{rest}" + + return path_str + + +warnings.filterwarnings("ignore") + +FIRST_REPLY_NOTICE = """<first-reply-notice> +On the first visible assistant reply in this session, briefly acknowledge that Trellis SessionStart context loaded. +Choose the acknowledgment language in this order: +1. Use the language of the user's current request (the user message that triggered this reply). +2. If that request has no clear natural language, use an explicitly established project communication language. +3. If neither provides a language, output the language-neutral fallback exactly: `Trellis SessionStart ✓`. +Continue directly with the user's request after the acknowledgment. +The acknowledgment must not alter the language used for the remainder of the response. +This notice is one-shot: do not repeat it after the first visible assistant reply in this session. +</first-reply-notice>""" + + +def should_skip_injection() -> bool: + if os.environ.get("TRELLIS_HOOKS") == "0": + return True + if os.environ.get("TRELLIS_DISABLE_HOOKS") == "1": + return True + return os.environ.get("CODEX_NON_INTERACTIVE") == "1" + + +def configure_project_encoding(project_dir: Path) -> None: + """Reuse Trellis' shared Windows stdio encoding helper before JSON output.""" + scripts_dir = project_dir / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + + try: + from common import configure_encoding # type: ignore[import-not-found] + + configure_encoding() + except Exception: + pass # Optional encoding helper; host defaults are still usable. + + +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 ``inject-subagent-context.py``. + """ + 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 read_file(path: Path, fallback: str = "") -> str: + try: + return path.read_text(encoding="utf-8") + except (FileNotFoundError, PermissionError): + return fallback + + +def _resolve_context_key(project_dir: Path, hook_input: dict) -> str | None: + scripts_dir = project_dir / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.active_task import resolve_context_key # type: ignore[import-not-found] + except Exception: + return None + return resolve_context_key(hook_input, platform="codex") + + +def _resolve_active_task(trellis_dir: Path, hook_input: 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, hook_input, platform="codex") + + +def run_script(script_path: Path, context_key: str | None = None) -> str: + try: + 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)] + result = subprocess.run( + cmd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=5, + cwd=str(script_path.parent.parent.parent), + env=env, + ) + return result.stdout if result.returncode == 0 else "No context available" + except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError): + return "No context available" + + +def _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, hook_input: dict) -> str: + active = _resolve_active_task(trellis_dir, hook_input) + if not active.task_path: + return ( + "Status: NO ACTIVE TASK\n" + "Next: Classify the current turn and ask for task-creation consent " + "before creating any Trellis task." + ) + + 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" + "Next: Task directory not found. Run: python3 ./.trellis/scripts/task.py finish" + ) + + task_json_path = task_dir / "task.json" + task_data: dict = {} + if task_json_path.is_file(): + try: + task_data = json.loads(task_json_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, PermissionError): + pass # Optional task metadata; fall back to generic status. + + task_title = task_data.get("title", task_ref) + task_status = task_data.get("status", "unknown") + + if task_status == "completed": + return ( + f"Status: COMPLETED\nTask: {task_title}\n" + f"Next: Archive with `python3 ./.trellis/scripts/task.py archive {task_dir.name}` " + "or start a new task." + ) + + has_prd = (task_dir / "prd.md").is_file() + has_design = (task_dir / "design.md").is_file() + has_implement = (task_dir / "implement.md").is_file() + present = [ + name + for name in ("prd.md", "design.md", "implement.md", "implement.jsonl", "check.jsonl") + if (task_dir / name).is_file() + ] + present_line = ", ".join(present) if present else "none" + + if not has_prd: + return ( + f"Status: PLANNING\nTask: {task_title}\nPresent: {present_line}\n" + "Next: Load trellis-brainstorm and write prd.md. Stay in planning." + ) + + if task_status == "planning": + if has_design and has_implement: + next_action = "Review planning artifacts with the user before `task.py start`." + else: + next_action = ( + "Lightweight task can ask for start review with PRD-only; " + "complex task must add design.md and implement.md before `task.py start`." + ) + return ( + f"Status: PLANNING\nTask: {task_title}\nPresent: {present_line}\n" + f"Next: {next_action}" + ) + + return ( + f"Status: {task_status.upper()}\nTask: {task_title}\nPresent: {present_line}\n" + "Next: Follow the matching per-turn workflow-state. Context order is jsonl entries, " + "prd.md, design.md if present, implement.md if present." + ) + + +def _run_git(repo_root: Path, args: list[str]) -> str: + try: + result = subprocess.run( + ["git", *args], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=3, + cwd=str(repo_root), + ) + except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError): + return "" + if result.returncode != 0: + return "" + return result.stdout.strip() + + +def _format_git_state(repo_root: Path) -> str: + branch = _run_git(repo_root, ["branch", "--show-current"]) or "(detached)" + dirty_lines = [ + line for line in _run_git(repo_root, ["status", "--porcelain"]).splitlines() + if line.strip() + ] + dirty_text = "clean" if not dirty_lines else f"dirty {len(dirty_lines)} paths" + return f"Git: branch {branch}; {dirty_text}." + + +def _repo_relative(repo_root: Path, path: Path) -> str: + try: + return path.relative_to(repo_root).as_posix() + except ValueError: + return str(path) + + +def _collect_spec_index_paths(trellis_dir: Path) -> list[str]: + paths: list[str] = [] + guides_index = trellis_dir / "spec" / "guides" / "index.md" + if guides_index.is_file(): + paths.append(".trellis/spec/guides/index.md") + + spec_dir = trellis_dir / "spec" + if not spec_dir.is_dir(): + return paths + + for sub in sorted(spec_dir.iterdir()): + if not sub.is_dir() or sub.name.startswith(".") or sub.name == "guides": + continue + index_file = sub / "index.md" + if index_file.is_file(): + paths.append(f".trellis/spec/{sub.name}/index.md") + 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") + + return paths + + +def _build_compact_current_state( + trellis_dir: Path, + hook_input: dict, + spec_index_paths: list[str], +) -> str: + repo_root = trellis_dir.parent + lines: list[str] = [] + + try: + from common.paths import get_active_journal_file, get_developer, get_tasks_dir, count_lines # type: ignore[import-not-found] + from common.tasks import iter_active_tasks # type: ignore[import-not-found] + except Exception: + get_active_journal_file = None # type: ignore[assignment] + get_developer = None # type: ignore[assignment] + get_tasks_dir = None # type: ignore[assignment] + count_lines = None # type: ignore[assignment] + iter_active_tasks = None # type: ignore[assignment] + + developer = get_developer(repo_root) if get_developer else None + lines.append(f"Developer: {developer or '(not initialized)'}") + lines.append(_format_git_state(repo_root)) + + active = _resolve_active_task(trellis_dir, hook_input) + if active.task_path: + task_dir = _resolve_task_dir(trellis_dir, active.task_path) + status = "unknown" + task_json = task_dir / "task.json" + if task_json.is_file(): + try: + data = json.loads(task_json.read_text(encoding="utf-8")) + if isinstance(data, dict): + status = str(data.get("status") or "unknown") + except (json.JSONDecodeError, OSError): + pass # Optional task metadata; fall back to generic status. + lines.append(f"Current task: {_repo_relative(repo_root, task_dir)}; status={status}.") + else: + lines.append("Current task: none.") + + if get_tasks_dir and iter_active_tasks: + try: + task_count = sum(1 for _ in iter_active_tasks(get_tasks_dir(repo_root))) + lines.append( + f"Active tasks: {task_count} total. Use `python3 ./.trellis/scripts/task.py list --mine` only if needed." + ) + except Exception: + pass # Optional task summary; keep compact state available. + + if get_active_journal_file and count_lines: + journal = get_active_journal_file(repo_root) + if journal: + lines.append( + f"Journal: {_repo_relative(repo_root, journal)}, {count_lines(journal)} / 2000 lines." + ) + + if spec_index_paths: + lines.append(f"Spec indexes: {len(spec_index_paths)} available.") + + return "\n".join(lines) + + +def _extract_range(content: str, start_header: str, end_header: str) -> str: + """Extract lines starting at `## start_header` up to (but excluding) `## end_header`.""" + 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: + stripped = _BREADCRUMB_TAG_RE.sub("", content) + stripped = re.sub(r"<!--.*?-->", "", stripped, flags=re.DOTALL) + stripped = re.sub(r"^\[(?!/?workflow-state:)/?[^\]\n]+\]\s*\n?", "", stripped, flags=re.MULTILINE) + return re.sub(r"\n{3,}", "\n\n", stripped).strip() + + +def _build_workflow_toc(workflow_path: Path) -> str: + """Inject only the compact Phase Index summary for SessionStart.""" + content = read_file(workflow_path) + if not content: + return "No workflow.md found" + + out_lines = [ + "# Development Workflow - Session Summary", + "Full guide: .trellis/workflow.md. Step detail: `python3 ./.trellis/scripts/get_context.py --mode phase --step <X.Y>`.", + "", + ] + + phases = _extract_range(content, "Phase Index", "Phase 1: Plan") + if phases: + out_lines.append(_strip_breadcrumb_tag_blocks(phases).rstrip()) + + return "\n".join(out_lines).rstrip() + + +def main() -> None: + if should_skip_injection(): + sys.exit(0) + + # Read hook input from stdin + try: + hook_input = json.loads(sys.stdin.read()) + if not isinstance(hook_input, dict): + hook_input = {} + project_dir = Path(_normalize_windows_shell_path(hook_input.get("cwd", "."))).resolve() + except (json.JSONDecodeError, KeyError): + hook_input = {} + project_dir = Path(".").resolve() + + configure_project_encoding(project_dir) + + trellis_dir = project_dir / ".trellis" + spec_index_paths = _collect_spec_index_paths(trellis_dir) + + output = StringIO() + + output.write("""<session-context> +Trellis compact SessionStart context. Use it to orient the session; load details on demand. +</session-context> + +""") + output.write(FIRST_REPLY_NOTICE) + output.write("\n\n") + + output.write("<current-state>\n") + output.write(_build_compact_current_state(trellis_dir, hook_input, spec_index_paths)) + output.write("\n</current-state>\n\n") + + output.write("<trellis-workflow>\n") + output.write(_build_workflow_toc(trellis_dir / "workflow.md")) + output.write("\n</trellis-workflow>\n\n") + + output.write("<guidelines>\n") + output.write( + "Task context order for implementation/check: jsonl entries -> `prd.md` -> " + "`design.md if present` -> `implement.md if present`. Missing optional artifacts " + "are skipped for lightweight tasks.\n\n" + ) + + if spec_index_paths: + output.write("## Available indexes (read on demand)\n") + for p in spec_index_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("</guidelines>\n\n") + + task_status = _get_task_status(trellis_dir, hook_input) + output.write(f"<task-status>\n{task_status}\n</task-status>\n\n") + + output.write("""<ready> +Context loaded. Follow <task-status>. Load workflow/spec/task details only when needed. +</ready>""") + + context = output.getvalue() + result = { + "suppressOutput": True, + "systemMessage": f"Trellis context injected ({len(context)} chars)", + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": context, + }, + } + + print(json.dumps(result, ensure_ascii=False), flush=True) + + +if __name__ == "__main__": + main() diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..bec56db --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# Trellis: append-only developer journals should merge cleanly across +# parallel sessions/worktrees — each session only appends a new block, so +# there is nothing to actually conflict on. +# +# Do NOT add a rule for workspace/*/index.md here — it is fully regenerated +# every session, so a real conflict there is expected and safe to resolve by +# picking either side (task state lives in task.json, not index.md). See +# .trellis/spec/cli/backend/directory-structure.md for details. +.trellis/workspace/*/journal-*.md merge=union diff --git a/.trellis/.gitignore b/.trellis/.gitignore new file mode 100644 index 0000000..5a991ea --- /dev/null +++ b/.trellis/.gitignore @@ -0,0 +1,32 @@ +# Developer identity (local only) +.developer + +# Current task pointer (each dev works on different task) +.current-task + +# Session/window scoped runtime state +.runtime/ + +# Ralph Loop state file +.ralph-state.json + +# Agent runtime files +.agents/ +.agent-log +.session-id + +# Task directory runtime files +.plan-log + +# Atomic update temp files +*.tmp + +# Update backup directories +.backup-* + +# Conflict resolution temp files +*.new + +# Python cache +**/__pycache__/ +**/*.pyc diff --git a/.trellis/.template-hashes.json b/.trellis/.template-hashes.json new file mode 100644 index 0000000..4bff6f0 --- /dev/null +++ b/.trellis/.template-hashes.json @@ -0,0 +1,145 @@ +{ + "__version": 2, + "hashes": { + ".claude/agents/trellis-check.md": "9e48342243f311d55386f8fb42933945e87aba73d5ade547133aa98345a06128", + ".claude/agents/trellis-implement.md": "73b56b3047c0e852382c4630aa181fb847d1e5ea1f63459dac4bf728f43fd097", + ".claude/agents/trellis-research.md": "add4aa4259ded425b04ec992802c646490352bb4eb3730a7ab450beea87d4faa", + ".claude/settings.json": "1a65892b2b161910468970ab30ebc3f8216241640f75fccbe4be5552c58a2752", + ".claude/hooks/inject-subagent-context.py": "abffa237eb53f87ae6ffa434063b46b03d58a36a84cdb8fe88bfc5f243aab609", + ".claude/hooks/inject-workflow-state.py": "9ce43910ac39cbb0e4d1783fbde931761eb04536c7f82661a96d95ea72c14bde", + ".claude/hooks/session-start.py": "68c68e08ff3382c95766eced91332e2ae77b655d82972f206e4585b1e5751249", + ".claude/hooks/statusline.py": "edbd8d1443ac8e7bb4f8cce61cb21812a8b0e17c59e1bc5c0e3ad03ea1f69adb", + ".claude/commands/trellis/continue.md": "6c34c41824f8eff4b2df792e032e0c36787b59f8a8879b0338347073f76ad52c", + ".claude/commands/trellis/finish-work.md": "d6aa570ab684f57e4845de2d84a1ff6d9f0908e04c5a56e14fd70ae739c369fc", + ".claude/skills/trellis-before-dev/SKILL.md": "859894f2e8258cbfa142d710363433761c54984c69f7ed7bd44512fb4eb165cd", + ".claude/skills/trellis-brainstorm/SKILL.md": "a0f226ddcb8a3e846acd2a35d121996e9ca55165ce76202095d0b65e2b48a5e8", + ".claude/skills/trellis-break-loop/SKILL.md": "f5a93699832f29dee443b53c135a7459519b371f689af191d15c29e8ee5c7bde", + ".claude/skills/trellis-check/SKILL.md": "b21ff04b7680ebacb8c5ecbc48a22d627eb13e2b47fceb78c8ced0b43b60b282", + ".claude/skills/trellis-update-spec/SKILL.md": "d975db7af166578488958751ae2c56edb827a68bddb569aa27acc3453f64e610", + ".claude/skills/trellis-channel/references/command-reference.md": "c4df3d940d89814310bfdaded20e84dd1d6b96786f6772688de7a123338d3c8e", + ".claude/skills/trellis-channel/references/forum.md": "407db39e8ebde47cea8637e3069bdb3d76ba42b43ed1d08ba97a56f26dbcfcb4", + ".claude/skills/trellis-channel/references/progress-debugging.md": "93b021fd8d5d3d4ee54952c40424ab91ca57c2ab8032bfbfc3b5c5e22f1464cd", + ".claude/skills/trellis-channel/references/workers.md": "d4481b2858bca82050cb8d131916da8a2ccc3857f199cdb9ced8bb41d592e2a7", + ".claude/skills/trellis-channel/references/workflows.md": "0782d602efde6e94a7d7d7950fd605eedc9a1f5a523908284e76b628fd618fd4", + ".claude/skills/trellis-channel/SKILL.md": "ff84801c511a736da018b4b1149348ebc9a16b7fc18d905ec10cfa7acbf269a3", + ".claude/skills/trellis-meta/references/customize-local/add-project-local-conventions.md": "86009ccb5d0373f399582da0bc570c4e5c6053c3c764857424ff93384f0e04e5", + ".claude/skills/trellis-meta/references/customize-local/change-agents.md": "4216ac3cc570038fd8ce3319a85932bb537fea9e0a7f4d11fa315cfa645c9c85", + ".claude/skills/trellis-meta/references/customize-local/change-context-loading.md": "350d319dc1ab99609ddbf52cf8c06c71bd97ba1a29ce2eac8b97d0bb192938bf", + ".claude/skills/trellis-meta/references/customize-local/change-hooks.md": "4c18b134f05d5ba1609c517ec94c97c9442b5f0670aa2211c487c31ed2a4f358", + ".claude/skills/trellis-meta/references/customize-local/change-skills-or-commands.md": "5b942e9f512e049a75e8dd9e8cc4d49786f6da61b22afc82bb447acf808f9fe2", + ".claude/skills/trellis-meta/references/customize-local/change-spec-structure.md": "31eccaad7097d96e66a45c1b4caea1ba4f2e54b7814184c3ebf82c87dafc4841", + ".claude/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "60ff9efb93604b87a461a4af30322d76750402a51e40f31531a7ff88d309996d", + ".claude/skills/trellis-meta/references/customize-local/change-workflow.md": "43fa780a2ca580de121b10893d49b99f978873deebbf45008c466e5ac6651519", + ".claude/skills/trellis-meta/references/customize-local/overview.md": "ce8f09e9f93ce9a48500763fb3a4db2b3908a5fbf4f985ab71dacebb404cf8f4", + ".claude/skills/trellis-meta/references/local-architecture/bundled-skills.md": "7a8d1a5dcc8d1140c4c6bd19d02949364cdd01801bd0825a975a308cf85b8f37", + ".claude/skills/trellis-meta/references/local-architecture/context-injection.md": "8497289bf333b3aa456f317039d1239b7ece79254aa0eb62cfc647714c866084", + ".claude/skills/trellis-meta/references/local-architecture/generated-files.md": "7eb2d452eddb4f4226f7578c2ec6d5ee0434ed172ba4c36107cc8bdff7554dc6", + ".claude/skills/trellis-meta/references/local-architecture/multi-agent-channel.md": "56e5070474aeca872e2d70c46feea5aaafecd3d3ec052c3f7b1877358dca62e9", + ".claude/skills/trellis-meta/references/local-architecture/overview.md": "50638fd9eaaba2e0edf2f2a84d920578d5b2fb7934031b174e417d3dc510b2a6", + ".claude/skills/trellis-meta/references/local-architecture/spec-system.md": "b8d8a6a0888b44a232c8f50161b9e20e903cf621ad7be4021715ab6fab226f47", + ".claude/skills/trellis-meta/references/local-architecture/task-system.md": "2b561d49c390f7d0db5391912946133be4bf73189231e2b8cc9afa1c5ac6165a", + ".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": "9f41349b78f7ae64698a38490a317882561f3805b26a79bda587a13d03fda245", + ".claude/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "154af08f7ee8afe7a704968ec0b9fc21e905b7cfb03871c9dbafcdd6cc654319", + ".claude/skills/trellis-meta/references/platform-files/overview.md": "1aec9087ccedd56a213af877b5db474131aa4b4789b6ac0e8da73b058aa43bd4", + ".claude/skills/trellis-meta/references/platform-files/platform-map.md": "9e476e500f10b2a1a05278dd700837deb731a77e3f3993e90f8102528a9bdcca", + ".claude/skills/trellis-meta/references/platform-files/skills-and-commands.md": "e39831d860bd27a04e7757f7bd941ab83b3c5b11ad4460cec03975b655f26cc3", + ".claude/skills/trellis-meta/SKILL.md": "208e92ace3b8d979d8158b3bd0169185f508f2e1424fcd78fcd03cbcc39516a1", + ".claude/skills/trellis-session-insight/references/cli-quick-reference.md": "c520353fe3fc00b9702ed4f780647c8fbd6d342e66527a03647f533a9fe09779", + ".claude/skills/trellis-session-insight/references/triggering-patterns.md": "121ecd23be83d1567e8ce15c366a81073d7a2b1d3ad616fce235c07ca1f1cc20", + ".claude/skills/trellis-session-insight/SKILL.md": "d20f1d20c6946e26ba6f848b9a016d9a16c803d87501462e5b11a2ced6f56643", + ".claude/skills/trellis-spec-bootstrap/references/mcp-setup.md": "df542fc8f279edd38046d26a7c8151804b708f57b24d4aa2733cea587a88c65e", + ".claude/skills/trellis-spec-bootstrap/references/repository-analysis.md": "0dae98d774f6e34559b9f3442888ac43e3a8af110c37cbefc49ce256986858b6", + ".claude/skills/trellis-spec-bootstrap/references/spec-task-planning.md": "ef493d028c3b0807a8a534bb71fb92a68129f273db763ad27ceb464a522e799d", + ".claude/skills/trellis-spec-bootstrap/references/spec-writing.md": "e9800fe9ed4a4cd87062ea1829cf2caa8d170ec15e141678a6a30e74c497f47d", + ".claude/skills/trellis-spec-bootstrap/SKILL.md": "97bfa68c06cebb558eb4464bc1b81f7d2d56040d75baa8de1ee5ad90cca0196a", + ".agents/skills/trellis-continue/SKILL.md": "7723ccf49fbf19d8f086cacc7a080bd8be8db6fc70a32908b80f68efa318d7bf", + ".agents/skills/trellis-finish-work/SKILL.md": "161060fbcd44f787440d3a5c297a9f5223ea7774bb3021a50e376875a9ac5b2d", + ".agents/skills/trellis-start/SKILL.md": "79a5ba7a2aff3c72e06d7f4cd6942dc4f4f4092dd40f9c8e94f1838024a81e4d", + ".agents/skills/trellis-before-dev/SKILL.md": "859894f2e8258cbfa142d710363433761c54984c69f7ed7bd44512fb4eb165cd", + ".agents/skills/trellis-brainstorm/SKILL.md": "a0f226ddcb8a3e846acd2a35d121996e9ca55165ce76202095d0b65e2b48a5e8", + ".agents/skills/trellis-break-loop/SKILL.md": "f5a93699832f29dee443b53c135a7459519b371f689af191d15c29e8ee5c7bde", + ".agents/skills/trellis-check/SKILL.md": "b21ff04b7680ebacb8c5ecbc48a22d627eb13e2b47fceb78c8ced0b43b60b282", + ".agents/skills/trellis-update-spec/SKILL.md": "003ce08a3404aeb50998029392c4d4e57b626edf526d3ebd585032bb92dcbb96", + ".agents/skills/trellis-channel/references/command-reference.md": "c4df3d940d89814310bfdaded20e84dd1d6b96786f6772688de7a123338d3c8e", + ".agents/skills/trellis-channel/references/forum.md": "407db39e8ebde47cea8637e3069bdb3d76ba42b43ed1d08ba97a56f26dbcfcb4", + ".agents/skills/trellis-channel/references/progress-debugging.md": "93b021fd8d5d3d4ee54952c40424ab91ca57c2ab8032bfbfc3b5c5e22f1464cd", + ".agents/skills/trellis-channel/references/workers.md": "d4481b2858bca82050cb8d131916da8a2ccc3857f199cdb9ced8bb41d592e2a7", + ".agents/skills/trellis-channel/references/workflows.md": "0782d602efde6e94a7d7d7950fd605eedc9a1f5a523908284e76b628fd618fd4", + ".agents/skills/trellis-channel/SKILL.md": "ff84801c511a736da018b4b1149348ebc9a16b7fc18d905ec10cfa7acbf269a3", + ".agents/skills/trellis-meta/references/customize-local/add-project-local-conventions.md": "86009ccb5d0373f399582da0bc570c4e5c6053c3c764857424ff93384f0e04e5", + ".agents/skills/trellis-meta/references/customize-local/change-agents.md": "4216ac3cc570038fd8ce3319a85932bb537fea9e0a7f4d11fa315cfa645c9c85", + ".agents/skills/trellis-meta/references/customize-local/change-context-loading.md": "350d319dc1ab99609ddbf52cf8c06c71bd97ba1a29ce2eac8b97d0bb192938bf", + ".agents/skills/trellis-meta/references/customize-local/change-hooks.md": "4c18b134f05d5ba1609c517ec94c97c9442b5f0670aa2211c487c31ed2a4f358", + ".agents/skills/trellis-meta/references/customize-local/change-skills-or-commands.md": "5b942e9f512e049a75e8dd9e8cc4d49786f6da61b22afc82bb447acf808f9fe2", + ".agents/skills/trellis-meta/references/customize-local/change-spec-structure.md": "31eccaad7097d96e66a45c1b4caea1ba4f2e54b7814184c3ebf82c87dafc4841", + ".agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "60ff9efb93604b87a461a4af30322d76750402a51e40f31531a7ff88d309996d", + ".agents/skills/trellis-meta/references/customize-local/change-workflow.md": "43fa780a2ca580de121b10893d49b99f978873deebbf45008c466e5ac6651519", + ".agents/skills/trellis-meta/references/customize-local/overview.md": "ce8f09e9f93ce9a48500763fb3a4db2b3908a5fbf4f985ab71dacebb404cf8f4", + ".agents/skills/trellis-meta/references/local-architecture/bundled-skills.md": "7a8d1a5dcc8d1140c4c6bd19d02949364cdd01801bd0825a975a308cf85b8f37", + ".agents/skills/trellis-meta/references/local-architecture/context-injection.md": "8497289bf333b3aa456f317039d1239b7ece79254aa0eb62cfc647714c866084", + ".agents/skills/trellis-meta/references/local-architecture/generated-files.md": "7eb2d452eddb4f4226f7578c2ec6d5ee0434ed172ba4c36107cc8bdff7554dc6", + ".agents/skills/trellis-meta/references/local-architecture/multi-agent-channel.md": "56e5070474aeca872e2d70c46feea5aaafecd3d3ec052c3f7b1877358dca62e9", + ".agents/skills/trellis-meta/references/local-architecture/overview.md": "50638fd9eaaba2e0edf2f2a84d920578d5b2fb7934031b174e417d3dc510b2a6", + ".agents/skills/trellis-meta/references/local-architecture/spec-system.md": "b8d8a6a0888b44a232c8f50161b9e20e903cf621ad7be4021715ab6fab226f47", + ".agents/skills/trellis-meta/references/local-architecture/task-system.md": "2b561d49c390f7d0db5391912946133be4bf73189231e2b8cc9afa1c5ac6165a", + ".agents/skills/trellis-meta/references/local-architecture/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6", + ".agents/skills/trellis-meta/references/local-architecture/workspace-memory.md": "e6427b46aba744563c2444b30df4043cd856561b7709ec2dece26095416421fd", + ".agents/skills/trellis-meta/references/platform-files/agents.md": "9f41349b78f7ae64698a38490a317882561f3805b26a79bda587a13d03fda245", + ".agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "154af08f7ee8afe7a704968ec0b9fc21e905b7cfb03871c9dbafcdd6cc654319", + ".agents/skills/trellis-meta/references/platform-files/overview.md": "1aec9087ccedd56a213af877b5db474131aa4b4789b6ac0e8da73b058aa43bd4", + ".agents/skills/trellis-meta/references/platform-files/platform-map.md": "9e476e500f10b2a1a05278dd700837deb731a77e3f3993e90f8102528a9bdcca", + ".agents/skills/trellis-meta/references/platform-files/skills-and-commands.md": "e39831d860bd27a04e7757f7bd941ab83b3c5b11ad4460cec03975b655f26cc3", + ".agents/skills/trellis-meta/SKILL.md": "208e92ace3b8d979d8158b3bd0169185f508f2e1424fcd78fcd03cbcc39516a1", + ".agents/skills/trellis-session-insight/references/cli-quick-reference.md": "c520353fe3fc00b9702ed4f780647c8fbd6d342e66527a03647f533a9fe09779", + ".agents/skills/trellis-session-insight/references/triggering-patterns.md": "121ecd23be83d1567e8ce15c366a81073d7a2b1d3ad616fce235c07ca1f1cc20", + ".agents/skills/trellis-session-insight/SKILL.md": "d20f1d20c6946e26ba6f848b9a016d9a16c803d87501462e5b11a2ced6f56643", + ".agents/skills/trellis-spec-bootstrap/references/mcp-setup.md": "df542fc8f279edd38046d26a7c8151804b708f57b24d4aa2733cea587a88c65e", + ".agents/skills/trellis-spec-bootstrap/references/repository-analysis.md": "0dae98d774f6e34559b9f3442888ac43e3a8af110c37cbefc49ce256986858b6", + ".agents/skills/trellis-spec-bootstrap/references/spec-task-planning.md": "ef493d028c3b0807a8a534bb71fb92a68129f273db763ad27ceb464a522e799d", + ".agents/skills/trellis-spec-bootstrap/references/spec-writing.md": "e9800fe9ed4a4cd87062ea1829cf2caa8d170ec15e141678a6a30e74c497f47d", + ".agents/skills/trellis-spec-bootstrap/SKILL.md": "97bfa68c06cebb558eb4464bc1b81f7d2d56040d75baa8de1ee5ad90cca0196a", + ".codex/agents/trellis-check.toml": "79070c63fa404fc53061cca5194bf66db839132b67a57b9c8d6a295037ba7308", + ".codex/agents/trellis-implement.toml": "388fb8f39797e0ee6cf4db447c859c79b4ac15f531f7e1e3c68c1d1d71a1c188", + ".codex/agents/trellis-research.toml": "4435ce73197ba1d29d40359a3279b6423f7e4f559a449f934c016808090066c4", + ".codex/hooks/session-start.py": "14de3be1cf6eb9c9feba348d8998b407f3837d6c0756b74210c9200543440677", + ".codex/hooks/inject-subagent-context.py": "abffa237eb53f87ae6ffa434063b46b03d58a36a84cdb8fe88bfc5f243aab609", + ".codex/hooks/inject-workflow-state.py": "9ce43910ac39cbb0e4d1783fbde931761eb04536c7f82661a96d95ea72c14bde", + ".codex/hooks.json": "85a58ba7cdf1e19e7f75ddcc64e5680180c487ca266a74bd5005f31abeee2e02", + ".codex/config.toml": "9f2d20e28f0bc9c886312eca3ad3bba41533ef4615aaaafe25e98152302267bb", + "AGENTS.md": "6cacfe99748b435d0660c2463c697bc323d53798aecf3492283ca8eac1b29682", + ".trellis/agents/check.md": "edb4f57361407249a53bf5998ebf91c40d2b969e826a2c5e1b4e813a08bcb175", + ".trellis/agents/implement.md": "66e25ad046c94869442834bc3cdfbd5a9a7412d3ff54561d64d2886552c27e87", + ".trellis/config.yaml": "a966e6d374e9e6ff283cf761ccd99631323ee1754856cef51ad154ca0afb9dfa", + ".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c", + ".trellis/scripts/add_session.py": "876dad478edf70db59acccaae9cb4db646a155681f730bd99af48de72ddc9881", + ".trellis/scripts/common/__init__.py": "3d5e9347141f0296319a5beb29d69ae714c5a474b9078caeb3edd7c5f6562e22", + ".trellis/scripts/common/active_task.py": "31271e3b69b5a5eca958d8ce25f61fe852eb6e48d32c150471da8e7272fd6119", + ".trellis/scripts/common/cli_adapter.py": "5d6bd9d6f5c631e7e792db7dd343351317f9643bc87b73a9a98abd51cefb4307", + ".trellis/scripts/common/config.py": "8d2e5f8ccfcd5f622cd2af002aa761f3d3ffcc653182fefb2268afd102e77bca", + ".trellis/scripts/common/developer.py": "f5f833123abe68890171b4da825a324216d24913f6b5ad9245afc556424ffd7b", + ".trellis/scripts/common/git.py": "6fc5845d0104dd506ebd8b366a24cb4b1e3d8777e4e6acc12ea15c9d8e2662f2", + ".trellis/scripts/common/git_context.py": "fa30ced454f1a91ffc9f8b2abeb32225e3447cbdc90bad783797374eba07265d", + ".trellis/scripts/common/io.py": "75648caae03d5b1107d7aeccaa785d133b25762266e54a520d90ca8c76b43bdb", + ".trellis/scripts/common/log.py": "471df6895cfac80f995edebbf9974f6b7440634b7a688f28b8331c868bc0f3cf", + ".trellis/scripts/common/packages_context.py": "efe158d7c99c2268851d0216fbb08de22836e418a8dbeb73575b8cc249eed7b7", + ".trellis/scripts/common/paths.py": "05898ef136cc7c4d861b05fbf2b16d53ddd3e6f311a231d4fcfcb81bde7c45ee", + ".trellis/scripts/common/safe_commit.py": "baa5c82324eb62154374ec63394ecdc8609bb37d93892e3bcb88f452bb7d6446", + ".trellis/scripts/common/session_context.py": "4ed3e13b2878ba367e9f2e2cd709b396f806152902df2cd1cd1478317d069017", + ".trellis/scripts/common/task_context.py": "4ea260a022f4122361eb0d9dd9200a9324aeafbdb20cadd2848bea1649938f1d", + ".trellis/scripts/common/task_queue.py": "0be61f713462b1fe4574927c82fc4704e678afe72dcb9813543aedf2f9e9e0c5", + ".trellis/scripts/common/task_store.py": "e3c2fbf8b79b591e39fc3c9f4e2f3ee0c840c8201c94a16709ec743fa45037f6", + ".trellis/scripts/common/task_utils.py": "90c0a6d50bad502c3f01cb24c1ccfeb0eece5e2c69efaff8d65eb827fba43871", + ".trellis/scripts/common/tasks.py": "4436a8b0b53c270a35989e26d9dbd92669408c6562d88c02083a404562da85fe", + ".trellis/scripts/common/trellis_config.py": "e282e897183e3ec2f4e6e56349431946e5f98c1c31d3eca4de7fc44e1383a7bf", + ".trellis/scripts/common/types.py": "9962081cc2608fb9d1deb32c6880e336f62cdca6b338e7ae813304701e155ee9", + ".trellis/scripts/common/workflow_phase.py": "79ee522de20246acf1e2c222e8ad180ad25aaec7fec98214a93d9e81b350d9a8", + ".trellis/scripts/get_context.py": "ca5bf9e90bdb1d75d3de182b95f820f9d108ab28793d29097b24fd71315adcf5", + ".trellis/scripts/get_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f", + ".trellis/scripts/hooks/linear_sync.py": "e09cc4ce4699aada908808718698f33f705a3edf55c4dcf8f777ad892f80ca79", + ".trellis/scripts/init_developer.py": "f9e6c0d882406e81c8cd6b1c5abb204b0befc0069ff89cf650cd536a80f8c60e", + ".trellis/scripts/task.py": "e0ffed9f14994069f0c992141e3ec168524be5af32e3681e6ea30ba0a5da4bc4", + ".trellis/workflow.md": "e2c5ab7004ff83a5a804b50df81746aa1d558dd4480463287622605f86a82a76" + } +} \ No newline at end of file diff --git a/.trellis/.version b/.trellis/.version new file mode 100644 index 0000000..e9acb99 --- /dev/null +++ b/.trellis/.version @@ -0,0 +1 @@ +0.6.12 \ No newline at end of file diff --git a/.trellis/agents/check.md b/.trellis/agents/check.md new file mode 100644 index 0000000..6c1bf13 --- /dev/null +++ b/.trellis/agents/check.md @@ -0,0 +1,70 @@ +--- +name: check +description: | + Code quality auditor for the Trellis channel runtime. Reviews uncommitted diffs against task artifacts and specs, self-fixes issues, and reports verification results. +provider: claude +labels: [trellis, check] +--- + +# Check Agent (channel runtime) + +You are the Check Agent spawned by `trellis channel spawn --agent check` inside the Trellis channel runtime. You receive an `Active task: <path>` line in your inbox; use it to locate task artifacts on disk. + +## Context + +Before reviewing, read in this order: + +1. `<task-path>/check.jsonl` if present — spec manifest curated for this turn; read every listed file +2. `<task-path>/prd.md` — requirements +3. `<task-path>/design.md` if present — technical design +4. `<task-path>/implement.md` if present — execution plan +5. `.trellis/spec/` — project-wide guidelines (load only what is relevant to the diff under review) + +## Core Responsibilities + +1. **Get the diff** — `git diff` / `git diff --staged` for uncommitted changes +2. **Review against task artifacts** — does the diff satisfy `prd.md` (and `design.md` / `implement.md` if present)? +3. **Review against specs** — naming, structure, type safety, error handling, conventions in `.trellis/spec/` +4. **Self-fix** — when an issue is mechanical and small, fix it directly with the editing tools you have +5. **Run verification** — project lint and typecheck on the changed scope +6. **Report** — concrete findings with `file:line` citations and what was fixed vs. what is open + +## Forbidden Operations + +- `git commit` +- `git push` +- `git merge` + +The supervising main session owns commits. Report the post-fix state; do not commit on its behalf. + +## Workflow + +1. Run `git diff --name-only` and `git diff` to scope the changes +2. Read the task artifacts and relevant spec files +3. For each issue: + - If mechanical (lint nit, missing type, wrong import, dead branch) → fix in-place + - If a design/judgment issue → record and report, do not silently rewrite +4. Run the project's lint and typecheck on the changed scope after self-fixes +5. Report + +## Report Format + +``` +## Self-Check Complete + +### Files Checked +- <path> + +### Issues Found and Fixed +1. `<file>:<line>` — <what was wrong> → <what you changed> + +### Issues Not Fixed +- `<file>:<line>` — <issue> — <why deferred to the main session> + +### Verification Results +- TypeCheck: <pass|fail|skipped + reason> +- Lint: <pass|fail|skipped + reason> + +### Summary +Checked <N> files, found <X> issues, fixed <Y>, <X-Y> open. +``` diff --git a/.trellis/agents/implement.md b/.trellis/agents/implement.md new file mode 100644 index 0000000..3262f79 --- /dev/null +++ b/.trellis/agents/implement.md @@ -0,0 +1,71 @@ +--- +name: implement +description: | + Code implementation expert for the Trellis channel runtime. Understands specs and task artifacts, then implements features. No git commit allowed. +provider: claude +labels: [trellis, implement] +--- + +# Implement Agent (channel runtime) + +You are the Implement Agent spawned by `trellis channel spawn --agent implement` inside the Trellis channel runtime. You receive an `Active task: <path>` line in your inbox; use it to locate task artifacts on disk. + +## Context + +Before implementing, read in this order: + +1. `<task-path>/implement.jsonl` if present — spec manifest curated for this turn; read every listed file +2. `<task-path>/prd.md` — requirements +3. `<task-path>/design.md` if present — technical design +4. `<task-path>/implement.md` if present — execution plan +5. `.trellis/spec/` — project-wide guidelines (load only what is relevant to the diff you are about to write) + +## Core Responsibilities + +1. **Understand specs** — read relevant spec files in `.trellis/spec/` +2. **Understand task artifacts** — read the artifacts listed above +3. **Implement features** — write code that follows specs and existing patterns +4. **Self-check** — run lint and typecheck on the changed scope before reporting + +## Forbidden Operations + +- `git commit` +- `git push` +- `git merge` + +The supervising main session owns commits. Report what changed; do not commit on its behalf. + +## Workflow + +1. Read relevant specs based on task type and the files in `implement.jsonl` if present +2. Read the task's `prd.md`, `design.md` if present, and `implement.md` if present +3. Implement features following specs and existing patterns +4. Run the project's lint and typecheck commands on the changed scope +5. Report files touched, key decisions, and verification results back to the channel + +## Code Standards + +- Follow existing code patterns +- Don't add unnecessary abstractions +- Only do what the PRD asks for; no speculative scope expansion +- Surface uncertainty back to the channel rather than guessing + +## Report Format + +``` +## Implementation Complete + +### Files Modified +- <path> — <one-line description> + +### Implementation Summary +1. <step> +2. <step> + +### Verification Results +- Lint: <pass|fail|skipped + reason> +- TypeCheck: <pass|fail|skipped + reason> + +### Open Questions +- <if any, otherwise omit> +``` diff --git a/.trellis/config.yaml b/.trellis/config.yaml new file mode 100644 index 0000000..4eaf288 --- /dev/null +++ b/.trellis/config.yaml @@ -0,0 +1,158 @@ +# Trellis Configuration +# Project-level settings for the Trellis workflow system +# +# All values have sensible defaults. Only override what you need. + +#------------------------------------------------------------------------------- +# Session Recording +#------------------------------------------------------------------------------- + +# Commit message used when auto-committing journal/index changes +# after running add_session.py +session_commit_message: "chore: record journal" + +# Maximum lines per journal file before rotating to a new one +max_journal_lines: 2000 + +#------------------------------------------------------------------------------- +# Session Auto-Commit +#------------------------------------------------------------------------------- + +# Auto-commit behavior for session journal + task archive operations. +# - true (default): scripts auto-stage and auto-commit journal / task changes +# after add_session.py / task.py archive runs. +# - false: scripts do not touch git. Files (journal-*.md, task archive moves) +# are still written to disk; you decide whether to git add / commit. +# +# Use `false` if your project's .gitignore intentionally excludes `.trellis/` +# and you want session data kept local-only, or if you prefer to review +# staged changes manually before each commit. +# +# Accepts: true / false / yes / no / 1 / 0 / on / off (case-insensitive). +# +# session_auto_commit: true + +#------------------------------------------------------------------------------- +# Task Lifecycle Hooks +#------------------------------------------------------------------------------- + +# Shell commands to run after task lifecycle events. +# Each hook receives TASK_JSON_PATH environment variable pointing to task.json. +# Hook failures print a warning but do not block the main operation. +# +# hooks: +# after_create: +# - "echo 'Task created'" +# after_start: +# - "echo 'Task started'" +# after_finish: +# - "echo 'Task finished'" +# after_archive: +# - "echo 'Task archived'" + +#------------------------------------------------------------------------------- +# Monorepo / Packages +#------------------------------------------------------------------------------- + +# Declare packages for monorepo projects. +# Trellis auto-detects workspaces during `trellis init`, but you can also +# configure them manually here. +# +# packages: +# frontend: +# path: packages/frontend +# backend: +# path: packages/backend +# docs: +# path: docs-site +# type: submodule +# # 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 + +#------------------------------------------------------------------------------- +# Channel worker OOM guard +#------------------------------------------------------------------------------- +# Default safeguards for `trellis channel spawn` workers. The guard runs +# at spawn time (cleans expired idle workers, then enforces the live-worker +# budget) and inside each supervisor (self-terminates a worker that stays +# continuously idle past `idle_timeout`). +# +# Precedence: CLI flag > env var (TRELLIS_CHANNEL_WORKER_IDLE_TIMEOUT / +# TRELLIS_CHANNEL_MAX_LIVE_WORKERS) > this config > built-in default. +# +# `idle_timeout: 0` disables idle cleanup (workers can sit idle forever +# unless explicitly killed or given `--timeout`). +# `max_live_workers: 0` disables the spawn-time budget check. +# +# `trusted_context_dirs` extends the `--file`/`--jsonl`/`--agent` containment +# check beyond the worker cwd — useful when `.trellis/tasks` or +# `.trellis/workspace` is a symlink to an external directory. Realpaths under +# any listed dir are accepted in addition to cwd. +# `auto_trust_trellis_symlinks: false` disables the narrow auto-trust of +# `.trellis/tasks` / `.trellis/workspace` when either is itself a top-level +# symlink (auto-trust is on by default). +# +channel: + worker_guard: + idle_timeout: 5m + max_live_workers: 6 + # trusted_context_dirs: + # - /work/user/trellis_workspace + # auto_trust_trellis_symlinks: false + +#------------------------------------------------------------------------------- +# Codex (dispatch behavior) +#------------------------------------------------------------------------------- +# Codex-only knob; other platforms ignore it. Default ("auto") dispatches +# trellis-implement / trellis-check / trellis-research sub-agents. This does +# not rely on inherited parent transcripts: `fork_turns` remains +# caller-controlled, while Codex's native SubagentStart hook injects task +# context when trusted and child-side loading remains the fallback when it is +# unavailable. Set to "inline" only to keep implementation and checks in the +# main session. "sub-agent" remains a backwards-compatible alias for "auto". +# Invalid explicit values safely use inline mode. +# +# In "auto" mode, dispatched sub-agents inherit the main session's model +# unless you pin one. To use a cheaper/faster model for implement/check/ +# research sub-agent work, edit `model` / `model_reasoning_effort` directly +# on the generated `.codex/agents/trellis-*.toml` files (see the commented +# hint lines in those files) — there is no config.yaml knob for this, +# `trellis update` preserves your edits across regeneration. +# +# codex: +# dispatch_mode: auto # or "inline"; legacy alias: "sub-agent" + +#------------------------------------------------------------------------------- +# Sub-agent context injection limits +#------------------------------------------------------------------------------- +# Caps how much task context (implement.jsonl / check.jsonl referenced files, +# plus prd.md / design.md / implement.md) gets inlined into a sub-agent's +# first prompt. Oversized files are truncated with a notice; once the total +# payload cap is reached, remaining files degrade to index lines (path + +# reason + size) instead of being inlined. +# +# All values are byte counts. `0` disables the corresponding limit. +# +# context_injection: +# max_file_bytes: 32768 # per implement.jsonl / check.jsonl referenced file +# max_artifact_bytes: 65536 # per task artifact (prd.md / design.md / implement.md) +# max_total_bytes: 131072 # whole injected payload; overflow degrades to index lines + +#------------------------------------------------------------------------------- +# Per-turn prompt injection +#------------------------------------------------------------------------------- +# Escape hatch for the per-turn <workflow-state> breadcrumb. When a user +# prompt contains the skip keyword as a standalone word (case-insensitive, +# word-boundary match — "no-trellisfoo" does NOT count), the breadcrumb is +# skipped for that turn only. Does not affect SessionStart or sub-agent +# context injection. +# +# prompt_injection: +# skip_keyword: "no-trellis" # "" disables the escape hatch entirely diff --git a/.trellis/scripts/__init__.py b/.trellis/scripts/__init__.py new file mode 100755 index 0000000..815a137 --- /dev/null +++ b/.trellis/scripts/__init__.py @@ -0,0 +1,5 @@ +""" +Trellis Python Scripts + +This module provides Python implementations of Trellis workflow scripts. +""" diff --git a/.trellis/scripts/add_session.py b/.trellis/scripts/add_session.py new file mode 100755 index 0000000..6cd6636 --- /dev/null +++ b/.trellis/scripts/add_session.py @@ -0,0 +1,681 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Add a new session to journal file and update index.md. + +Usage: + python3 add_session.py --title "Title" --commit "hash" --summary "Summary" [--package cli] + python3 add_session.py --title "Title" --branch "feat/my-branch" + + # Pipe detailed content via stdin (use --stdin to opt in): + cat << 'EOF' | python3 add_session.py --stdin --title "Title" --summary "Summary" + <session content here> + EOF + + # Structured content (repeatable; a section with no bullets is omitted): + python3 add_session.py --title "Title" --change "Did X" --test "Ran Y" --next-step "Do Z" + +Branch resolution order: + 1. --branch CLI arg (explicit) + 2. task.json branch field (from active task, if still exists) + 3. git branch --show-current (auto-detect) + 4. None (omitted gracefully) +""" + +from __future__ import annotations + +import argparse +import re +import sys +from datetime import datetime +from pathlib import Path + +from common.paths import ( + DIR_TASKS, + DIR_WORKFLOW, + FILE_JOURNAL_PREFIX, + get_repo_root, + get_current_task, + get_developer, + get_workspace_dir, +) +from common.developer import ensure_developer +from common.git import run_git +from common.log import Colors, colored +from common.safe_commit import ( + print_gitignore_warning, + safe_git_add, + safe_trellis_paths_to_add, +) +from common.tasks import load_task +from common.types import TaskInfo +from common.config import ( + get_packages, + get_session_auto_commit, + get_session_commit_message, + get_max_journal_lines, + is_monorepo, + resolve_package, + validate_package, +) + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def get_latest_journal_info(dev_dir: Path) -> tuple[Path | None, int, int]: + """Get latest journal file info. + + Returns: + Tuple of (file_path, file_number, line_count). + """ + latest_file: Path | None = None + latest_num = -1 + + for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"): + if not f.is_file(): + continue + + match = re.search(r"(\d+)$", f.stem) + if match: + num = int(match.group(1)) + if num > latest_num: + latest_num = num + latest_file = f + + if latest_file: + lines = len(latest_file.read_text(encoding="utf-8").splitlines()) + return latest_file, latest_num, lines + + return None, 0, 0 + + +def get_current_session(index_file: Path) -> int: + """Get current session number from index.md.""" + if not index_file.is_file(): + return 0 + + content = index_file.read_text(encoding="utf-8") + for line in content.splitlines(): + if "Total Sessions" in line: + match = re.search(r":\s*(\d+)", line) + if match: + return int(match.group(1)) + return 0 + + +def _extract_journal_num(filename: str) -> int: + """Extract journal number from filename for sorting.""" + match = re.search(r"(\d+)", filename) + return int(match.group(1)) if match else 0 + + +def count_journal_files(dev_dir: Path, active_num: int) -> str: + """Count journal files and return table rows.""" + active_file = f"{FILE_JOURNAL_PREFIX}{active_num}.md" + result_lines = [] + + files = sorted( + [f for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md") if f.is_file()], + key=lambda f: _extract_journal_num(f.stem), + reverse=True + ) + + for f in files: + filename = f.name + lines = len(f.read_text(encoding="utf-8").splitlines()) + status = "Active" if filename == active_file else "Archived" + result_lines.append(f"| `{filename}` | ~{lines} | {status} |") + + return "\n".join(result_lines) + + +def get_current_git_branch(repo_root: Path) -> str | None: + """Return the current checkout branch, or None for detached/non-git states.""" + rc, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) + if rc != 0: + return None + detected = branch_out.strip() + return detected or None + + +def branch_ref_exists(repo_root: Path, branch: str) -> bool: + """Return True when branch exists locally or as the local origin ref.""" + for ref in (f"refs/heads/{branch}", f"refs/remotes/origin/{branch}"): + rc, _, _ = run_git(["show-ref", "--verify", "--quiet", ref], cwd=repo_root) + if rc == 0: + return True + return False + + +def resolve_session_branch( + repo_root: Path, + cli_branch: str | None, + task_data: TaskInfo | None, +) -> str | None: + """Resolve journal branch without trusting stale task.json branch fields.""" + if cli_branch: + return cli_branch + + current_branch = get_current_git_branch(repo_root) + raw_task_branch = task_data.raw.get("branch") if task_data else None + task_branch = raw_task_branch.strip() if isinstance(raw_task_branch, str) else "" + if not task_branch: + return current_branch + + if branch_ref_exists(repo_root, task_branch): + return task_branch + + if current_branch: + print( + f"Warning: task.json branch '{task_branch}' no longer exists locally or as origin/{task_branch}; using current branch '{current_branch}'.", + file=sys.stderr, + ) + return current_branch + + print( + f"Warning: task.json branch '{task_branch}' no longer exists locally or as origin/{task_branch}; omitting branch.", + file=sys.stderr, + ) + return None + + +def is_git_worktree(repo_root: Path) -> bool: + """Return True when repo_root is a linked worktree (not the main working tree). + + Standard test: `git rev-parse --git-dir` (per-worktree) differs from + `git rev-parse --git-common-dir` (shared across all worktrees) once both + are resolved to absolute paths. In the main working tree these are the + same directory. + """ + rc_dir, git_dir, _ = run_git(["rev-parse", "--git-dir"], cwd=repo_root) + rc_common, git_common_dir, _ = run_git( + ["rev-parse", "--git-common-dir"], cwd=repo_root + ) + if rc_dir != 0 or rc_common != 0: + return False + + git_dir_path = (repo_root / git_dir.strip()).resolve() + git_common_dir_path = (repo_root / git_common_dir.strip()).resolve() + return git_dir_path != git_common_dir_path + + +def warn_if_parallel_worktree(repo_root: Path) -> None: + """Non-blocking note: index.md conflicts across parallel worktrees/branches + are expected and safe. Only fires when running in a linked git worktree + (not the main tree) with `session_auto_commit` enabled (#415 quick-fix tier). + """ + if not get_session_auto_commit(repo_root): + return + if not is_git_worktree(repo_root): + return + print( + colored( + "[NOTE] Running in a git worktree with session_auto_commit enabled: " + "journal-*.md files auto-merge via .gitattributes, but index.md " + "conflicts across parallel worktrees/branches are expected and safe " + "to resolve by picking either side (task state lives in task.json, " + "not index.md). See .trellis/spec/cli/backend/directory-structure.md " + '("Workspace Journal Merge Behavior").', + Colors.YELLOW, + ), + file=sys.stderr, + ) + + +def create_new_journal_file( + dev_dir: Path, num: int, developer: str, today: str, max_lines: int = 2000, +) -> Path: + """Create a new journal file.""" + prev_num = num - 1 + new_file = dev_dir / f"{FILE_JOURNAL_PREFIX}{num}.md" + + content = f"""# Journal - {developer} (Part {num}) + +> Continuation from `{FILE_JOURNAL_PREFIX}{prev_num}.md` (archived at ~{max_lines} lines) +> Started: {today} + +--- + +""" + new_file.write_text(content, encoding="utf-8") + return new_file + + +def _render_bullet_section(header: str, items: list[str], bullet_prefix: str = "- ") -> str: + """Render a Markdown section as bullets, or "" when there is no content. + + A section with zero provided values is omitted entirely from the + rendered entry rather than falling back to a placeholder string. + """ + if not items: + return "" + bullets = "\n".join(f"{bullet_prefix}{item}" for item in items) + return f"\n\n### {header}\n\n{bullets}" + + +def _render_main_changes(changes: list[str], extra_content: str | None) -> str: + """Render the Main Changes section from --change bullets or freeform content.""" + if changes: + return _render_bullet_section("Main Changes", changes) + if extra_content: + return f"\n\n### Main Changes\n\n{extra_content}" + return "" + + +def generate_session_content( + session_num: int, + title: str, + commit: str, + summary: str, + today: str, + package: str | None = None, + branch: str | None = None, + changes: list[str] | None = None, + extra_content: str | None = None, + tests: list[str] | None = None, + next_steps: list[str] | None = None, +) -> str: + """Generate session content.""" + if commit and commit != "-": + commit_table = """| Hash | Message | +|------|---------|""" + for c in commit.split(","): + c = c.strip() + commit_table += f"\n| `{c}` | (see git log) |" + else: + commit_table = "(No commits - planning session)" + + package_line = f"\n**Package**: {package}" if package else "" + branch_line = f"\n**Branch**: `{branch}`" if branch else "" + + main_changes_section = _render_main_changes(changes or [], extra_content) + testing_section = _render_bullet_section("Testing", tests or [], bullet_prefix="- [OK] ") + next_steps_section = _render_bullet_section("Next Steps", next_steps or []) + + return f""" + +## Session {session_num}: {title} + +**Date**: {today} +**Task**: {title}{package_line}{branch_line} + +### Summary + +{summary}{main_changes_section} + +### Git Commits + +{commit_table}{testing_section} + +### Status + +[OK] **Completed**{next_steps_section} +""" + + +def update_index( + index_file: Path, + dev_dir: Path, + title: str, + commit: str, + new_session: int, + active_file: str, + today: str, + branch: str | None = None, +) -> bool: + """Update index.md with new session info.""" + # Format commit for display + commit_display = "-" + if commit and commit != "-": + commit_display = re.sub(r"([a-f0-9]{7,})", r"`\1`", commit.replace(",", ", ")) + + # Get file number from active_file name + match = re.search(r"(\d+)", active_file) + active_num = int(match.group(1)) if match else 0 + files_table = count_journal_files(dev_dir, active_num) + + print(f"Updating index.md for session {new_session}...") + print(f" Title: {title}") + print(f" Commit: {commit_display}") + print(f" Active File: {active_file}") + print() + + content = index_file.read_text(encoding="utf-8") + + if "@@@auto:current-status" not in content: + print("Error: Markers not found in index.md. Please ensure markers exist.", file=sys.stderr) + return False + + # Process sections + lines = content.splitlines() + new_lines = [] + + in_current_status = False + in_active_documents = False + in_session_history = False + header_written = False + + for line in lines: + if "@@@auto:current-status" in line: + new_lines.append(line) + in_current_status = True + new_lines.append(f"- **Active File**: `{active_file}`") + new_lines.append(f"- **Total Sessions**: {new_session}") + new_lines.append(f"- **Last Active**: {today}") + continue + + if "@@@/auto:current-status" in line: + in_current_status = False + new_lines.append(line) + continue + + if "@@@auto:active-documents" in line: + new_lines.append(line) + in_active_documents = True + new_lines.append("| File | Lines | Status |") + new_lines.append("|------|-------|--------|") + new_lines.append(files_table) + continue + + if "@@@/auto:active-documents" in line: + in_active_documents = False + new_lines.append(line) + continue + + if "@@@auto:session-history" in line: + new_lines.append(line) + in_session_history = True + header_written = False + continue + + if "@@@/auto:session-history" in line: + in_session_history = False + new_lines.append(line) + continue + + if in_current_status: + continue + + if in_active_documents: + continue + + if in_session_history: + # Migrate old 4/6-column headers to 5-column Branch-only history. + if re.match( + r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*Base Branch\s*\|\s*$", + line, + ): + new_lines.append("| # | Date | Title | Commits | Branch |") + continue + if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*$", line): + new_lines.append("| # | Date | Title | Commits | Branch |") + continue + if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*$", line): + new_lines.append("| # | Date | Title | Commits | Branch |") + continue + if re.match(r"^\|[-| ]+\|\s*$", line) and not header_written: + new_lines.append("|---|------|-------|---------|--------|") + new_lines.append(f"| {new_session} | {today} | {title} | {commit_display} | `{branch or '-'}` |") + header_written = True + continue + new_lines.append(line) + continue + + new_lines.append(line) + + index_file.write_text("\n".join(new_lines), encoding="utf-8") + print("[OK] Updated index.md successfully!") + return True + + +# ============================================================================= +# Main Function +# ============================================================================= + +def _auto_commit_workspace(repo_root: Path) -> None: + """Stage Trellis-owned workspace + current-task paths and commit. + + Path scope is restricted to specific products: the current developer's + journal files + index.md, and ONLY the current task directory (resolved + via ``get_current_task``). We never `git add` the whole `.trellis/` tree + or iterate over all active task dirs (#303: parallel-window dirty task + dirs must not be bundled into the session auto-commit). If `.gitignore` + blocks the specific paths we warn + skip — never retry with ``-f``. + + Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when set to + ``false``, this function returns immediately without touching git + (journal/index files are still written to disk by the caller). + """ + if not get_session_auto_commit(repo_root): + print( + "[OK] session_auto_commit: false — skipping git stage/commit.", + file=sys.stderr, + ) + return + + commit_msg = get_session_commit_message(repo_root) + # Resolve the current task so staging is scoped to its dir only. The ref + # is ``.trellis/tasks/<name>`` (or under archive/) — pass the bare name. + current = get_current_task(repo_root) + if current: + task_name = Path(current).name + paths = safe_trellis_paths_to_add(repo_root, task_name=task_name) + else: + # Current task unknown (0 or >=2 parallel sessions — exactly the + # parallel-window case #303 is about). Do NOT fall back to the wide + # `tasks_dir.iterdir()` scan; that would re-leak other tasks' dirty + # dirs into the session commit. Stage only the developer's journal/ + # index and skip every task dir. + paths = [ + p + for p in safe_trellis_paths_to_add(repo_root, task_name=None) + if not p.startswith(f"{DIR_WORKFLOW}/{DIR_TASKS}/") + ] + if not paths: + print("[OK] No workspace changes to commit.", file=sys.stderr) + return + + success, _, err = safe_git_add(paths, repo_root) + if not success: + if err and "ignored by" in err.lower(): + print_gitignore_warning(paths) + else: + print( + f"[WARN] git add failed: {err.strip() if err else 'unknown error'}", + file=sys.stderr, + ) + return + + # Check if there are staged changes for the paths we just staged. + rc, _, _ = run_git( + ["diff", "--cached", "--quiet", "--", *paths], cwd=repo_root + ) + if rc == 0: + print("[OK] No workspace changes to commit.", file=sys.stderr) + return + + rc, _, commit_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: {commit_err.strip()}", + file=sys.stderr, + ) + + +def add_session( + title: str, + commit: str = "-", + summary: str = "Session summary was not supplied.", + changes: list[str] | None = None, + extra_content: str | None = None, + tests: list[str] | None = None, + next_steps: list[str] | None = None, + auto_commit: bool = True, + package: str | None = None, + branch: str | None = None, +) -> int: + """Add a new session.""" + repo_root = get_repo_root() + warn_if_parallel_worktree(repo_root) + ensure_developer(repo_root) + + developer = get_developer(repo_root) + if not developer: + print("Error: Developer not initialized", file=sys.stderr) + return 1 + + dev_dir = get_workspace_dir(repo_root) + if not dev_dir: + print("Error: Workspace directory not found", file=sys.stderr) + return 1 + + max_lines = get_max_journal_lines(repo_root) + + index_file = dev_dir / "index.md" + today = datetime.now().strftime("%Y-%m-%d") + + journal_file, current_num, current_lines = get_latest_journal_info(dev_dir) + current_session = get_current_session(index_file) + new_session = current_session + 1 + + session_content = generate_session_content( + new_session, title, commit, summary, today, package, branch, + changes=changes, extra_content=extra_content, tests=tests, + next_steps=next_steps, + ) + content_lines = len(session_content.splitlines()) + + print("========================================", file=sys.stderr) + print("ADD SESSION", file=sys.stderr) + print("========================================", file=sys.stderr) + print("", file=sys.stderr) + print(f"Session: {new_session}", file=sys.stderr) + print(f"Title: {title}", file=sys.stderr) + print(f"Commit: {commit}", file=sys.stderr) + print("", file=sys.stderr) + print(f"Current journal file: {FILE_JOURNAL_PREFIX}{current_num}.md", file=sys.stderr) + print(f"Current lines: {current_lines}", file=sys.stderr) + print(f"New content lines: {content_lines}", file=sys.stderr) + print(f"Total after append: {current_lines + content_lines}", file=sys.stderr) + print("", file=sys.stderr) + + target_file = journal_file + target_num = current_num + + if current_lines + content_lines > max_lines: + target_num = current_num + 1 + print(f"[!] Exceeds {max_lines} lines, creating {FILE_JOURNAL_PREFIX}{target_num}.md", file=sys.stderr) + target_file = create_new_journal_file(dev_dir, target_num, developer, today, max_lines) + print(f"Created: {target_file}", file=sys.stderr) + + # Append session content + if target_file: + with target_file.open("a", encoding="utf-8") as f: + f.write(session_content) + print(f"[OK] Appended session to {target_file.name}", file=sys.stderr) + + print("", file=sys.stderr) + + # Update index.md + active_file = f"{FILE_JOURNAL_PREFIX}{target_num}.md" + if not update_index( + index_file, + dev_dir, + title, + commit, + new_session, + active_file, + today, + branch, + ): + return 1 + + print("", file=sys.stderr) + print("========================================", file=sys.stderr) + print(f"[OK] Session {new_session} added successfully!", file=sys.stderr) + print("========================================", file=sys.stderr) + print("", file=sys.stderr) + print("Files updated:", file=sys.stderr) + print(f" - {target_file.name if target_file else 'journal'}", file=sys.stderr) + print(" - index.md", file=sys.stderr) + + # Auto-commit workspace changes + if auto_commit: + print("", file=sys.stderr) + _auto_commit_workspace(repo_root) + + return 0 + + +# ============================================================================= +# Main Entry +# ============================================================================= + +def main() -> int: + """CLI entry point.""" + parser = argparse.ArgumentParser( + description="Add a new session to journal file and update index.md" + ) + parser.add_argument("--title", required=True, help="Session title") + parser.add_argument("--commit", default="-", help="Comma-separated commit hashes") + parser.add_argument("--summary", default="Session summary was not supplied.", 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("--change", action="append", help="Main Changes bullet (repeatable)") + parser.add_argument("--test", action="append", help="Testing bullet (repeatable)") + parser.add_argument("--next-step", action="append", help="Next Steps bullet (repeatable)") + parser.add_argument("--no-commit", action="store_true", + help="Skip auto-commit of workspace changes") + parser.add_argument("--stdin", action="store_true", + help="Read extra content from stdin (explicit opt-in)") + + args = parser.parse_args() + + extra_content: str | None = None + if args.content_file: + content_path = Path(args.content_file) + if content_path.is_file(): + extra_content = content_path.read_text(encoding="utf-8") + elif args.stdin: + extra_content = sys.stdin.read() + + # Load active task once — shared by package and branch resolution + repo_root = get_repo_root() + current = get_current_task(repo_root) + task_data = load_task(repo_root / current) if current else None + + package = args.package + if package: + # CLI source: fail-fast in monorepo, ignore in single-repo + if not is_monorepo(repo_root): + print("Warning: --package ignored in single-repo project", file=sys.stderr) + package = None + elif not validate_package(package, repo_root): + packages = get_packages(repo_root) + available = ", ".join(sorted(packages.keys())) if packages else "(none)" + print(f"Error: unknown package '{package}'. Available: {available}", file=sys.stderr) + return 1 + else: + # Inferred: active task's task.json.package → default_package → None + task_package = task_data.package if task_data else None + package = resolve_package(task_package, repo_root) + + branch = resolve_session_branch(repo_root, args.branch, task_data) + + return add_session( + args.title, args.commit, args.summary, + changes=args.change, extra_content=extra_content, tests=args.test, + next_steps=args.next_step, + auto_commit=not args.no_commit, + package=package, + branch=branch, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.trellis/scripts/common/__init__.py b/.trellis/scripts/common/__init__.py new file mode 100755 index 0000000..6d72360 --- /dev/null +++ b/.trellis/scripts/common/__init__.py @@ -0,0 +1,92 @@ +""" +Common utilities for Trellis workflow scripts. + +This module provides shared functionality used by other Trellis scripts. +""" + +import io +import sys + +# ============================================================================= +# Windows Encoding Fix (MUST be at top, before any other output) +# ============================================================================= +# On Windows, stdout defaults to the system code page (often GBK/CP936). +# This causes UnicodeEncodeError when printing non-ASCII characters. +# +# Any script that imports from common will automatically get this fix. +# ============================================================================= + + +def _configure_stream(stream: object) -> object: + """Configure a stream for UTF-8 encoding on Windows.""" + # Try reconfigure() first (Python 3.7+, more reliable) + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + return stream + # Fallback: detach and rewrap with TextIOWrapper + elif hasattr(stream, "detach"): + return io.TextIOWrapper( + stream.detach(), # type: ignore[union-attr] + encoding="utf-8", + errors="replace", + ) + return stream + + +if sys.platform == "win32": + sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment] + sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment] + sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment] + + +def configure_encoding() -> None: + """ + Configure stdout/stderr/stdin for UTF-8 encoding on Windows. + + This is automatically called when importing from common, + but can be called manually for scripts that don't import common. + + Safe to call multiple times. + """ + global sys + if sys.platform == "win32": + sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment] + sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment] + sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment] + + +from .paths import ( + DIR_WORKFLOW, + DIR_WORKSPACE, + DIR_TASKS, + DIR_ARCHIVE, + DIR_SPEC, + DIR_SCRIPTS, + FILE_DEVELOPER, + FILE_CURRENT_TASK, + FILE_TASK_JSON, + FILE_JOURNAL_PREFIX, + get_repo_root, + get_developer, + check_developer, + get_tasks_dir, + get_workspace_dir, + get_active_journal_file, + count_lines, + get_current_task, + get_current_task_abs, + normalize_task_ref, + resolve_task_ref, + set_current_task, + clear_current_task, + has_current_task, + generate_task_date_prefix, +) + +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..0eec6df --- /dev/null +++ b/.trellis/scripts/common/active_task.py @@ -0,0 +1,662 @@ +#!/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", + "trae", + "grok", + "kimi", + "zcode", + "snow", +} + +_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")), + ("trae", ("TRAE_SESSION_ID",)), + # ZCode reuses CLAUDE_SESSION_ID (it does not document a ZCODE_SESSION_ID). + # Platform-scoped lookup (_iter_env_keys filters by platform name), so this + # only fires when the resolver already detected "zcode" — no collision with + # the claude entry above. + ("zcode", ("CLAUDE_SESSION_ID",)), + # Snow CLI exports SNOW_SESSION_ID into hook/terminal/sub-agent children. + # TRELLIS_CONTEXT_ID remains the preferred override when present. + ("snow", ("SNOW_SESSION_ID",)), +) +_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", +} +# ZCode intentionally reuses CLAUDE_SESSION_ID. Hooks know the host is ZCode, +# while later shell commands see only the shared env name and resolve it through +# the Claude entry. Canonicalize both paths to one runtime filename. +_CONTEXT_KEY_PLATFORM_ALIASES = { + "zcode": "claude", +} + + +@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: + platform_name = _CONTEXT_KEY_PLATFORM_ALIASES.get(platform_name, platform_name) + 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, + *, + allow_environment_context: bool = True, +) -> 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. + """ + if allow_environment_context: + 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) + + if allow_environment_context: + env_context_key = _lookup_env_context_key(platform_name) + if env_context_key: + return env_context_key + + if allow_environment_context and 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, + *, + allow_single_session_fallback: bool = True, + allow_environment_context: bool = True, +) -> 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 pull-based platform sub-agents + (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, + allow_environment_context=allow_environment_context, + ) + 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 + + if allow_single_session_fallback: + 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 its resolved 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) + if not previous.task_path or not previous.context_key: + return previous + + context_path = _context_path(repo_root, previous.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 new file mode 100755 index 0000000..85e1c3a --- /dev/null +++ b/.trellis/scripts/common/cli_adapter.py @@ -0,0 +1,950 @@ +""" +CLI Adapter for Multi-Platform Support. + +Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, Devin, Qoder, CodeBuddy, GitHub Copilot, Factory Droid, and Pi Agent interfaces. + +Supported platforms: +- claude: Claude Code (default) +- opencode: OpenCode +- cursor: Cursor IDE +- iflow: iFlow CLI +- codex: Codex CLI (skills-based) +- kilo: Kilo CLI +- kiro: Kiro Code (skills-based) +- gemini: Gemini CLI +- antigravity: Antigravity (workflow-based) +- devin: Devin (formerly Windsurf; workflow-based) +- qoder: Qoder +- codebuddy: CodeBuddy +- copilot: GitHub Copilot (VS Code) +- droid: Factory Droid (commands-based) +- pi: Pi Agent (extension-backed) +- trae: Trae IDE (IDE-only, hooks-based) +- omp: Oh My Pi +- grok: Grok Build (pull-based skills/agents; no hook context injection) +- kimi: Kimi Code (pull-based skills; commands delivered as skills; no hook context injection) + +Usage: + from common.cli_adapter import CLIAdapter + + adapter = CLIAdapter("opencode") + cmd = adapter.build_run_command( + agent="dispatch", + session_id="abc123", + prompt="Start the pipeline" + ) +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import ClassVar, Literal + +Platform = Literal[ + "claude", + "opencode", + "cursor", + "iflow", + "codex", + "kilo", + "kiro", + "gemini", + "antigravity", + "devin", + "qoder", + "codebuddy", + "copilot", + "droid", + "pi", + "trae", + "omp", + "grok", + "kimi", +] + + +@dataclass +class CLIAdapter: + """Adapter for different AI coding CLI tools.""" + + platform: Platform + + # ========================================================================= + # Agent Name Mapping + # ========================================================================= + + # OpenCode has built-in agents that cannot be overridden + # See: https://github.com/sst/opencode/issues/4271 + # Note: Class-level constant, not a dataclass field + _AGENT_NAME_MAP: ClassVar[dict[Platform, dict[str, str]]] = { + "claude": {}, # No mapping needed + "opencode": { + "plan": "trellis-plan", # 'plan' is built-in in OpenCode + }, + } + + def get_agent_name(self, agent: str) -> str: + """Get platform-specific agent name. + + Args: + agent: Original agent name (e.g., 'plan', 'dispatch') + + Returns: + Platform-specific agent name (e.g., 'trellis-plan' for OpenCode) + """ + mapping = self._AGENT_NAME_MAP.get(self.platform, {}) + return mapping.get(agent, agent) + + # ========================================================================= + # Agent Path + # ========================================================================= + + @property + def config_dir_name(self) -> str: + """Get platform-specific config directory name. + + Returns: + Directory name ('.claude', '.opencode', '.cursor', '.iflow', '.codex', '.kilocode', '.kiro', '.gemini', '.agent', '.devin', '.qoder', '.codebuddy', '.github/copilot', '.factory', '.pi', or '.trae') + """ + if self.platform == "opencode": + return ".opencode" + elif self.platform == "cursor": + return ".cursor" + elif self.platform == "iflow": + return ".iflow" + elif self.platform == "codex": + return ".codex" + elif self.platform == "kilo": + return ".kilocode" + elif self.platform == "kiro": + return ".kiro" + elif self.platform == "gemini": + return ".gemini" + elif self.platform == "antigravity": + return ".agent" + elif self.platform == "devin": + return ".devin" + 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" + elif self.platform == "trae": + return ".trae" + elif self.platform == "omp": + return ".omp" + elif self.platform == "grok": + return ".grok" + elif self.platform == "kimi": + return ".kimi-code" + else: + return ".claude" + + def get_config_dir(self, project_root: Path) -> Path: + """Get platform-specific config directory. + + Args: + project_root: Project root directory + + Returns: + Path to config directory (.claude, .opencode, .cursor, .iflow, .codex, .kilocode, .kiro, .gemini, .agent, .devin, .qoder, .codebuddy, .github/copilot, .factory, .pi, or .trae) + """ + return project_root / self.config_dir_name + + def get_agent_path(self, agent: str, project_root: Path) -> Path: + """Get path to agent definition file. + + Args: + agent: Agent name (original, before mapping) + project_root: Project root directory + + Returns: + Path to agent definition file (.md for most platforms, .toml for Codex) + """ + mapped_name = self.get_agent_name(agent) + if self.platform == "codex": + return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.toml" + return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.md" + + def get_commands_path(self, project_root: Path, *parts: str) -> Path: + """Get path to commands directory or specific command file. + + Args: + project_root: Project root directory + *parts: Additional path parts (e.g., 'trellis', 'finish-work.md') + + Returns: + Path to commands directory or file + + Note: + Cursor uses prefix naming: .cursor/commands/trellis-<name>.md + Antigravity uses workflow directory: .agent/workflows/<name>.md + Devin uses workflow directory: .devin/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) + # OMP and Grok: flat slash commands under .{platform}/commands/trellis-<name>.md + if self.platform in ("omp", "grok"): + commands_dir = self.get_config_dir(project_root) / "commands" + if not parts: + return commands_dir + if len(parts) >= 2 and parts[0] == "trellis": + filename = parts[-1] + if filename.endswith(".md"): + filename = filename[:-3] + return commands_dir / f"trellis-{filename}.md" + return commands_dir / Path(*parts) + + # Kimi: commands are skills under .kimi-code/skills/trellis-<name>/SKILL.md + if self.platform == "kimi": + skills_dir = self.get_config_dir(project_root) / "skills" + if not parts: + return skills_dir + if len(parts) >= 2 and parts[0] == "trellis": + filename = parts[-1] + if filename.endswith(".md"): + filename = filename[:-3] + return skills_dir / f"trellis-{filename}" / "SKILL.md" + return skills_dir / Path(*parts) + + if self.platform == "devin": + workflow_dir = self.get_config_dir(project_root) / "workflows" + if not parts: + return workflow_dir + if len(parts) >= 2 and parts[0] == "trellis": + filename = parts[-1] + return workflow_dir / f"trellis-{filename}" + return workflow_dir / Path(*parts) + + if self.platform in ("antigravity", "kilo"): + workflow_dir = self.get_config_dir(project_root) / "workflows" + if not parts: + return workflow_dir + if len(parts) >= 2 and parts[0] == "trellis": + filename = parts[-1] + return workflow_dir / filename + return workflow_dir / Path(*parts) + + if self.platform == "copilot": + prompts_dir = project_root / ".github" / "prompts" + if not parts: + return prompts_dir + if len(parts) >= 2 and parts[0] == "trellis": + filename = parts[-1] + if filename.endswith(".md"): + filename = filename[:-3] + return prompts_dir / f"{filename}.prompt.md" + return prompts_dir / Path(*parts) + + if not parts: + return self.get_config_dir(project_root) / "commands" + + # Cursor uses prefix naming instead of subdirectory + if self.platform == "cursor" and len(parts) >= 2 and parts[0] == "trellis": + # Convert trellis/<name>.md to trellis-<name>.md + filename = parts[-1] + return ( + self.get_config_dir(project_root) / "commands" / f"trellis-{filename}" + ) + + return self.get_config_dir(project_root) / "commands" / Path(*parts) + + def get_trellis_command_path(self, name: str) -> str: + """Get relative path to a trellis command file. + + Args: + name: Command name without extension (e.g., 'finish-work', 'check') + + Returns: + Relative path string for use in JSONL entries + + Note: + Cursor: .cursor/commands/trellis-<name>.md + Codex: .agents/skills/trellis-<name>/SKILL.md + Kiro: .kiro/skills/trellis-<name>/SKILL.md + Gemini: .gemini/commands/trellis/<name>.toml + Antigravity: .agent/workflows/<name>.md + Devin: .devin/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": + # 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/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 == "devin": + return f".devin/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" + elif self.platform in ("omp", "grok"): + return f"{self.config_dir_name}/commands/trellis-{name}.md" + elif self.platform == "kimi": + return f".kimi-code/skills/trellis-{name}/SKILL.md" + else: + return f"{self.config_dir_name}/commands/trellis/{name}.md" + + # ========================================================================= + # Environment Variables + # ========================================================================= + + def get_non_interactive_env(self) -> dict[str, str]: + """Get environment variables for non-interactive mode. + + Returns: + Dict of environment variables to set + """ + if self.platform == "opencode": + return {"OPENCODE_NON_INTERACTIVE": "1"} + elif self.platform == "iflow": + return {"IFLOW_NON_INTERACTIVE": "1"} + elif self.platform == "codex": + return {"CODEX_NON_INTERACTIVE": "1"} + elif self.platform == "kiro": + return {"KIRO_NON_INTERACTIVE": "1"} + elif self.platform == "gemini": + return {} # Gemini CLI doesn't have a non-interactive env var + elif self.platform == "antigravity": + return {} + elif self.platform == "devin": + 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 {} + elif self.platform == "trae": + return {} + elif self.platform == "omp": + return {} + elif self.platform == "grok": + return {} + elif self.platform == "kimi": + return {} + else: + return {"CLAUDE_NON_INTERACTIVE": "1"} + + # ========================================================================= + # CLI Command Building + # ========================================================================= + + def build_run_command( + self, + agent: str, + prompt: str, + session_id: str | None = None, + skip_permissions: bool = True, + verbose: bool = True, + json_output: bool = True, + ) -> list[str]: + """Build CLI command for running an agent. + + Args: + agent: Agent name (will be mapped if needed) + prompt: Prompt to send to the agent + session_id: Optional session ID (Claude Code only for creation) + skip_permissions: Whether to skip permission prompts + verbose: Whether to enable verbose output + json_output: Whether to use JSON output format + + Returns: + List of command arguments + """ + mapped_agent = self.get_agent_name(agent) + + if self.platform == "opencode": + cmd = ["opencode", "run"] + cmd.extend(["--agent", mapped_agent]) + + # Note: OpenCode 'run' mode is non-interactive by default + # No equivalent to Claude Code's --dangerously-skip-permissions + # See: https://github.com/anomalyco/opencode/issues/9070 + + if json_output: + cmd.extend(["--format", "json"]) + + if verbose: + cmd.extend(["--log-level", "DEBUG", "--print-logs"]) + + # Note: OpenCode doesn't support --session-id on creation + # Session ID must be extracted from logs after startup + + cmd.append(prompt) + + elif self.platform == "iflow": + cmd = ["iflow", "-y", "-p"] + cmd.append(f"${mapped_agent} {prompt}") + elif self.platform == "codex": + cmd = ["codex", "exec"] + cmd.append(prompt) + elif self.platform == "kiro": + cmd = ["kiro", "run", prompt] + elif self.platform == "gemini": + cmd = ["gemini"] + cmd.append(prompt) + elif self.platform == "antigravity": + raise ValueError( + "Antigravity workflows are UI slash commands; CLI agent run is not supported." + ) + elif self.platform == "devin": + raise ValueError( + "Devin 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] + elif self.platform == "trae": + raise ValueError( + "Trae is IDE-only; CLI agent run is not supported." + ) + elif self.platform == "omp": + raise ValueError( + "OMP uses native task tool for agent runs; CLI agent run is not supported." + ) + elif self.platform == "grok": + # Headless single-prompt; sub-agents use in-process spawn_subagent. + cmd = ["grok", "-p", prompt, "--yolo"] + elif self.platform == "kimi": + # Headless single-prompt with auto-approval; sub-agents are the + # built-in coder/explore/plan agents dispatched in-session. + cmd = ["kimi", "-p", prompt, "--yolo"] + + else: # claude + cmd = ["claude", "-p"] + cmd.extend(["--agent", mapped_agent]) + + if session_id: + cmd.extend(["--session-id", session_id]) + + if skip_permissions: + cmd.append("--dangerously-skip-permissions") + + if json_output: + cmd.extend(["--output-format", "stream-json"]) + + if verbose: + cmd.append("--verbose") + + cmd.append(prompt) + + return cmd + + def build_resume_command(self, session_id: str) -> list[str]: + """Build CLI command for resuming a session. + + Args: + session_id: Session ID to resume (ignored for iFlow) + + Returns: + List of command arguments + """ + if self.platform == "opencode": + return ["opencode", "run", "--session", session_id] + elif self.platform == "iflow": + # iFlow uses -c to continue most recent conversation + # session_id is ignored as iFlow doesn't support session IDs + return ["iflow", "-c"] + elif self.platform == "codex": + return ["codex", "resume", session_id] + elif self.platform == "kiro": + return ["kiro", "resume", session_id] + elif self.platform == "gemini": + return ["gemini", "--resume", session_id] + elif self.platform == "antigravity": + raise ValueError( + "Antigravity workflows are UI slash commands; CLI resume is not supported." + ) + elif self.platform == "devin": + raise ValueError( + "Devin 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] + elif self.platform == "trae": + raise ValueError( + "Trae is IDE-only; CLI resume is not supported." + ) + elif self.platform == "omp": + raise ValueError( + "OMP uses native task tool for agent runs; CLI resume is not supported." + ) + elif self.platform == "grok": + return ["grok", "-c"] + elif self.platform == "kimi": + return ["kimi", "--session", session_id] + else: + return ["claude", "--resume", session_id] + + def get_resume_command_str(self, session_id: str, cwd: str | None = None) -> str: + """Get human-readable resume command string. + + Args: + session_id: Session ID to resume + cwd: Optional working directory to cd into + + Returns: + Command string for display + """ + cmd = self.build_resume_command(session_id) + cmd_str = " ".join(cmd) + + if cwd: + return f"cd {cwd} && {cmd_str}" + return cmd_str + + # ========================================================================= + # Platform Detection Helpers + # ========================================================================= + + @property + def is_opencode(self) -> bool: + """Check if platform is OpenCode.""" + return self.platform == "opencode" + + @property + def is_claude(self) -> bool: + """Check if platform is Claude Code.""" + return self.platform == "claude" + + @property + def is_cursor(self) -> bool: + """Check if platform is Cursor.""" + return self.platform == "cursor" + + @property + def is_iflow(self) -> bool: + """Check if platform is iFlow CLI.""" + return self.platform == "iflow" + + @property + def cli_name(self) -> str: + """Get CLI executable name. + + Note: Cursor doesn't have a CLI tool, returns None-like value. + """ + if self.is_opencode: + return "opencode" + elif self.is_cursor: + return "cursor" # Note: Cursor is IDE-only, no CLI + elif self.platform == "iflow": + return "iflow" + elif self.platform == "kiro": + return "kiro" + elif self.platform == "gemini": + return "gemini" + elif self.platform == "antigravity": + return "agy" + elif self.platform == "devin": + return "devin" + 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" + elif self.platform == "trae": + return "trae" + elif self.platform == "omp": + return "omp" + elif self.platform == "grok": + return "grok" + elif self.platform == "kimi": + return "kimi" + else: + return "claude" + + @property + def supports_cli_agents(self) -> bool: + """Check if platform supports running agents via CLI. + + Claude Code, OpenCode, iFlow, and Codex support CLI agent execution. + Cursor is IDE-only and doesn't support CLI agents. + """ + return self.platform in ( + "claude", + "opencode", + "iflow", + "codex", + "pi", + "grok", + "kimi", + ) + + @property + def requires_agent_definition_file(self) -> bool: + """Check if platform requires an agent definition file (.md/.toml) to run. + + Claude Code, OpenCode, iFlow: require agent .md files (--agent flag). + Codex: auto-discovers agents from .codex/agents/*.toml, no --agent flag. + """ + return self.platform in ("claude", "opencode", "iflow") + + # ========================================================================= + # Session ID Handling + # ========================================================================= + + @property + def supports_session_id_on_create(self) -> bool: + """Check if platform supports specifying session ID on creation. + + Claude Code: Yes (--session-id) + OpenCode: No (auto-generated, extract from logs) + iFlow: No (no session ID support) + """ + return self.platform == "claude" + + def extract_session_id_from_log(self, log_content: str) -> str | None: + """Extract session ID from log output (OpenCode only). + + OpenCode generates session IDs in format: ses_xxx + + Args: + log_content: Log file content + + Returns: + Session ID if found, None otherwise + """ + import re + + # OpenCode session ID pattern + match = re.search(r"ses_[a-zA-Z0-9]+", log_content) + if match: + return match.group(0) + return None + + +# ============================================================================= +# Factory Function +# ============================================================================= + + +def get_cli_adapter(platform: str = "claude") -> CLIAdapter: + """Get CLI adapter for the specified platform. + + Args: + platform: Platform name ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'devin', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', or 'trae') + + Returns: + CLIAdapter instance + + Raises: + ValueError: If platform is not supported + + Note: + 'windsurf' is accepted as a deprecated alias for 'devin' (Windsurf was + renamed to Devin) and normalized before validation. + """ + # Deprecated alias: Windsurf was renamed to Devin. + if platform == "windsurf": + platform = "devin" + if platform not in ( + "claude", + "opencode", + "cursor", + "iflow", + "codex", + "kilo", + "kiro", + "gemini", + "antigravity", + "devin", + "qoder", + "codebuddy", + "copilot", + "droid", + "pi", + "trae", + "omp", + "grok", + "kimi", + ): + raise ValueError( + f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'devin', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', 'trae', 'omp', 'grok', or 'kimi')" + ) + + return CLIAdapter(platform=platform) # type: ignore + + +_ALL_PLATFORM_CONFIG_DIRS = ( + ".claude", + ".cursor", + ".iflow", + ".opencode", + ".codex", + ".kilocode", + ".kiro", + ".gemini", + ".agent", + ".devin", + ".windsurf", # deprecated: pre-rename Devin config dir (still a platform signal) + ".qoder", + ".codebuddy", + ".github/copilot", + ".factory", + ".pi", + ".trae", + ".omp", + ".grok", + ".kimi-code", +) +"""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, Devin, 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. + + Detection order: + 1. TRELLIS_PLATFORM environment variable (if set) + 2. .opencode directory exists → opencode + 3. .iflow directory exists → iflow + 4. .cursor directory exists (without .claude) → cursor + 5. .gemini directory exists → gemini + 6. .codex exists and no other platform dirs → codex + 7. .kilocode directory exists → kilo + 8. .kiro/skills exists and no other platform dirs → kiro + 9. .agent/workflows exists and no other platform dirs → antigravity + 10. .devin/workflows (or legacy .windsurf/workflows) exists and no other platform dirs → devin + 11. .codebuddy directory exists → codebuddy + 12. .qoder directory exists → qoder + 13. .github/copilot directory exists → copilot + 14. .factory directory exists → droid + 15. .pi directory exists → pi + 16. .trae directory exists → trae + 17. Default → claude + + Args: + project_root: Project root directory + + Returns: + Detected platform ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'devin', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', 'trae', or default 'claude') + """ + import os + + # Check environment variable first + env_platform = os.environ.get("TRELLIS_PLATFORM", "").lower() + # Deprecated alias: Windsurf was renamed to Devin. + if env_platform == "windsurf": + env_platform = "devin" + if env_platform in ( + "claude", + "opencode", + "cursor", + "iflow", + "codex", + "kilo", + "kiro", + "gemini", + "antigravity", + "devin", + "qoder", + "codebuddy", + "copilot", + "droid", + "pi", + "trae", + "omp", + "grok", + "kimi", + ): + return env_platform # type: ignore + + # Check for .opencode directory (OpenCode-specific) + if (project_root / ".opencode").is_dir(): + return "opencode" + + # Check for .iflow directory (iFlow-specific) + if (project_root / ".iflow").is_dir(): + return "iflow" + + # Check for .cursor directory (Cursor-specific) + # Only detect as cursor if .claude doesn't exist (to avoid confusion) + if (project_root / ".cursor").is_dir() and not (project_root / ".claude").is_dir(): + return "cursor" + + # Check for .gemini directory (Gemini CLI-specific) + if (project_root / ".gemini").is_dir(): + return "gemini" + + # Check for .codex directory (Codex-specific) + # .agents/skills/ alone does NOT trigger codex detection (it's a shared standard) + if (project_root / ".codex").is_dir() and not _has_other_platform_dir( + project_root, {".codex", ".agents"} + ): + return "codex" + + # Check for .kilocode directory (Kilo-specific) + if (project_root / ".kilocode").is_dir(): + return "kilo" + + # Check for Kiro skills directory only when no other platform config exists + if (project_root / ".kiro" / "skills").is_dir() and not _has_other_platform_dir( + project_root, {".kiro"} + ): + return "kiro" + + # Check for Antigravity workflow directory only when no other platform config exists + if ( + project_root / ".agent" / "workflows" + ).is_dir() and not _has_other_platform_dir( + project_root, {".agent", ".gemini"} + ): + return "antigravity" + + # Check for Devin workflow directory only when no other platform config + # exists. `.windsurf/workflows` is the legacy pre-rename path (still detected + # as devin for back-compat until users migrate via `trellis update --migrate`). + if ( + (project_root / ".devin" / "workflows").is_dir() + or (project_root / ".windsurf" / "workflows").is_dir() + ) and not _has_other_platform_dir( + project_root, {".devin", ".windsurf"} + ): + return "devin" + + # 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" + + # Check for .trae directory (Trae IDE-specific) + if (project_root / ".trae").is_dir(): + return "trae" + + # Check for .omp directory (OMP-specific) + if (project_root / ".omp").is_dir(): + return "omp" + + # Check for .grok directory (Grok Build-specific) + if (project_root / ".grok").is_dir(): + return "grok" + + # Check for .kimi-code directory (Kimi Code-specific) + if (project_root / ".kimi-code").is_dir(): + return "kimi" + + # 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" + + +def get_cli_adapter_auto(project_root: Path) -> CLIAdapter: + """Get CLI adapter with auto-detected platform. + + Args: + project_root: Project root directory + + Returns: + CLIAdapter instance for detected platform + """ + platform = detect_platform(project_root) + return CLIAdapter(platform=platform) diff --git a/.trellis/scripts/common/config.py b/.trellis/scripts/common/config.py new file mode 100755 index 0000000..99f79b2 --- /dev/null +++ b/.trellis/scripts/common/config.py @@ -0,0 +1,569 @@ +#!/usr/bin/env python3 +""" +Trellis configuration reader. + +Reads settings from .trellis/config.yaml with sensible defaults. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from .paths import DIR_WORKFLOW, get_repo_root + + +# ============================================================================= +# 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 _strip_inline_comment(value: str) -> str: + """Strip ` # …` inline comments while preserving `#` inside quoted strings. + + YAML treats ` #` (space-hash) as a comment opener; bare `#` inside a token + is part of the value. Quoted strings are immune. + + Mirrors :func:`common.trellis_config._strip_inline_comment` so both + parsers handle ``key: value # comment`` identically. + """ + in_quote: str | None = None + for idx, ch in enumerate(value): + if in_quote: + if ch == in_quote: + in_quote = None + continue + if ch in ('"', "'"): + in_quote = ch + continue + if ch == "#" and (idx == 0 or value[idx - 1].isspace()): + return value[:idx] + return value + + +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 = _strip_inline_comment(value).strip() + was_quoted = len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'") + value = _unquote(value) + current_list = None + + if value or was_quoted: + # key: value (an explicit quoted "" is a value, not "no 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 +DEFAULT_SESSION_COMMIT_MESSAGE = "chore: record journal" +DEFAULT_MAX_JOURNAL_LINES = 2000 +DEFAULT_SESSION_AUTO_COMMIT = True +DEFAULT_CODEX_DISPATCH_MODE = "auto" + +CONFIG_FILE = "config.yaml" + + +def _is_true_config_value(value: object) -> bool: + """Return True when a config value represents an enabled flag.""" + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() == "true" + return False + + +def _get_config_path(repo_root: Path | None = None) -> Path: + """Get path to config.yaml.""" + root = repo_root or get_repo_root() + return root / DIR_WORKFLOW / CONFIG_FILE + + +def _load_config(repo_root: Path | None = None) -> dict: + """Load and parse config.yaml. Returns empty dict on any error.""" + config_file = _get_config_path(repo_root) + try: + content = config_file.read_text(encoding="utf-8") + return parse_simple_yaml(content) + except (OSError, IOError): + return {} + + +def get_session_commit_message(repo_root: Path | None = None) -> str: + """Get the commit message for auto-committing session records.""" + config = _load_config(repo_root) + return config.get("session_commit_message", DEFAULT_SESSION_COMMIT_MESSAGE) + + +def get_max_journal_lines(repo_root: Path | None = None) -> int: + """Get the maximum lines per journal file.""" + config = _load_config(repo_root) + value = config.get("max_journal_lines", DEFAULT_MAX_JOURNAL_LINES) + try: + return int(value) + except (ValueError, TypeError): + return DEFAULT_MAX_JOURNAL_LINES + + +def get_session_auto_commit(repo_root: Path | None = None) -> bool: + """Whether scripts should auto-stage + auto-commit session/task changes. + + Governs both ``add_session.py:_auto_commit_workspace`` and + ``task_store.py:_auto_commit_archive``. + + Default: ``True`` (existing behavior — auto-stage + auto-commit). + Set ``session_auto_commit: false`` in ``.trellis/config.yaml`` to skip + auto-staging entirely; the journal/archive files are still written to + disk, but the user manages ``git add`` / ``git commit`` themselves. + + Accepts native YAML booleans (``true`` / ``false``) and the string + aliases ``true / false / yes / no / 1 / 0 / on / off`` (case-insensitive). + Invalid values fall back to ``True`` with a stderr warning. + """ + config = _load_config(repo_root) + raw = config.get("session_auto_commit", DEFAULT_SESSION_AUTO_COMMIT) + if isinstance(raw, bool): + return raw + s = str(raw).strip().lower() + if s in ("true", "yes", "1", "on"): + return True + if s in ("false", "no", "0", "off"): + return False + print( + f"[WARN] invalid session_auto_commit value: {raw!r}; using true (default)", + file=sys.stderr, + ) + return DEFAULT_SESSION_AUTO_COMMIT + + +def get_codex_dispatch_mode(repo_root: Path | None = None) -> str: + """Return Codex dispatch mode. + + Default is ``auto``, which dispatches Trellis sub-agents and uses native + context injection with a child-side fallback. ``inline`` is an explicit + opt-out. ``sub-agent`` remains a backwards-compatible alias for ``auto``. + + Invalid explicit configuration falls back to ``inline`` rather than + unexpectedly dispatching a sub-agent. This CLI-facing parser is the only + place that emits a warning for invalid values; hook readers fail safely + without producing per-turn warning noise. + """ + config = _load_config(repo_root) + codex = config.get("codex") + if codex is None: + return DEFAULT_CODEX_DISPATCH_MODE + if not isinstance(codex, dict): + print( + f"[WARN] invalid codex config: {codex!r}; using inline", + file=sys.stderr, + ) + return "inline" + + raw = codex.get("dispatch_mode", DEFAULT_CODEX_DISPATCH_MODE) + mode = str(raw).strip().lower() + if mode in ("auto", "inline"): + return mode + if mode == "sub-agent": + return "auto" + print( + f"[WARN] invalid codex.dispatch_mode value: {raw!r}; using inline", + file=sys.stderr, + ) + return "inline" + + +DEFAULT_CONTEXT_INJECTION_MAX_FILE_BYTES = 32768 +DEFAULT_CONTEXT_INJECTION_MAX_ARTIFACT_BYTES = 65536 +DEFAULT_CONTEXT_INJECTION_MAX_TOTAL_BYTES = 131072 + + +def get_context_injection_limits(repo_root: Path | None = None) -> dict[str, int]: + """Return sub-agent context injection byte limits. + + Reads the ``context_injection:`` section of ``.trellis/config.yaml``: + + context_injection: + max_file_bytes: 32768 + max_artifact_bytes: 65536 + max_total_bytes: 131072 + + ``0`` disables the corresponding limit. Missing keys use their default; + invalid (non-int or negative) values fall back to the default for that + key with a stderr warning. + """ + defaults = { + "max_file_bytes": DEFAULT_CONTEXT_INJECTION_MAX_FILE_BYTES, + "max_artifact_bytes": DEFAULT_CONTEXT_INJECTION_MAX_ARTIFACT_BYTES, + "max_total_bytes": DEFAULT_CONTEXT_INJECTION_MAX_TOTAL_BYTES, + } + + config = _load_config(repo_root) + section = config.get("context_injection") + if not isinstance(section, dict): + return defaults + + result = dict(defaults) + for key, default_value in defaults.items(): + if key not in section: + continue + raw = section[key] + try: + value = int(raw) + except (TypeError, ValueError): + print( + f"[WARN] invalid context_injection.{key} value: {raw!r}; " + f"using default {default_value}", + file=sys.stderr, + ) + continue + if value < 0: + print( + f"[WARN] invalid context_injection.{key} value: {raw!r}; " + f"using default {default_value}", + file=sys.stderr, + ) + continue + result[key] = value + + return result + + +DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD = "no-trellis" + + +def get_prompt_injection_config(repo_root: Path | None = None) -> dict[str, str]: + """Return per-turn prompt injection config. + + Reads the ``prompt_injection:`` section of ``.trellis/config.yaml``: + + prompt_injection: + skip_keyword: "no-trellis" # "" disables the escape hatch entirely + + ``skip_keyword`` is the word-boundary, case-insensitive keyword that, when + present in the user's prompt, makes the per-turn workflow-state injection + emit nothing for that turn. Defaults to ``"no-trellis"``. A non-string + value falls back to the default. + """ + defaults = {"skip_keyword": DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD} + + config = _load_config(repo_root) + section = config.get("prompt_injection") + if not isinstance(section, dict): + return defaults + + result = dict(defaults) + raw = section.get("skip_keyword", DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD) + if isinstance(raw, str): + result["skip_keyword"] = raw + return result + + +def get_hooks(event: str, repo_root: Path | None = None) -> list[str]: + """Get hook commands for a lifecycle event. + + Args: + event: Event name (e.g. "after_create", "after_archive"). + repo_root: Repository root path. + + Returns: + List of shell commands to execute, empty if none configured. + """ + config = _load_config(repo_root) + hooks = config.get("hooks") + if not isinstance(hooks, dict): + return [] + commands = hooks.get(event) + if isinstance(commands, list): + return [str(c) for c in commands] + return [] + + +# ============================================================================= +# Monorepo / Packages +# ============================================================================= + + +def get_packages(repo_root: Path | None = None) -> dict[str, dict] | None: + """Get monorepo package declarations. + + Returns: + Dict mapping package name to its config (path, type, etc.), + or None if not configured (single-repo mode). + + Example return: + {"cli": {"path": "packages/cli"}, "docs-site": {"path": "docs-site", "type": "submodule"}} + """ + config = _load_config(repo_root) + packages = config.get("packages") + if not isinstance(packages, dict): + return None + # Ensure each value is a dict (filter out scalar entries) + filtered = {k: v for k, v in packages.items() if isinstance(v, dict)} + if not filtered: + return None + return filtered + + +def get_default_package(repo_root: Path | None = None) -> str | None: + """Get the default package name from config. + + Returns: + Package name string, or None if not configured. + """ + config = _load_config(repo_root) + value = config.get("default_package") + return str(value) if value else None + + +def get_submodule_packages(repo_root: Path | None = None) -> dict[str, str]: + """Get packages that are git submodules. + + Returns: + Dict mapping package name to its path for submodule-type packages. + Empty dict if none configured. + + Example return: + {"docs-site": "docs-site"} + """ + packages = get_packages(repo_root) + if packages is None: + return {} + return { + name: cfg.get("path", name) + for name, cfg in packages.items() + if cfg.get("type") == "submodule" + } + + +def get_git_packages(repo_root: Path | None = None) -> dict[str, str]: + """Get packages that have their own independent git repository. + + These are sub-directories with their own .git (not submodules), + marked with ``git: true`` in config.yaml. + + Returns: + Dict mapping package name to its path for git-repo packages. + Empty dict if none configured. + + Example config:: + + packages: + backend: + path: iqs + git: true + + Example return:: + + {"backend": "iqs"} + """ + packages = get_packages(repo_root) + if packages is None: + return {} + return { + name: cfg.get("path", name) + for name, cfg in packages.items() + if _is_true_config_value(cfg.get("git")) + } + + +def is_monorepo(repo_root: Path | None = None) -> bool: + """Check if the project is configured as a monorepo (has packages in config).""" + return get_packages(repo_root) is not None + + +def get_spec_base(package: str | None = None, repo_root: Path | None = None) -> str: + """Get the spec directory base path relative to .trellis/. + + Single-repo: returns "spec" + Monorepo with package: returns "spec/<package>" + Monorepo without package: returns "spec" (caller should specify package) + """ + if package and is_monorepo(repo_root): + return f"spec/{package}" + return "spec" + + +def validate_package(package: str, repo_root: Path | None = None) -> bool: + """Check if a package name is valid in this project. + + Single-repo (no packages configured): always returns True. + Monorepo: returns True only if package exists in config.yaml packages. + """ + packages = get_packages(repo_root) + if packages is None: + return True # Single-repo, no validation needed + return package in packages + + +def resolve_package( + task_package: str | None = None, + repo_root: Path | None = None, +) -> str | None: + """Resolve package from inferred sources with validation. + + Checks in order: task_package → default_package. + Invalid inferred values print a warning to stderr and are skipped. + + Returns: + Resolved package name, or None if no valid package found. + + Note: + CLI --package should be validated separately by the caller + (fail-fast with available packages list on error). + """ + packages = get_packages(repo_root) + if packages is None: + return None # Single-repo, no package needed + + # Try task_package (guard against non-string values from malformed JSON) + if task_package and isinstance(task_package, str): + if task_package in packages: + return task_package + print( + f"Warning: task.json package '{task_package}' not found in config, skipping", + file=sys.stderr, + ) + + # Try default_package + default = get_default_package(repo_root) + if default: + if default in packages: + return default + print( + f"Warning: default_package '{default}' not found in config, skipping", + file=sys.stderr, + ) + + return None + + +def get_spec_scope(repo_root: Path | None = None) -> list[str] | str | None: + """Get session.spec_scope configuration. + + Returns: + list[str]: Package names to include in spec scanning. + str: "active_task" to use current task's package. + None: No scope configured (scan all packages). + """ + config = _load_config(repo_root) + session = config.get("session") + if not isinstance(session, dict): + return None + + scope = session.get("spec_scope") + if scope is None: + return None + if isinstance(scope, str): + return scope # e.g. "active_task" + if isinstance(scope, list): + return [str(s) for s in scope] + return None diff --git a/.trellis/scripts/common/developer.py b/.trellis/scripts/common/developer.py new file mode 100755 index 0000000..c203a31 --- /dev/null +++ b/.trellis/scripts/common/developer.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +""" +Developer management utilities. + +Provides: + init_developer - Initialize developer + ensure_developer - Ensure developer is initialized (exit if not) + show_developer_info - Show developer information +""" + +from __future__ import annotations + +import sys +from datetime import datetime +from pathlib import Path + +from .paths import ( + DIR_WORKFLOW, + DIR_WORKSPACE, + DIR_TASKS, + FILE_DEVELOPER, + FILE_JOURNAL_PREFIX, + get_repo_root, + get_developer, + check_developer, +) + + +# ============================================================================= +# Developer Initialization +# ============================================================================= + +def init_developer(name: str, repo_root: Path | None = None) -> bool: + """Initialize developer. + + Creates: + - .trellis/.developer file with developer info + - .trellis/workspace/<name>/ directory structure + - Initial journal file and index.md + + Args: + name: Developer name. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True on success, False on error. + """ + if not name: + print("Error: developer name is required", file=sys.stderr) + return False + + if repo_root is None: + repo_root = get_repo_root() + + dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER + workspace_dir = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / name + + # Create .developer file + initialized_at = datetime.now().isoformat() + try: + dev_file.write_text( + f"name={name}\ninitialized_at={initialized_at}\n", + encoding="utf-8" + ) + except (OSError, IOError) as e: + print(f"Error: Failed to create .developer file: {e}", file=sys.stderr) + return False + + # Create workspace directory structure + try: + workspace_dir.mkdir(parents=True, exist_ok=True) + except (OSError, IOError) as e: + print(f"Error: Failed to create workspace directory: {e}", file=sys.stderr) + return False + + # Create initial journal file + journal_file = workspace_dir / f"{FILE_JOURNAL_PREFIX}1.md" + if not journal_file.exists(): + today = datetime.now().strftime("%Y-%m-%d") + journal_content = f"""# Journal - {name} (Part 1) + +> AI development session journal +> Started: {today} + +--- + +""" + try: + journal_file.write_text(journal_content, encoding="utf-8") + except (OSError, IOError) as e: + print(f"Error: Failed to create journal file: {e}", file=sys.stderr) + return False + + # Create index.md with markers for auto-update + index_file = workspace_dir / "index.md" + if not index_file.exists(): + index_content = f"""# Workspace Index - {name} + +> Journal tracking for AI development sessions. + +--- + +## Current Status + +<!-- @@@auto:current-status --> +- **Active File**: `journal-1.md` +- **Total Sessions**: 0 +- **Last Active**: - +<!-- @@@/auto:current-status --> + +--- + +## Active Documents + +<!-- @@@auto:active-documents --> +| File | Lines | Status | +|------|-------|--------| +| `journal-1.md` | ~0 | Active | +<!-- @@@/auto:active-documents --> + +--- + +## Session History + +<!-- @@@auto:session-history --> +| # | Date | Title | Commits | Branch | +|---|------|-------|---------|--------| +<!-- @@@/auto:session-history --> + +--- + +## Notes + +- Sessions are appended to journal files +- New journal file created when current exceeds 2000 lines +- Use `add_session.py` to record sessions +""" + try: + index_file.write_text(index_content, encoding="utf-8") + except (OSError, IOError) as e: + print(f"Error: Failed to create index.md: {e}", file=sys.stderr) + return False + + print(f"Developer initialized: {name}") + print(f" .developer file: {dev_file}") + print(f" Workspace dir: {workspace_dir}") + + return True + + +def ensure_developer(repo_root: Path | None = None) -> None: + """Ensure developer is initialized, exit if not. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + """ + if repo_root is None: + repo_root = get_repo_root() + + if not check_developer(repo_root): + print("Error: Developer not initialized.", file=sys.stderr) + print(f"Run: python3 ./{DIR_WORKFLOW}/scripts/init_developer.py <your-name>", file=sys.stderr) + sys.exit(1) + + +def show_developer_info(repo_root: Path | None = None) -> None: + """Show developer information. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + """ + if repo_root is None: + repo_root = get_repo_root() + + developer = get_developer(repo_root) + + if not developer: + print("Developer: (not initialized)") + else: + print(f"Developer: {developer}") + print(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/") + print(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/") + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + show_developer_info() diff --git a/.trellis/scripts/common/git.py b/.trellis/scripts/common/git.py new file mode 100755 index 0000000..426836c --- /dev/null +++ b/.trellis/scripts/common/git.py @@ -0,0 +1,74 @@ +""" +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, + timeout: float | 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). Callers + may provide a timeout for best-effort probes; normal Git operations remain + unbounded by default. + """ + 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", + timeout=timeout, + ) + return result.returncode, result.stdout, result.stderr + except Exception as e: + return 1, "", str(e) + + +def resolve_default_branch(repo_root: Path) -> str | None: + """Resolve the repository's default branch (origin/HEAD target). + + Tries the local `refs/remotes/origin/HEAD` symbolic ref first (no + network access), then falls back to `git remote show origin` (which + may hit the network but also repairs a missing/stale symbolic-ref). + Returns None when neither resolves, so callers can fall back to their + own pre-existing behavior. + """ + rc, out, _ = run_git(["symbolic-ref", "refs/remotes/origin/HEAD"], cwd=repo_root) + if rc == 0 and out.strip(): + return out.strip().rsplit("/", 1)[-1] + + rc, out, _ = run_git(["remote", "show", "origin"], cwd=repo_root) + if rc == 0: + for line in out.splitlines(): + line = line.strip() + if line.startswith("HEAD branch:"): + branch = line.split(":", 1)[1].strip() + if branch and branch != "(unknown)": + return branch + + return None + + +def branch_exists_locally(branch: str, repo_root: Path) -> bool: + """Check whether a local branch ref exists in the repository.""" + if not branch: + return False + rc, _, _ = run_git( + ["rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"], + cwd=repo_root, + ) + return rc == 0 diff --git a/.trellis/scripts/common/git_context.py b/.trellis/scripts/common/git_context.py new file mode 100755 index 0000000..23fc6ec --- /dev/null +++ b/.trellis/scripts/common/git_context.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Git and Session Context utilities. + +Entry shim — delegates to session_context and packages_context. + +Provides: + output_json - Output context in JSON format + output_text - Output context in text format +""" + +from __future__ import annotations + +import json + +from .git import run_git +from .session_context import ( + get_context_json, + get_context_text, + get_context_record_json, + get_context_text_record, + output_json, + output_text, +) +from .packages_context import ( + get_context_packages_text, + get_context_packages_json, +) +from .trellis_config import read_trellis_config +from .workflow_phase import ( + filter_platform, + get_phase_index, + get_step, + resolve_effective_platform, +) + +# Backward-compatible alias — external modules import this name +_run_git_command = run_git + + +# ============================================================================= +# Main Entry +# ============================================================================= + +def main() -> None: + """CLI entry point.""" + import argparse + + parser = argparse.ArgumentParser(description="Get Session Context for AI Agent") + parser.add_argument( + "--json", + "-j", + action="store_true", + help="Output in JSON format (works with any --mode)", + ) + parser.add_argument( + "--mode", + "-m", + choices=["default", "record", "packages", "phase"], + default="default", + 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() + + if args.mode == "record": + if args.json: + print(json.dumps(get_context_record_json(), indent=2, ensure_ascii=False)) + else: + print(get_context_text_record()) + elif args.mode == "packages": + if args.json: + print(json.dumps(get_context_packages_json(), indent=2, ensure_ascii=False)) + else: + print(get_context_packages_text()) + 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: + effective = resolve_effective_platform( + args.platform, read_trellis_config() + ) + content = filter_platform(content, effective) + print(content, end="") + else: + if args.json: + output_json() + else: + output_text() + + +if __name__ == "__main__": + main() diff --git a/.trellis/scripts/common/io.py b/.trellis/scripts/common/io.py new file mode 100755 index 0000000..c918c1f --- /dev/null +++ b/.trellis/scripts/common/io.py @@ -0,0 +1,61 @@ +""" +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 +import os +import tempfile +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. + + The write is atomic: content goes to a temp file in the same directory + and is then renamed over the target. A crash or Ctrl-C mid-write leaves + the existing file intact rather than truncated, so a corrupted task.json + can never make a task silently vanish from `task.py list`. + + Returns True on success, False on error. + """ + payload = json.dumps(data, indent=2, ensure_ascii=False) + try: + fd, tmp = tempfile.mkstemp( + dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp" + ) + except OSError: + return False + + try: + try: + f = os.fdopen(fd, "w", encoding="utf-8") + except OSError: + # fdopen never took ownership of fd; close it ourselves. + os.close(fd) + raise + with f: + f.write(payload) + os.replace(tmp, path) + return True + except OSError: + try: + os.unlink(tmp) + except OSError: + pass + 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 new file mode 100755 index 0000000..1c5a58e --- /dev/null +++ b/.trellis/scripts/common/paths.py @@ -0,0 +1,447 @@ +#!/usr/bin/env python3 +""" +Common path utilities for Trellis workflow. + +Provides: + get_repo_root - Get repository root directory + get_developer - Get developer name + get_workspace_dir - Get developer workspace directory + get_tasks_dir - Get tasks directory + get_active_journal_file - Get current journal file +""" + +from __future__ import annotations + +import re +from datetime import datetime +from pathlib import Path + + +# ============================================================================= +# Path Constants (change here to rename directories) +# ============================================================================= + +# Directory names +DIR_WORKFLOW = ".trellis" +DIR_WORKSPACE = "workspace" +DIR_TASKS = "tasks" +DIR_ARCHIVE = "archive" +DIR_SPEC = "spec" +DIR_SCRIPTS = "scripts" + +# File names +FILE_DEVELOPER = ".developer" +FILE_CURRENT_TASK = ".current-task" +FILE_TASK_JSON = "task.json" +FILE_JOURNAL_PREFIX = "journal-" + + +# ============================================================================= +# Repository Root +# ============================================================================= + +def get_repo_root(start_path: Path | None = None) -> Path: + """Find the nearest directory containing .trellis/ folder. + + This handles nested git repos correctly (e.g., test project inside another repo). + + Args: + start_path: Starting directory to search from. Defaults to current directory. + + Returns: + Path to repository root, or current directory if no .trellis/ found. + """ + current = (start_path or Path.cwd()).resolve() + + while current != current.parent: + if (current / DIR_WORKFLOW).is_dir(): + return current + current = current.parent + + # Fallback to current directory if no .trellis/ found + return Path.cwd().resolve() + + +# ============================================================================= +# Developer +# ============================================================================= + +def get_developer(repo_root: Path | None = None) -> str | None: + """Get developer name from .developer file. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Developer name or None if not initialized. + """ + if repo_root is None: + repo_root = get_repo_root() + + dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER + + if not dev_file.is_file(): + return None + + try: + content = dev_file.read_text(encoding="utf-8") + for line in content.splitlines(): + if line.startswith("name="): + return line.split("=", 1)[1].strip() + except (OSError, IOError): + pass + + return None + + +def check_developer(repo_root: Path | None = None) -> bool: + """Check if developer is initialized. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True if developer is initialized. + """ + return get_developer(repo_root) is not None + + +# ============================================================================= +# Tasks Directory +# ============================================================================= + +def get_tasks_dir(repo_root: Path | None = None) -> Path: + """Get tasks directory path. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Path to tasks directory. + """ + if repo_root is None: + repo_root = get_repo_root() + return repo_root / DIR_WORKFLOW / DIR_TASKS + + +# ============================================================================= +# Workspace Directory +# ============================================================================= + +def get_workspace_dir(repo_root: Path | None = None) -> Path | None: + """Get developer workspace directory. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Path to workspace directory or None if developer not set. + """ + if repo_root is None: + repo_root = get_repo_root() + + developer = get_developer(repo_root) + if developer: + return repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer + return None + + +# ============================================================================= +# Journal File +# ============================================================================= + +def get_active_journal_file(repo_root: Path | None = None) -> Path | None: + """Get the current active journal file. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Path to active journal file or None if not found. + """ + if repo_root is None: + repo_root = get_repo_root() + + workspace_dir = get_workspace_dir(repo_root) + if workspace_dir is None or not workspace_dir.is_dir(): + return None + + latest: Path | None = None + highest = 0 + + for f in workspace_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"): + if not f.is_file(): + continue + + # Extract number from filename + name = f.stem # e.g., "journal-1" + match = re.search(r"(\d+)$", name) + if match: + num = int(match.group(1)) + if num > highest: + highest = num + latest = f + + return latest + + +def count_lines(file_path: Path) -> int: + """Count lines in a file. + + Args: + file_path: Path to file. + + Returns: + Number of lines, or 0 if file doesn't exist. + """ + if not file_path.is_file(): + return 0 + + try: + return len(file_path.read_text(encoding="utf-8").splitlines()) + except (OSError, IOError): + return 0 + + +# ============================================================================= +# Current Task Management +# ============================================================================= + +def normalize_task_ref(task_ref: str) -> str: + """Normalize a task ref for stable runtime storage. + + 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() + + 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, + platform_input: dict | None = None, + platform: str | None = None, +) -> str | None: + """Get current task directory path (relative to repo_root). + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Relative path to current task directory or None. + """ + if repo_root is None: + repo_root = get_repo_root() + + from .active_task import resolve_active_task + + return resolve_active_task(repo_root, platform_input, platform).task_path + + +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: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Absolute path to current task directory or None. + """ + if repo_root is None: + repo_root = get_repo_root() + + relative = get_current_task(repo_root, platform_input, platform) + if relative: + return resolve_task_ref(relative, repo_root) + return None + + +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). + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True on success, False on error. + """ + if repo_root is None: + repo_root = get_repo_root() + + from .active_task import set_active_task + + 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, + 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. + + Returns: + True on success. + """ + if repo_root is None: + repo_root = get_repo_root() + + 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: + """Check if has current task. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True if current task is set. + """ + return get_current_task(repo_root) is not None + + +# ============================================================================= +# Task ID Generation +# ============================================================================= + +def generate_task_date_prefix() -> str: + """Generate task ID based on date (MM-DD format). + + Returns: + Date prefix string (e.g., "01-21"). + """ + return datetime.now().strftime("%m-%d") + + +# ============================================================================= +# Monorepo / Package Paths +# ============================================================================= + + +def get_spec_dir(package: str | None = None, repo_root: Path | None = None) -> Path: + """Get the spec directory path. + + Single-repo: .trellis/spec + Monorepo with package: .trellis/spec/<package> + + Uses lazy import to avoid circular dependency with config.py. + """ + if repo_root is None: + repo_root = get_repo_root() + + from .config import get_spec_base + + base = get_spec_base(package, repo_root) + return repo_root / DIR_WORKFLOW / base + + +def get_package_path(package: str, repo_root: Path | None = None) -> Path | None: + """Get a package's source directory absolute path from config. + + Returns: + Absolute path to the package directory, or None if not found. + """ + if repo_root is None: + repo_root = get_repo_root() + + from .config import get_packages + + packages = get_packages(repo_root) + if not packages or package not in packages: + return None + + info = packages[package] + if isinstance(info, dict): + rel_path = info.get("path", package) + else: + rel_path = str(info) + + return repo_root / rel_path + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + repo = get_repo_root() + print(f"Repository root: {repo}") + print(f"Developer: {get_developer(repo)}") + print(f"Tasks dir: {get_tasks_dir(repo)}") + print(f"Workspace dir: {get_workspace_dir(repo)}") + print(f"Journal file: {get_active_journal_file(repo)}") + print(f"Current task: {get_current_task(repo)}") diff --git a/.trellis/scripts/common/safe_commit.py b/.trellis/scripts/common/safe_commit.py new file mode 100755 index 0000000..1a1cba2 --- /dev/null +++ b/.trellis/scripts/common/safe_commit.py @@ -0,0 +1,315 @@ +""" +Safe git-add helpers for Trellis-owned paths. + +Why this module exists +---------------------- +A real user incident: a project's `.gitignore` listed `.trellis/` (company-wide +template / personal habit). When `add_session.py` and `task.py archive` ran +their auto-commit and `git add` failed with `ignored by .gitignore`, the AI +agent driving the workflow "fixed" it by retrying with +`git add -f .trellis/` — which fan-out-included every ignored subtree +(`.trellis/.backup-*/`, `.trellis/worktrees/`, `.trellis/.template-hashes.json`, +`.trellis/.runtime/`), committing 548 files / 83474 lines of caches/backups. + +Design +------ +- Scripts only stage SPECIFIC product paths (journal files, index.md, the + current task dir, the archive dir). Never the whole `.trellis/` tree. +- If plain `git add <specific>` fails with "ignored by", DO NOT retry with + ``-f``. The presence of `.trellis/` in `.gitignore` is treated as user + intent ("keep .trellis/ local-only"). The script warns and skips the + auto-commit; users who want auto-staging can either fix their `.gitignore` + or set ``session_auto_commit: false`` and manage git themselves. +- The warning includes a negative example: ``Do NOT use `git add -f .trellis/` ...`` + so any AI rereading the log doesn't reinvent the bug. + +History note: 0.5.10 introduced an automatic ``git add -f`` retry on the +specific paths. That was reverted in 0.5.11 — auto-forcing into a tree the +user had gitignored violates user intent even when the path list is narrow. +The wider-grain forbidden command stays forbidden, and the narrow-grain auto +``-f`` is gone too. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from .git import run_git +from .paths import ( + DIR_ARCHIVE, + DIR_TASKS, + DIR_WORKFLOW, + DIR_WORKSPACE, + FILE_JOURNAL_PREFIX, + get_developer, +) + + +# Paths under .trellis/ that must NEVER be auto-staged. Listed here so the +# warning to the user can show concrete subpaths to ignore individually +# instead of ignoring the whole `.trellis/` tree. +TRELLIS_IGNORED_SUBPATHS = ( + ".trellis/.backup-*", + ".trellis/worktrees/", + ".trellis/.template-hashes.json", + ".trellis/.runtime/", + ".trellis/.cache/", +) + + +def safe_trellis_paths_to_add( + repo_root: Path, + task_name: str | None = None, +) -> list[str]: + """Return the list of repo-relative paths the auto-commit should stage. + + Only includes paths that exist on disk so callers don't pass non-existent + arguments to git. The caller is responsible for `git diff --cached` + checking afterwards. + + Included: + - .trellis/workspace/<developer>/journal-*.md + - .trellis/workspace/<developer>/index.md + - .trellis/tasks/<task_name>/ (ONLY the current task dir when + ``task_name`` is passed; plus its archive location if the task + already lives under archive/) + + Excluded (intentionally — these must not be staged): + - .trellis/.backup-*, .trellis/worktrees/, + .trellis/.template-hashes.json, .trellis/.runtime/, .trellis/.cache/ + + Scope contract (see #303 / break-loop analysis): when ``task_name`` is + passed, the task segment stages ONLY that task directory — it never walks + ``tasks_dir.iterdir()`` over all active tasks. This mirrors + :func:`safe_archive_paths_to_add` and prevents dirty changes in OTHER + parallel-window task dirs from being bundled into the session auto-commit. + + Backwards-compat: with no ``task_name``, the function walks every active + task directory (+ the archive subtree) the old wide way. New callers + should always pass ``task_name``. + """ + paths: list[str] = [] + + # Workspace journal files + index.md + developer = get_developer(repo_root) + if developer: + ws = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer + if ws.is_dir(): + for f in sorted(ws.glob(f"{FILE_JOURNAL_PREFIX}*.md")): + if f.is_file(): + paths.append( + f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{f.name}" + ) + index_md = ws / "index.md" + if index_md.is_file(): + paths.append( + f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/index.md" + ) + + tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS + if not tasks_dir.is_dir(): + return paths + + if task_name is not None: + # Narrow scope — ONLY the current task directory (active or archived). + # Never iterdir() all tasks: parallel-window dirty task dirs must not + # leak into the session auto-commit. + active_task = tasks_dir / task_name + if active_task.is_dir(): + paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}") + archived_task = tasks_dir / DIR_ARCHIVE / task_name + if archived_task.is_dir(): + paths.append( + f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{task_name}" + ) + return paths + + # Legacy wide scope (no task_name): each direct child of tasks/ that is a + # directory and not the archive root, plus the whole archive subtree. + for child in sorted(tasks_dir.iterdir()): + if not child.is_dir(): + continue + if child.name == DIR_ARCHIVE: + continue + paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child.name}") + + archive_dir = tasks_dir / DIR_ARCHIVE + if archive_dir.is_dir(): + paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}") + + return paths + + +def safe_archive_paths_to_add( + repo_root: Path, + task_name: str | None = None, + modified_children: list[str] | None = None, +) -> list[str]: + """Return paths to stage after `task.py archive`. + + Scoped to ONLY the paths the archive operation actually touched: + + - the archive subtree (where the freshly-moved task lives) + - the source task directory (for source-side deletes; caller pairs + this with `git rm --cached` since `git add` won't stage deletes + for a path that no longer exists in the working tree) + - any child task directories whose `task.json` was edited to drop + the archived parent (parent-children relationship update) + + This narrow scope avoids "scope creep" — dirty changes in OTHER + active task dirs (parallel-window edits) are NOT bundled into the + archive commit. Callers handle each kind of change in its own + commit boundary. + + Backwards-compat: with no arguments, the function walks the whole + `.trellis/tasks/` subtree the old way (active tasks + archive). New + callers should always pass `task_name`. + """ + paths: list[str] = [] + tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS + if not tasks_dir.is_dir(): + return paths + + archive_dir = tasks_dir / DIR_ARCHIVE + + if task_name is not None: + # Narrow scope — only paths that still exist on disk (so + # `git add` doesn't choke on the moved-away source). The caller + # handles the source-side deletes via `git rm --cached` + # explicitly. + if archive_dir.is_dir(): + paths.append( + f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}" + ) + for child_name in modified_children or []: + paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child_name}") + return paths + + # Legacy wide scope (no task_name): preserve old behavior so callers + # that have not been updated keep working. + if archive_dir.is_dir(): + paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}") + for child in sorted(tasks_dir.iterdir()): + if not child.is_dir(): + continue + if child.name == DIR_ARCHIVE: + continue + paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child.name}") + return paths + + +def _stderr_indicates_ignored(stderr: str) -> bool: + """git add error indicates the path is excluded by .gitignore.""" + if not stderr: + return False + lowered = stderr.lower() + return "ignored by" in lowered + + +def safe_git_add( + paths: list[str], repo_root: Path +) -> tuple[bool, bool, str]: + """Run `git add` on specific paths; never retry with -f. + + Returns ``(success, used_force, stderr)``. The ``used_force`` field is + kept for signature compatibility with the 0.5.10 implementation but is + always ``False`` — we never auto-force. + + Behavior: + - No paths passed → success, no force, empty stderr. + - Plain ``git add -- <paths>`` succeeds → return success. + - Plain fails (any reason — ignored or otherwise) → return failure with + the stderr. Callers should inspect the stderr (see + :func:`print_gitignore_warning`) and skip the auto-commit. + """ + if not paths: + return True, False, "" + + rc, _, err = run_git(["add", "--", *paths], cwd=repo_root) + if rc == 0: + return True, False, "" + return False, False, err + + +def print_gitignore_warning(paths: list[str]) -> None: + """Explain to the user (and any AI reading the log) what to do. + + CRITICAL: includes the negative example + ``Do NOT use `git add -f .trellis/``` — agents reading the warning are + known to invent that command, which fans out to ignored caches/backups. + """ + print( + "[WARN] git add failed because .trellis/ paths are ignored by your .gitignore.", + file=sys.stderr, + ) + print( + "[WARN] Skipping auto-commit. The journal/task files were still written to disk;", + file=sys.stderr, + ) + print( + "[WARN] git was not touched.", + file=sys.stderr, + ) + print("[WARN]", file=sys.stderr) + print( + "[WARN] Trellis manages these specific paths and they should be tracked:", + file=sys.stderr, + ) + if paths: + for p in paths: + print(f"[WARN] {p}", file=sys.stderr) + else: + print( + "[WARN] .trellis/workspace/<developer>/{journal-*.md,index.md}", + file=sys.stderr, + ) + print( + "[WARN] .trellis/tasks/<task-dir>/", + file=sys.stderr, + ) + print( + "[WARN] .trellis/tasks/archive/", + file=sys.stderr, + ) + print("[WARN]", file=sys.stderr) + print( + "[WARN] Recommended: change your .gitignore from `.trellis/` to specific", + file=sys.stderr, + ) + print( + "[WARN] subpaths that should remain ignored, e.g.:", + file=sys.stderr, + ) + for sub in TRELLIS_IGNORED_SUBPATHS: + print(f"[WARN] {sub}", file=sys.stderr) + print("[WARN]", file=sys.stderr) + print( + "[WARN] Or, if you intentionally keep .trellis/ local-only, set in", + file=sys.stderr, + ) + print( + "[WARN] .trellis/config.yaml:", + file=sys.stderr, + ) + print( + "[WARN] session_auto_commit: false", + file=sys.stderr, + ) + print( + "[WARN] so the scripts skip git entirely and you can review / commit", + file=sys.stderr, + ) + print( + "[WARN] manually with `git status` / `git add` / `git commit`.", + file=sys.stderr, + ) + print("[WARN]", file=sys.stderr) + print( + "[WARN] Do NOT use `git add -f .trellis/` — it pulls in backups, worktrees,", + file=sys.stderr, + ) + print( + "[WARN] and runtime caches that should never be committed.", + file=sys.stderr, + ) diff --git a/.trellis/scripts/common/session_context.py b/.trellis/scripts/common/session_context.py new file mode 100755 index 0000000..8f1fd1c --- /dev/null +++ b/.trellis/scripts/common/session_context.py @@ -0,0 +1,874 @@ +#!/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 +import os +import re +import subprocess +import sys +from pathlib import Path + +from .active_task import resolve_context_key +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 +# ============================================================================= + +_PACKAGE_NAME = "@mindfoldhq/trellis" +_UPDATE_CHECK_TIMEOUT_SECONDS = 1.0 +_VERSION_RE = re.compile( + r"^\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?\s*$" +) +_VERSION_TOKEN_RE = re.compile(r"\b\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?\b") +_POLYREPO_IGNORED_DIRS = { + "node_modules", + "target", + "dist", + "build", + "out", + "bin", + "obj", + "vendor", + "coverage", + "tmp", + "__pycache__", +} +_POLYREPO_SCAN_MAX_DEPTH = 2 +_POLYREPO_SCAN_MAX_REPOS = 8 +_GIT_PROBE_TIMEOUT_SECONDS = 2.0 + + +def _is_git_worktree(path: Path) -> bool: + """Return True when path is inside a Git worktree.""" + rc, out, _ = run_git( + ["rev-parse", "--is-inside-work-tree"], + cwd=path, + timeout=_GIT_PROBE_TIMEOUT_SECONDS, + ) + return rc == 0 and out.strip().lower() == "true" + + +def _parse_recent_commits(log_output: str) -> list[dict]: + """Parse `git log --oneline` output into structured commit entries.""" + commits = [] + for line in log_output.splitlines(): + if not line.strip(): + continue + 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": ""}) + return commits + + +def _collect_git_repo_info(name: str, rel_path: str, repo_dir: Path) -> dict | None: + """Collect Git status for one known repository directory.""" + if not (repo_dir / ".git").exists(): + return None + + status_rc, status_out, _ = run_git( + ["status", "--porcelain"], + cwd=repo_dir, + timeout=_GIT_PROBE_TIMEOUT_SECONDS, + ) + if status_rc != 0: + return None + changes = len([line for line in status_out.splitlines() if line.strip()]) + + _, branch_out, _ = run_git( + ["branch", "--show-current"], + cwd=repo_dir, + timeout=_GIT_PROBE_TIMEOUT_SECONDS, + ) + branch = branch_out.strip() or "unknown" + + _, log_out, _ = run_git( + ["log", "--oneline", "-5"], + cwd=repo_dir, + timeout=_GIT_PROBE_TIMEOUT_SECONDS, + ) + + return { + "name": name, + "path": rel_path, + "branch": branch, + "isClean": changes == 0, + "uncommittedChanges": changes, + "recentCommits": _parse_recent_commits(log_out), + } + + +def _collect_root_git_info(repo_root: Path) -> dict: + """Collect root Git info without pretending a non-Git root is clean.""" + if not _is_git_worktree(repo_root): + return { + "isRepo": False, + "branch": "", + "isClean": False, + "uncommittedChanges": 0, + "recentCommits": [], + } + + _, branch_out, _ = run_git( + ["branch", "--show-current"], + cwd=repo_root, + timeout=_GIT_PROBE_TIMEOUT_SECONDS, + ) + branch = branch_out.strip() or "unknown" + + status_rc, status_out, _ = run_git( + ["status", "--porcelain"], + cwd=repo_root, + timeout=_GIT_PROBE_TIMEOUT_SECONDS, + ) + status_lines = [line for line in status_out.splitlines() if line.strip()] + + _, short_out, _ = run_git( + ["status", "--short"], + cwd=repo_root, + timeout=_GIT_PROBE_TIMEOUT_SECONDS, + ) + + _, log_out, _ = run_git( + ["log", "--oneline", "-5"], + cwd=repo_root, + timeout=_GIT_PROBE_TIMEOUT_SECONDS, + ) + + return { + "isRepo": True, + "branch": branch, + "isClean": status_rc == 0 and len(status_lines) == 0, + "uncommittedChanges": len(status_lines), + "statusShort": short_out.splitlines(), + "recentCommits": _parse_recent_commits(log_out), + } + + +def _discover_child_git_repos(repo_root: Path) -> list[tuple[str, str]]: + """Discover child Git repositories using the init-time polyrepo heuristic.""" + found: list[str] = [] + overflow = False + + def is_candidate_dir(path: Path) -> bool: + name = path.name + return not name.startswith(".") and name not in _POLYREPO_IGNORED_DIRS + + def scan(rel_dir: Path, depth: int) -> None: + nonlocal overflow + if overflow: + return + if depth >= _POLYREPO_SCAN_MAX_DEPTH: + return + abs_dir = repo_root / rel_dir + try: + children = sorted(abs_dir.iterdir(), key=lambda p: p.name) + except OSError: + return + + for child in children: + if not child.is_dir() or not is_candidate_dir(child): + continue + + child_rel = ( + rel_dir / child.name if rel_dir != Path(".") else Path(child.name) + ) + if (child / ".git").exists(): + if len(found) >= _POLYREPO_SCAN_MAX_REPOS: + overflow = True + return + found.append(child_rel.as_posix()) + continue + scan(child_rel, depth + 1) + + scan(Path("."), 0) + if overflow: + print( + "warning: found more than " + f"{_POLYREPO_SCAN_MAX_REPOS} child Git repositories; " + "skipping automatic Git status collection. Configure explicit " + "packages entries with path and git: true in .trellis/config.yaml.", + file=sys.stderr, + ) + return [] + if len(found) < 2: + return [] + return [(path.replace("/", "_"), path) for path in sorted(found)] + + +def _collect_package_git_info( + repo_root: Path, + discover_unconfigured: bool = False, +) -> list[dict]: + """Collect Git status for independent package repositories. + + Packages marked with ``git: true`` in config.yaml are authoritative. + When the Trellis root is not a Git repo and no configured package repos are + available, optionally fall back to the bounded polyrepo child scan. + + 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) + result = [] + for pkg_name, pkg_path in git_pkgs.items(): + pkg_dir = repo_root / pkg_path + info = _collect_git_repo_info(pkg_name, pkg_path, pkg_dir) + if info is not None: + result.append(info) + + if result or not discover_unconfigured: + return result + + discovered = [] + for pkg_name, pkg_path in _discover_child_git_repos(repo_root): + info = _collect_git_repo_info(pkg_name, pkg_path, repo_root / pkg_path) + if info is not None: + discovered.append(info) + return discovered + + +def _append_root_git_context(lines: list[str], root_git_info: dict) -> None: + """Append root Git status without misleading non-Git roots.""" + lines.append("## GIT STATUS") + if not root_git_info["isRepo"]: + lines.append("Root is not a Git repository.") + lines.append("Run Git commands from the package repository paths listed below.") + else: + lines.append(f"Branch: {root_git_info['branch']}") + if root_git_info["isClean"]: + lines.append("Working directory: Clean") + else: + lines.append( + f"Working directory: {root_git_info['uncommittedChanges']} " + "uncommitted change(s)" + ) + lines.append("") + lines.append("Changes:") + for line in root_git_info.get("statusShort", [])[:10]: + lines.append(line) + lines.append("") + + lines.append("## RECENT COMMITS") + if not root_git_info["isRepo"]: + lines.append( + "Root has no Git commit history because it is not a Git repository." + ) + elif root_git_info["recentCommits"]: + for commit in root_git_info["recentCommits"]: + lines.append(f"{commit['hash']} {commit['message']}") + else: + lines.append("(no commits)") + lines.append("") + + +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("") + + +def _read_project_version(repo_root: Path) -> str | None: + try: + version = (repo_root / DIR_WORKFLOW / ".version").read_text( + encoding="utf-8" + ).strip() + except OSError: + return None + return version or None + + +def _fetch_trellis_version_output() -> str | None: + try: + result = subprocess.run( + ["trellis", "--version"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=_UPDATE_CHECK_TIMEOUT_SECONDS, + ) + except (OSError, subprocess.SubprocessError, TimeoutError): + return None + + if result.returncode != 0: + return None + output = f"{result.stdout}\n{result.stderr}".strip() + return output or None + + +def _extract_available_update_version(output: str) -> str | None: + update_match = re.search( + r"Trellis update available:\s*" + r"(?P<current>\S+)\s*(?:→|->)\s*(?P<latest>\S+)", + output, + ) + if update_match: + return update_match.group("latest").strip() + candidates = _VERSION_TOKEN_RE.findall(output) + return candidates[-1] if candidates else None + + +def _resolve_available_update_version() -> str | None: + output = _fetch_trellis_version_output() + if not output: + return None + return _extract_available_update_version(output) + + +def _parse_version(version: str) -> tuple[tuple[int, int, int], tuple[str, ...] | None] | None: + match = _VERSION_RE.match(version) + if not match: + return None + major, minor, patch, prerelease = match.groups() + numbers = (int(major), int(minor or "0"), int(patch or "0")) + prerelease_parts = tuple(prerelease.split(".")) if prerelease else None + return numbers, prerelease_parts + + +def _compare_prerelease( + left: tuple[str, ...] | None, + right: tuple[str, ...] | None, +) -> int: + if left is None and right is None: + return 0 + if left is None: + return 1 + if right is None: + return -1 + + for left_part, right_part in zip(left, right): + if left_part == right_part: + continue + left_numeric = left_part.isdigit() + right_numeric = right_part.isdigit() + if left_numeric and right_numeric: + left_int = int(left_part) + right_int = int(right_part) + return (left_int > right_int) - (left_int < right_int) + if left_numeric: + return -1 + if right_numeric: + return 1 + return (left_part > right_part) - (left_part < right_part) + + return (len(left) > len(right)) - (len(left) < len(right)) + + +def _compare_versions(left: str, right: str) -> int | None: + parsed_left = _parse_version(left) + parsed_right = _parse_version(right) + if parsed_left is None or parsed_right is None: + return None + + left_numbers, left_prerelease = parsed_left + right_numbers, right_prerelease = parsed_right + if left_numbers != right_numbers: + return (left_numbers > right_numbers) - (left_numbers < right_numbers) + return _compare_prerelease(left_prerelease, right_prerelease) + + +def _update_marker_path(repo_root: Path) -> Path: + context_key = resolve_context_key() + if not context_key: + terminal_key = os.environ.get("TERM_SESSION_ID", "").strip() + context_key = terminal_key or f"ppid-{os.getppid()}" + safe_key = re.sub(r"[^A-Za-z0-9._-]+", "_", context_key).strip("._-") + if not safe_key: + safe_key = "session" + return ( + repo_root + / DIR_WORKFLOW + / ".runtime" + / f"update-check-{safe_key[:160]}.marker" + ) + + +def _mark_update_check_attempted(repo_root: Path) -> bool: + marker_path = _update_marker_path(repo_root) + if marker_path.exists(): + return False + try: + marker_path.parent.mkdir(parents=True, exist_ok=True) + marker_path.write_text("checked\n", encoding="utf-8") + except OSError: + pass + return True + + +def _get_update_hint(repo_root: Path) -> str | None: + marker_path = _update_marker_path(repo_root) + if marker_path.exists(): + return None + + current_version = _read_project_version(repo_root) + if not current_version: + return None + + latest_version = _resolve_available_update_version() + if not latest_version: + return None + + _mark_update_check_attempted(repo_root) + comparison = _compare_versions(current_version, latest_version) + if comparison is None or comparison >= 0: + return None + + return ( + f"Trellis update available: {current_version} -> {latest_version}, " + "run trellis update" + ) + + +# ============================================================================= +# 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}" + ) + + root_git_info = _collect_root_git_info(repo_root) + + # 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, + discover_unconfigured=not root_git_info["isRepo"], + ) + + result = { + "developer": developer or "", + "git": { + "isRepo": root_git_info["isRepo"], + "branch": root_git_info["branch"], + "isClean": root_git_info["isClean"], + "uncommittedChanges": root_git_info["uncommittedChanges"], + "recentCommits": root_git_info["recentCommits"], + }, + "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("") + + root_git_info = _collect_root_git_info(repo_root) + _append_root_git_context(lines, root_git_info) + + # Package git repos — independent sub-repositories + _append_package_git_context( + lines, + _collect_package_git_info( + repo_root, + discover_unconfigured=not root_git_info["isRepo"], + ), + ) + + # 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) + + root_git_info = _collect_root_git_info(repo_root) + + # 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, + discover_unconfigured=not root_git_info["isRepo"], + ) + + result = { + "developer": developer or "", + "git": { + "isRepo": root_git_info["isRepo"], + "branch": root_git_info["branch"], + "isClean": root_git_info["isClean"], + "uncommittedChanges": root_git_info["uncommittedChanges"], + "recentCommits": root_git_info["recentCommits"], + }, + "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("") + + root_git_info = _collect_root_git_info(repo_root) + _append_root_git_context(lines, root_git_info) + + # Package git repos — independent sub-repositories + _append_package_git_context( + lines, + _collect_package_git_info( + repo_root, + discover_unconfigured=not root_git_info["isRepo"], + ), + ) + + # 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. + """ + if repo_root is None: + repo_root = get_repo_root() + update_hint = _get_update_hint(repo_root) + if update_hint: + print(update_hint) + print("") + 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..1c7a126 --- /dev/null +++ b/.trellis/scripts/common/task_context.py @@ -0,0 +1,319 @@ +#!/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 planning when + the task needs sub-agent/spec context. See ``.trellis/workflow.md`` for the + current planning artifact contract. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from .config import get_context_injection_limits +from .git import branch_exists_locally +from .io import read_json +from .log import Colors, colored +from .paths import FILE_TASK_JSON, get_repo_root +from .task_utils import resolve_task_dir + +# Extensions that look like code rather than spec/research docs. Entries with +# one of these extensions outside .trellis/spec/, docs/docs-site, or the +# task's own directory get a hygiene warning in `task.py validate` — the +# reader is a sub-agent, not a human, so code paths belong in the diff the +# agent reads itself, not in implement.jsonl / check.jsonl. +_CODE_FILE_EXTENSIONS = { + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".py", + ".go", + ".rs", + ".java", + ".rb", + ".c", + ".cc", + ".cpp", + ".h", +} + + +# ============================================================================= +# 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() + + # Warn (don't fail validation) when the recorded branch is stale — it + # was likely already merged and deleted (#399 item 2). + task_json_path = target_dir / FILE_TASK_JSON + if task_json_path.is_file(): + task_data = read_json(task_json_path) + stored_branch = task_data.get("branch") if task_data else None + if stored_branch and not branch_exists_locally(stored_branch, repo_root): + print( + colored( + f"Warning: recorded branch '{stored_branch}' no longer exists locally " + "(likely merged and deleted).", + Colors.YELLOW, + ) + ) + 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, target_dir) + 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 _is_exempt_from_code_file_warning(file_path: str, task_rel: str) -> bool: + """Whether a jsonl entry path is exempt from the code-file hygiene warning. + + Exempt: spec docs (``.trellis/spec/``), documentation (``docs``, + ``docs-site``), and the task's own directory (execution plans, generated + artifacts, etc. legitimately live there). + """ + posix_path = file_path.replace("\\", "/").lstrip("/") + exempt_prefixes = (".trellis/spec/", "docs/", "docs-site/") + if posix_path.startswith(exempt_prefixes): + return True + if task_rel and (posix_path == task_rel or posix_path.startswith(f"{task_rel}/")): + return True + return False + + +def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = None) -> int: + """Validate a single JSONL file. + + Seed rows (no ``file`` field — typically ``{"_example": "..."}``) are + skipped silently; they are self-describing comments, not real entries. + + Beyond hard errors (missing file/dir, invalid JSON), this also prints + non-blocking hygiene warnings (never counted in ``errors``, never change + the exit code): entries that look like code files rather than + spec/research docs, and entries whose file size exceeds the configured + sub-agent context injection cap (``context_injection.max_file_bytes``). + """ + 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 + + task_rel = "" + if task_dir is not None: + try: + task_rel = task_dir.resolve().relative_to(repo_root.resolve()).as_posix() + except ValueError: + task_rel = "" + + max_file_bytes = get_context_injection_limits(repo_root).get("max_file_bytes", 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 + continue + + if not full_path.is_file(): + print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}") + errors += 1 + continue + + extension = Path(file_path).suffix.lower() + if extension in _CODE_FILE_EXTENSIONS and not _is_exempt_from_code_file_warning( + file_path, task_rel + ): + warning_message = ( + f"{file_name}:{line_num}: Warning: {file_path} looks like a code file — " + "implement/check.jsonl should reference spec/research docs; " + "agents read code themselves" + ) + print(f" {colored(warning_message, Colors.YELLOW)}") + + if max_file_bytes: + size = full_path.stat().st_size + if size > max_file_bytes: + warning_message = ( + f"{file_name}:{line_num}: Warning: {file_path} is {size} bytes, " + f"exceeds context_injection.max_file_bytes ({max_file_bytes}); " + "injection will truncate it" + ) + print(f" {colored(warning_message, Colors.YELLOW)}") + + 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 new file mode 100755 index 0000000..f7485e2 --- /dev/null +++ b/.trellis/scripts/common/task_queue.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +Task queue utility functions. + +Provides: + list_tasks_by_status - List tasks by status + list_pending_tasks - List tasks with pending status + list_tasks_by_assignee - List tasks by assignee + list_my_tasks - List tasks assigned to current developer + get_task_stats - Get P0/P1/P2/P3 counts +""" + +from __future__ import annotations + +from pathlib import Path + +from .paths import ( + get_repo_root, + get_developer, + get_tasks_dir, +) +from .tasks import iter_active_tasks + + +# ============================================================================= +# Internal helper +# ============================================================================= + +def _task_to_dict(t) -> dict: + """Convert TaskInfo to the dict format callers expect.""" + return { + "priority": t.priority, + "id": t.raw.get("id", ""), + "title": t.title, + "status": t.status, + "assignee": t.assignee or "-", + "dir": t.dir_name, + "children": list(t.children), + "parent": t.parent, + } + + +# ============================================================================= +# Public Functions +# ============================================================================= + +def list_tasks_by_status( + filter_status: str | None = None, + repo_root: Path | None = None +) -> list[dict]: + """List tasks by status. + + Args: + filter_status: Optional status filter. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of task info dicts with keys: priority, id, title, status, assignee. + """ + if repo_root is None: + repo_root = get_repo_root() + + tasks_dir = get_tasks_dir(repo_root) + results = [] + + for t in iter_active_tasks(tasks_dir): + if filter_status and t.status != filter_status: + continue + results.append(_task_to_dict(t)) + + return results + + +def list_pending_tasks(repo_root: Path | None = None) -> list[dict]: + """List pending tasks. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of task info dicts. + """ + return list_tasks_by_status("planning", repo_root) + + +def list_tasks_by_assignee( + assignee: str, + filter_status: str | None = None, + repo_root: Path | None = None +) -> list[dict]: + """List tasks assigned to a specific developer. + + Args: + assignee: Developer name. + filter_status: Optional status filter. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of task info dicts. + """ + if repo_root is None: + repo_root = get_repo_root() + + tasks_dir = get_tasks_dir(repo_root) + results = [] + + for t in iter_active_tasks(tasks_dir): + if (t.assignee or "-") != assignee: + continue + if filter_status and t.status != filter_status: + continue + results.append(_task_to_dict(t)) + + return results + + +def list_my_tasks( + filter_status: str | None = None, + repo_root: Path | None = None +) -> list[dict]: + """List tasks assigned to current developer. + + Args: + filter_status: Optional status filter. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of task info dicts. + + Raises: + ValueError: If developer not set. + """ + if repo_root is None: + repo_root = get_repo_root() + + developer = get_developer(repo_root) + if not developer: + raise ValueError("Developer not set") + + return list_tasks_by_assignee(developer, filter_status, repo_root) + + +def get_task_stats(repo_root: Path | None = None) -> dict[str, int]: + """Get task statistics. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Dict with keys: P0, P1, P2, P3, Total. + """ + if repo_root is None: + repo_root = get_repo_root() + + tasks_dir = get_tasks_dir(repo_root) + stats = {"P0": 0, "P1": 0, "P2": 0, "P3": 0, "Total": 0} + + for t in iter_active_tasks(tasks_dir): + if t.priority in stats: + stats[t.priority] += 1 + stats["Total"] += 1 + + return stats + + +def format_task_stats(stats: dict[str, int]) -> str: + """Format task stats as string. + + Args: + stats: Stats dict from get_task_stats. + + Returns: + Formatted string like "P0:0 P1:1 P2:2 P3:0 Total:3". + """ + return f"P0:{stats['P0']} P1:{stats['P1']} P2:{stats['P2']} P3:{stats['P3']} Total:{stats['Total']}" + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + stats = get_task_stats() + print(format_task_stats(stats)) + print() + print("Pending tasks:") + for task in list_pending_tasks(): + print(f" {task['priority']}|{task['id']}|{task['title']}|{task['status']}|{task['assignee']}") diff --git a/.trellis/scripts/common/task_store.py b/.trellis/scripts/common/task_store.py new file mode 100755 index 0000000..2cd9789 --- /dev/null +++ b/.trellis/scripts/common/task_store.py @@ -0,0 +1,945 @@ +#!/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_set_meta - Set/overwrite a task metadata key + 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_codex_dispatch_mode, + get_packages, + get_session_auto_commit, + is_monorepo, + resolve_package, + validate_package, +) +from .git import branch_exists_locally, resolve_default_branch, 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 .safe_commit import ( + print_gitignore_warning, + safe_archive_paths_to_add, + safe_git_add, +) +from .task_utils import ( + archive_task_complete, + find_task_by_name, + is_within_tasks_dir, + 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 + + +def _find_archived_task_by_dir_name(tasks_dir: Path, dir_name: str) -> Path | None: + """Find an archived task directory with the exact active-task dir name.""" + archive_dir = tasks_dir / DIR_ARCHIVE + if not archive_dir.is_dir(): + return None + + for month_dir in sorted(archive_dir.iterdir()): + if not month_dir.is_dir(): + continue + candidate = month_dir / dir_name + if candidate.is_dir(): + return candidate + + return None + + +def _repo_relative_path(path: Path, repo_root: Path) -> str: + """Format a path relative to the repo root when possible.""" + try: + return path.relative_to(repo_root).as_posix() + except ValueError: + return str(path) + + +# ============================================================================= +# 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. +# Codex is checked separately because explicit inline mode does not consume +# JSONL. Kilo / Antigravity / Devin are NOT in this list either: they load +# specs through skills instead of JSONL. +_SUBAGENT_CONFIG_DIRS: tuple[str, ...] = ( + ".claude", + ".cursor", + ".kiro", + ".gemini", + ".opencode", + ".qoder", + ".codebuddy", + ".factory", # Factory Droid + ".github/copilot", + ".pi", # Pi Agent + ".trae", # Trae IDE + ".omp", # Oh My Pi + ".zcode", # ZCode + ".grok", # Grok Build + ".kimi-code", # Kimi Code +) +_CODEX_CONFIG_DIR = ".codex" + +_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. Codex + counts by default through ``codex.dispatch_mode: auto`` (including the + legacy ``sub-agent`` alias); explicit inline mode loads context through + skills, not JSONL. + """ + for config_dir in _SUBAGENT_CONFIG_DIRS: + if (repo_root / config_dir).is_dir(): + return True + if (repo_root / _CODEX_CONFIG_DIR).is_dir(): + return get_codex_dispatch_mode(repo_root) == "auto" + 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") + + +def _parse_meta_pairs(pairs: list[str] | None) -> dict[str, str] | None: + """Parse repeatable ``--meta key=value`` pairs into a dict. + + Returns ``None`` (after printing an error naming the bad value) on the + first malformed pair: missing ``=`` or an empty key. Values are stored + as-is (strings, no nesting, no type coercion). + """ + meta: dict[str, str] = {} + for pair in pairs or []: + key, sep, value = pair.partition("=") + if not sep or not key: + print( + colored(f"Error: malformed --meta value '{pair}' (expected key=value)", Colors.RED), + file=sys.stderr, + ) + return None + meta[key] = value + return meta + + +def _default_prd_content(title: str, description: str | None = None) -> str: + """Return the default PRD skeleton created with every task.""" + goal = (description or "").strip() or "TBD." + heading = title.strip() or "Untitled task" + return f"""# {heading} + +## Goal + +{goal} + +## Requirements + +- TBD + +## Acceptance Criteria + +- [ ] TBD + +## Notes + +- Keep `prd.md` focused on requirements, constraints, and acceptance criteria. +- Lightweight tasks can remain PRD-only. +- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`. +""" + + +# ============================================================================= +# 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 --meta (CLI source: fail-fast, before any directory is created) + meta = _parse_meta_pairs(getattr(args, "meta", None)) + if meta is None: + 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() + + # Guard against date-prefixed --slug (e.g. a full task dir name pasted in), + # which would otherwise produce MM-DD-MM-DD-slug (issue #377). Only an + # explicit --slug is guarded; title-derived slugs are left untouched. + if args.slug: + m = re.match(r"^(\d{2})-(\d{2})-(.+)$", slug) + if m and 1 <= int(m.group(1)) <= 12 and 1 <= int(m.group(2)) <= 31: + slug_prefix = f"{m.group(1)}-{m.group(2)}" + if slug_prefix == date_prefix: + slug = m.group(3) + print( + colored( + f'warning: --slug should not include the MM-DD prefix; normalized to "{slug}"', + Colors.YELLOW, + ), + file=sys.stderr, + ) + else: + print( + colored( + f"Error: --slug starts with a date prefix ({slug_prefix}-), but task.py create always uses today's date ({date_prefix}).", + Colors.RED, + ), + file=sys.stderr, + ) + print(f"Pass only the slug body, e.g. --slug {m.group(3)}", file=sys.stderr) + return 1 + + dir_name = f"{date_prefix}-{slug}" + task_dir = tasks_dir / dir_name + task_json_path = task_dir / FILE_TASK_JSON + + archived_task_dir = _find_archived_task_by_dir_name(tasks_dir, dir_name) + if archived_task_dir: + print(colored(f"Error: Task already archived: {dir_name}", Colors.RED), file=sys.stderr) + print(f"Archived at: {_repo_relative_path(archived_task_dir, repo_root)}", file=sys.stderr) + print("Use a new slug if you intend to create a new task.", file=sys.stderr) + return 1 + + 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 the PR target branch. Prefer the repo's actual default branch + # (origin/HEAD) so creating a task from a feature branch doesn't + # mis-stamp that feature branch as the PR target (#399 item 1). Falls + # back to the checked-out branch when the default can't be resolved + # (no remote configured, offline, etc.) — the pre-existing behavior. + # --base-branch lets the caller override both when neither is correct. + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) + current_branch = branch_out.strip() or "main" + explicit_base_branch: str | None = getattr(args, "base_branch", None) + if explicit_base_branch: + base_branch = explicit_base_branch + else: + resolved_base_branch = resolve_default_branch(repo_root) + if resolved_base_branch: + base_branch = resolved_base_branch + else: + base_branch = current_branch + print( + colored( + f"warning: could not resolve the repository's default branch " + f"(no remote configured, offline, etc.); stamping base_branch as " + f"the checked-out branch '{base_branch}'. Pass --base-branch to override.", + Colors.YELLOW, + ), + file=sys.stderr, + ) + + description = (args.description or "").strip() + if not description.strip(): + print( + colored( + "warning: task description is empty; pass --description to improve search and later audits.", + Colors.YELLOW, + ), + file=sys.stderr, + ) + + task_data = { + "id": slug, + "name": slug, + "title": args.title, + "description": description, + "status": "planning", + "dev_type": None, + "scope": None, + "package": package, + "priority": args.priority, + "creator": creator, + "assignee": assignee, + "createdAt": today, + "completedAt": None, + "branch": None, + "base_branch": base_branch, + "worktree_path": None, + "commit": None, + "pr_url": None, + "subtasks": [], + "children": [], + "parent": None, + "relatedFiles": [], + "notes": "", + "meta": meta, + } + + write_json(task_json_path, task_data) + + prd_path = task_dir / "prd.md" + if not prd_path.exists(): + prd_path.write_text( + _default_prd_content(args.title, description), + encoding="utf-8", + ) + + # Seed implement.jsonl / check.jsonl for sub-agent-capable platforms. + # Agent curates real entries during planning when the task needs them. + # Agent-less platforms (Kilo / Antigravity / Devin) 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. + if getattr(args, "no_start", False): + print( + colored( + "Skipped session activation (--no-start); run task.py start when ready.", + Colors.YELLOW, + ), + file=sys.stderr, + ) + else: + try: + from .active_task import resolve_context_key, set_active_task + except Exception as exc: + print( + colored(f"Warning: session activation unavailable (import failed: {exc})", Colors.YELLOW), + file=sys.stderr, + ) + else: + try: + context_key = resolve_context_key() + except Exception as exc: + print( + colored(f"Warning: session activation failed (context resolution: {exc})", Colors.YELLOW), + file=sys.stderr, + ) + else: + # No session identity is the normal CLI-outside-an-AI-session + # case (see comment above) — stay silent, not a failure. + if context_key: + try: + rel_dir = task_dir.relative_to(repo_root).as_posix() + except ValueError: + rel_dir = str(task_dir) + try: + active = set_active_task(rel_dir, repo_root) + except Exception as exc: + print( + colored(f"Warning: session activation failed (pointer persistence: {exc})", Colors.YELLOW), + file=sys.stderr, + ) + else: + if active: + print( + colored(f"Activated task for this session: {active.task_path}", Colors.GREEN), + file=sys.stderr, + ) + print(f"Source: {active.source}", file=sys.stderr) + else: + print( + colored("Warning: session activation failed (no pointer returned)", Colors.YELLOW), + 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(" - Fill prd.md with requirements and acceptance criteria", file=sys.stderr) + print(" - Lightweight task: PRD-only is valid", file=sys.stderr) + print(" - Complex task: add design.md and implement.md before task.py start", file=sys.stderr) + if seeded_jsonl: + print( + " - Curate implement.jsonl / check.jsonl as spec/research manifests when sub-agents need context", + file=sys.stderr, + ) + print(" - Use /trellis:continue or phase context to decide the next step", 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 + + # Refuse to archive anything that isn't a real task directly under + # .trellis/tasks/. A mistyped name (e.g. "src") resolves to repo_root/src, + # which is a dir but not a task — without this guard archive would move the + # user's source directory out of the repo. + if not is_within_tasks_dir(task_dir, repo_root): + print(colored( + f"Error: refusing to archive '{task_name}': " + f"{task_dir} is not a task under {tasks_dir}", + Colors.RED), 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") + # Names of child task dirs whose task.json gets modified below; passed + # into safe_archive_paths_to_add so they're staged in this commit. + modified_children: list[str] = [] + if task_json_path.is_file(): + data = read_json(task_json_path) + if data: + # Warn (don't block) when the recorded branch is stale — it was + # likely already merged and deleted (#399 item 2). + stored_branch = data.get("branch") + if stored_branch and not branch_exists_locally(stored_branch, repo_root): + print( + colored( + f"Warning: recorded branch '{stored_branch}' no longer exists locally " + "(likely merged and deleted).", + Colors.YELLOW, + ), + file=sys.stderr, + ) + + 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) + modified_children.append(child_dir_path.name) + + # 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): + if not _auto_commit_archive(dir_name, repo_root, modified_children): + print( + colored( + "Archive moved on disk, but git auto-commit did not complete. " + "Resolve `git status` before continuing.", + Colors.RED, + ), + file=sys.stderr, + ) + return 1 + + # 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, + modified_children: list[str] | None = None, +) -> bool: + """Stage Trellis-owned task paths and commit after archive. + + Scoped narrowly to the archived task's source + destination paths + plus any child task dirs whose ``task.json`` was edited (parent → + children relationship update). Dirty changes in OTHER active task + dirs are NOT bundled into the archive commit. + + If ``.gitignore`` blocks the paths, we warn + skip — we do NOT + retry with ``git add -f``. The warning explicitly forbids + ``git add -f .trellis/`` (which would fan out to caches/backups) + and points users at ``session_auto_commit: false``. + + Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when + set to ``false``, this function returns immediately without + touching git (the archive directory move on disk is unaffected). + """ + if not get_session_auto_commit(repo_root): + print( + "[OK] session_auto_commit: false — skipping git stage/commit.", + file=sys.stderr, + ) + return True + + source_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}" + rc, tracked_out, _ = run_git( + ["ls-files", "--", source_rel], + cwd=repo_root, + ) + source_was_tracked = rc == 0 and bool(tracked_out.strip()) + + paths = safe_archive_paths_to_add( + repo_root, task_name=task_name, modified_children=modified_children + ) + if not paths: + print("[OK] No task changes to commit.", file=sys.stderr) + return True + + success, _, err = safe_git_add(paths, repo_root) + if not success: + if err and "ignored by" in err.lower(): + print_gitignore_warning(paths) + else: + print( + f"[WARN] git add failed: {err.strip() if err else 'unknown error'}", + file=sys.stderr, + ) + return not source_was_tracked + + # Belt-and-suspenders for the phantom-delete bug: `safe_git_add` uses + # `git add` (no -A) which only stages additions/modifications. The + # source task directory was moved away by `shutil.move`, so its files + # need an explicit `git rm --cached` to stage the deletions in this + # same commit — otherwise they sit as uncommitted "phantom deletes" + # against HEAD until something later picks them up. + # + # `--ignore-unmatch` makes this a no-op when the task was never tracked + # (e.g. archiving a task that lived only in working tree). + run_git( + ["rm", "-r", "--cached", "--ignore-unmatch", "--", source_rel], + cwd=repo_root, + ) + + rc, _, _ = run_git( + ["diff", "--cached", "--quiet", "--", *paths, source_rel], + cwd=repo_root, + ) + if rc == 0: + print("[OK] No task changes to commit.", file=sys.stderr) + return True + + 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) + return True + else: + print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr) + return not source_was_tracked + + +# ============================================================================= +# 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 + + +# ============================================================================= +# Command: set-meta +# ============================================================================= + +def cmd_set_meta(args: argparse.Namespace) -> int: + """Set/overwrite one metadata key on an existing task.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + key = args.key + value = args.value + + if not key: + print(colored("Error: Missing arguments", Colors.RED)) + print("Usage: python3 task.py set-meta <task-dir> <key> <value>") + 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 + + meta = data.get("meta") + if not isinstance(meta, dict): + meta = {} + meta[key] = value + data["meta"] = meta + write_json(task_json, data) + + print(colored(f"✓ Meta set: {key} = {value}", Colors.GREEN)) + return 0 diff --git a/.trellis/scripts/common/task_utils.py b/.trellis/scripts/common/task_utils.py new file mode 100755 index 0000000..62368db --- /dev/null +++ b/.trellis/scripts/common/task_utils.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +""" +Task utility functions. + +Provides: + is_safe_task_path - Validate task path is safe to operate on + find_task_by_name - Find task directory by name + resolve_task_dir - Resolve task directory from name, relative, or absolute path + archive_task_dir - Archive task to monthly directory + run_task_hooks - Run lifecycle hooks for task events +""" + +from __future__ import annotations + +import shutil +import sys +from datetime import datetime +from pathlib import Path + +from .paths import get_repo_root, get_tasks_dir + + +# ============================================================================= +# Path Safety +# ============================================================================= + +def is_safe_task_path(task_path: str, repo_root: Path | None = None) -> bool: + """Check if a relative task path is safe to operate on. + + Args: + task_path: Task path (relative to repo_root). + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True if safe, False if dangerous. + """ + if repo_root is None: + repo_root = get_repo_root() + + normalized = task_path.replace("\\", "/") + + # Check empty or null + if not normalized or normalized == "null": + print("Error: empty or null task path", file=sys.stderr) + return False + + # Reject absolute paths + if Path(task_path).is_absolute(): + print(f"Error: absolute path not allowed: {task_path}", file=sys.stderr) + return False + + # Reject ".", "..", paths starting with "./" or "../", or containing ".." + if normalized in (".", "..") or normalized.startswith("./") or normalized.startswith("../") or ".." in normalized: + print(f"Error: path traversal not allowed: {task_path}", file=sys.stderr) + return False + + # Final check: ensure resolved path is not the repo root + abs_path = repo_root / Path(normalized) + if abs_path.exists(): + try: + resolved = abs_path.resolve() + root_resolved = repo_root.resolve() + if resolved == root_resolved: + print(f"Error: path resolves to repo root: {task_path}", file=sys.stderr) + return False + except (OSError, IOError): + pass + + return True + + +def is_within_tasks_dir(task_dir_abs: Path, repo_root: Path | None = None) -> bool: + """Check that a resolved task directory really is a task under the tasks dir. + + A real task lives directly at ``.trellis/tasks/<name>``. This returns True + only when ``task_dir_abs`` is an immediate child of the tasks directory. + + Guards archive: ``resolve_task_dir`` falls back to ``repo_root/<name>`` for + an unknown name, so a mistyped ``task.py archive src`` resolves to the real + ``src/`` source directory. Without this check archive would ``shutil.move`` + it out of the repo. Also rejects the tasks dir itself and anything nested + under ``archive/`` (already-archived tasks). + """ + if repo_root is None: + repo_root = get_repo_root() + try: + resolved = task_dir_abs.resolve() + tasks_resolved = get_tasks_dir(repo_root).resolve() + except (OSError, RuntimeError): + return False + if resolved.parent != tasks_resolved: + return False + return resolved.name != "archive" + + +# ============================================================================= +# Task Lookup +# ============================================================================= + +def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None: + """Find task directory by name (exact or suffix match). + + Args: + task_name: Task name to find. + tasks_dir: Tasks directory path. + + Returns: + Absolute path to task directory, or None if not found. + """ + if not task_name or not tasks_dir or not tasks_dir.is_dir(): + return None + + # Try exact match first + exact_match = tasks_dir / task_name + if exact_match.is_dir(): + return exact_match + + # Try suffix match (e.g., "my-task" matches "01-21-my-task") + for d in tasks_dir.iterdir(): + if d.is_dir() and d.name.endswith(f"-{task_name}"): + return d + + return None + + +# ============================================================================= +# Archive Operations +# ============================================================================= + +def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path | None: + """Archive a task directory to archive/{YYYY-MM}/. + + Args: + task_dir_abs: Absolute path to task directory. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Path to archived directory, or None on error. + """ + if not task_dir_abs.is_dir(): + print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr) + return None + + # Get tasks directory (parent of the task) + tasks_dir = task_dir_abs.parent + archive_dir = tasks_dir / "archive" + year_month = datetime.now().strftime("%Y-%m") + month_dir = archive_dir / year_month + + # Create archive directory + try: + month_dir.mkdir(parents=True, exist_ok=True) + except (OSError, IOError) as e: + print(f"Error: Failed to create archive directory: {e}", file=sys.stderr) + return None + + # Move task to archive + task_name = task_dir_abs.name + dest = month_dir / task_name + + try: + shutil.move(str(task_dir_abs), str(dest)) + except (OSError, IOError, shutil.Error) as e: + print(f"Error: Failed to move task to archive: {e}", file=sys.stderr) + return None + + return dest + + +def archive_task_complete( + task_dir_abs: Path, + repo_root: Path | None = None +) -> dict[str, str]: + """Complete archive workflow: archive directory. + + Args: + task_dir_abs: Absolute path to task directory. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Dict with archive result info. + """ + if not task_dir_abs.is_dir(): + print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr) + return {} + + archive_dest = archive_task_dir(task_dir_abs, repo_root) + if archive_dest: + return {"archived_to": str(archive_dest)} + + return {} + + +# ============================================================================= +# Task Directory Resolution +# ============================================================================= + +def resolve_task_dir(target_dir: str, repo_root: Path) -> Path: + """Resolve task directory to absolute path. + + Supports: + - Absolute path: /path/to/task + - Relative path: .trellis/tasks/01-31-my-task + - Task name: my-task (uses find_task_by_name for lookup) + + Args: + target_dir: Task directory specification. + repo_root: Repository root path. + + Returns: + Resolved absolute path. + """ + if not target_dir: + return Path() + + normalized = target_dir.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + + # Absolute path + if Path(target_dir).is_absolute(): + return Path(target_dir) + + # Relative path (contains path separator or starts with .trellis) + if "/" in normalized or normalized.startswith(".trellis"): + return repo_root / Path(normalized) + + # Task name - try to find in tasks directory + tasks_dir = get_tasks_dir(repo_root) + found = find_task_by_name(target_dir, tasks_dir) + if found: + return found + + # Fallback to treating as relative path + return repo_root / Path(normalized) + + +# ============================================================================= +# Lifecycle Hooks +# ============================================================================= + +def run_task_hooks(event: str, task_json_path: Path, repo_root: Path) -> None: + """Run lifecycle hooks for a task event. + + Args: + event: Event name (e.g. "after_create"). + task_json_path: Absolute path to the task's task.json. + repo_root: Repository root for cwd and config lookup. + """ + import os + import subprocess + + from .config import get_hooks + from .log import Colors, colored + + commands = get_hooks(event, repo_root) + if not commands: + return + + env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)} + + for cmd in commands: + try: + result = subprocess.run( + cmd, + shell=True, + cwd=repo_root, + env=env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if result.returncode != 0: + print( + colored(f"[WARN] Hook failed ({event}): {cmd}", Colors.YELLOW), + file=sys.stderr, + ) + if result.stderr.strip(): + print(f" {result.stderr.strip()}", file=sys.stderr) + except Exception as e: + print( + colored(f"[WARN] Hook error ({event}): {cmd} — {e}", Colors.YELLOW), + file=sys.stderr, + ) + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + repo = get_repo_root() + tasks = get_tasks_dir(repo) + + print(f"Tasks dir: {tasks}") + print(f"is_safe_task_path('.trellis/tasks/test'): {is_safe_task_path('.trellis/tasks/test', repo)}") + print(f"is_safe_task_path('../test'): {is_safe_task_path('../test', repo)}") 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/trellis_config.py b/.trellis/scripts/common/trellis_config.py new file mode 100755 index 0000000..a4a6d1e --- /dev/null +++ b/.trellis/scripts/common/trellis_config.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +""" +Standalone reader for .trellis/config.yaml. + +Mirrors a minimal subset of common.config so callers (hooks, workflow_phase) +can read configuration without importing the full task/repo helpers. Returns +an empty dict on missing/malformed files so callers stay simple. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Optional + + +CONFIG_REL_PATH = ".trellis/config.yaml" + + +def _unquote(value: str) -> str: + if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"): + return value[1:-1] + return value + + +def _strip_inline_comment(value: str) -> str: + """Strip ` # …` inline comments while preserving `#` inside quoted strings. + + YAML treats ` #` (space-hash) as a comment opener; bare `#` inside a token + is part of the value. Quoted strings are immune. + """ + in_quote: str | None = None + for idx, ch in enumerate(value): + if in_quote: + if ch == in_quote: + in_quote = None + continue + if ch in ('"', "'"): + in_quote = ch + continue + if ch == "#" and (idx == 0 or value[idx - 1].isspace()): + return value[:idx] + return value + + +def _next_content_line(lines: list[str], start: int) -> tuple[int, str]: + i = start + while i < len(lines): + stripped = lines[i].strip() + if stripped and not stripped.startswith("#"): + return i, lines[i] + i += 1 + return i, "" + + +def _parse_yaml_block( + lines: list[str], start: int, min_indent: int, target: dict +) -> int: + i = start + current_list: list | None = None + + while i < len(lines): + line = lines[i] + stripped = line.strip() + + if not stripped or stripped.startswith("#"): + i += 1 + continue + + indent = len(line) - len(line.lstrip()) + 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 = _strip_inline_comment(value).strip() + was_quoted = len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'") + value = _unquote(value) + current_list = None + + if value or was_quoted: + target[key] = value + i += 1 + else: + 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("- "): + current_list = [] + target[key] = current_list + i += 1 + else: + next_indent = len(next_line) - len(next_line.lstrip()) + if next_indent > indent: + nested: dict = {} + target[key] = nested + i = _parse_yaml_block(lines, i + 1, next_indent, nested) + else: + target[key] = {} + i += 1 + else: + i += 1 + + return i + + +def parse_simple_yaml(content: str) -> dict: + """Parse a small subset of YAML. See common.config for full doc.""" + lines = content.splitlines() + result: dict = {} + _parse_yaml_block(lines, 0, 0, result) + return result + + +def read_trellis_config(repo_root: Optional[Path] = None) -> dict: + """Read .trellis/config.yaml. Returns {} on missing or malformed file.""" + root = repo_root or Path.cwd() + config_file = root / CONFIG_REL_PATH + try: + content = config_file.read_text(encoding="utf-8") + except (FileNotFoundError, OSError): + return {} + try: + parsed = parse_simple_yaml(content) + except Exception: + return {} + return parsed if isinstance(parsed, dict) else {} 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..9e1c619 --- /dev/null +++ b/.trellis/scripts/common/workflow_phase.py @@ -0,0 +1,219 @@ +#!/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 the compact Phase Index summary from workflow.md. + + SessionStart and no-step phase context use this small summary as their + orientation payload. Detailed Phase 1/2/3 instructions are loaded with + ``get_step`` on demand. ``[workflow-state:STATUS]`` tag blocks are + consumed by the per-turn 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 == "## Phase 1: Plan": + 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 resolve_effective_platform(platform: str, config: dict) -> str: + """Map ``codex`` to a dispatch-mode-namespaced virtual platform name. + + When ``--platform codex`` is passed, return ``"codex-sub-agent"`` by + default or ``"codex-inline"`` when explicitly configured in + ``.trellis/config.yaml``. ``sub-agent`` remains an alias for ``auto``. + ``filter_platform`` then surfaces blocks whose marker lists include the + namespaced name (e.g. ``[codex-sub-agent, ...]`` or ``[codex-inline, Kilo, + Antigravity, Devin]``). + + Native Codex context injection supports the ``auto`` default. Invalid + explicit values fall back to ``inline`` safely; this renderer deliberately + does not warn because it can run in normal CLI output flows. + + Other platforms are returned unchanged. + """ + if platform == "codex": + mode = "auto" + codex_cfg = config.get("codex") if isinstance(config, dict) else None + if codex_cfg is not None: + if not isinstance(codex_cfg, dict): + mode = "inline" + else: + cfg_mode = str(codex_cfg.get("dispatch_mode", mode)).strip().lower() + if cfg_mode == "inline": + mode = "inline" + elif cfg_mode in ("auto", "sub-agent"): + mode = "auto" + else: + mode = "inline" + return "codex-sub-agent" if mode == "auto" else "codex-inline" + return platform + + +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/get_context.py b/.trellis/scripts/get_context.py new file mode 100755 index 0000000..bc63463 --- /dev/null +++ b/.trellis/scripts/get_context.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +""" +Get Session Context for AI Agent. + +Usage: + python3 get_context.py Output context in text format + python3 get_context.py --json Output context in JSON format +""" + +from __future__ import annotations + +from common.git_context import main + + +if __name__ == "__main__": + main() diff --git a/.trellis/scripts/get_developer.py b/.trellis/scripts/get_developer.py new file mode 100755 index 0000000..f8a89eb --- /dev/null +++ b/.trellis/scripts/get_developer.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +""" +Get current developer name. + +This is a wrapper that uses common/paths.py +""" + +from __future__ import annotations + +import sys + +from common.paths import get_developer + + +def main() -> None: + """CLI entry point.""" + developer = get_developer() + if developer: + print(developer) + else: + print("Developer not initialized", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.trellis/scripts/hooks/linear_sync.py b/.trellis/scripts/hooks/linear_sync.py new file mode 100755 index 0000000..5659fde --- /dev/null +++ b/.trellis/scripts/hooks/linear_sync.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Linear sync hook for Trellis task lifecycle. + +Syncs task events to Linear via the `linearis` CLI. + +Usage (called automatically by task.py hooks): + python3 .trellis/scripts/hooks/linear_sync.py create + python3 .trellis/scripts/hooks/linear_sync.py start + python3 .trellis/scripts/hooks/linear_sync.py archive + +Manual usage: + TASK_JSON_PATH=.trellis/tasks/<name>/task.json python3 .trellis/scripts/hooks/linear_sync.py sync + +Environment: + TASK_JSON_PATH - Absolute path to task.json (set by task.py) + +Configuration: + .trellis/hooks.local.json - Local config (gitignored), example: + { + "linear": { + "team": "TEAM_KEY", + "project": "Project Name", + "assignees": { + "dev-name": "linear-user-id" + } + } + } +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +# ─── Configuration ──────────────────────────────────────────────────────────── + +# Trellis priority → Linear priority (1=Urgent, 2=High, 3=Medium, 4=Low) +PRIORITY_MAP = {"P0": 1, "P1": 2, "P2": 3, "P3": 4} + +# Linear status names (must match your team's workflow) +STATUS_IN_PROGRESS = "In Progress" +STATUS_DONE = "Done" + + +def _load_config() -> dict: + """Load local hook config from .trellis/hooks.local.json.""" + task_json_path = os.environ.get("TASK_JSON_PATH", "") + if task_json_path: + # Walk up from task.json to find .trellis/ + trellis_dir = Path(task_json_path).parent.parent.parent + else: + trellis_dir = Path(".trellis") + + config_path = trellis_dir / "hooks.local.json" + try: + with open(config_path, encoding="utf-8") as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + return {} + + +CONFIG = _load_config() +LINEAR_CFG = CONFIG.get("linear", {}) + +TEAM = LINEAR_CFG.get("team", "") +PROJECT = LINEAR_CFG.get("project", "") +ASSIGNEE_MAP = LINEAR_CFG.get("assignees", {}) + +# ─── Helpers ────────────────────────────────────────────────────────────────── + + +def _read_task() -> tuple[dict, str]: + path = os.environ.get("TASK_JSON_PATH", "") + if not path: + print("TASK_JSON_PATH not set", file=sys.stderr) + sys.exit(1) + with open(path, encoding="utf-8") as f: + return json.load(f), path + + +def _write_task(data: dict, path: str) -> None: + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write("\n") + + +def _linearis(*args: str) -> dict | None: + result = subprocess.run( + ["linearis", *args], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if result.returncode != 0: + print(f"linearis error: {result.stderr.strip()}", file=sys.stderr) + sys.exit(1) + stdout = result.stdout.strip() + if stdout: + return json.loads(stdout) + return None + + +def _get_linear_issue(task: dict) -> str | None: + meta = task.get("meta") + if isinstance(meta, dict): + return meta.get("linear_issue") + return None + + +# ─── Actions ────────────────────────────────────────────────────────────────── + + +def cmd_create() -> None: + if not TEAM: + print("No linear.team configured in hooks.local.json", file=sys.stderr) + sys.exit(1) + + task, path = _read_task() + + # Skip if already linked + if _get_linear_issue(task): + print(f"Already linked: {_get_linear_issue(task)}") + return + + title = task.get("title") or task.get("name") or "Untitled" + args = ["issues", "create", title, "--team", TEAM] + + # Map priority + priority = PRIORITY_MAP.get(task.get("priority", ""), 0) + if priority: + args.extend(["-p", str(priority)]) + + # Set project + if PROJECT: + args.extend(["--project", PROJECT]) + + # Assign to Linear user + assignee = task.get("assignee", "") + linear_user_id = ASSIGNEE_MAP.get(assignee) + if linear_user_id: + args.extend(["--assignee", linear_user_id]) + + # Link to parent's Linear issue if available + parent_issue = _resolve_parent_linear_issue(task) + if parent_issue: + args.extend(["--parent-ticket", parent_issue]) + + result = _linearis(*args) + if result and "identifier" in result: + if not isinstance(task.get("meta"), dict): + task["meta"] = {} + task["meta"]["linear_issue"] = result["identifier"] + _write_task(task, path) + print(f"Created Linear issue: {result['identifier']}") + + +def cmd_start() -> None: + task, _ = _read_task() + issue = _get_linear_issue(task) + if not issue: + return + _linearis("issues", "update", issue, "-s", STATUS_IN_PROGRESS) + print(f"Updated {issue} -> {STATUS_IN_PROGRESS}") + cmd_sync() + + +def cmd_archive() -> None: + task, _ = _read_task() + issue = _get_linear_issue(task) + if not issue: + return + _linearis("issues", "update", issue, "-s", STATUS_DONE) + print(f"Updated {issue} -> {STATUS_DONE}") + + +def cmd_sync() -> None: + """Sync prd.md content to Linear issue description.""" + task, _ = _read_task() + issue = _get_linear_issue(task) + if not issue: + print("No linear_issue in meta, run create first", file=sys.stderr) + sys.exit(1) + + # Find prd.md next to task.json + task_json_path = os.environ.get("TASK_JSON_PATH", "") + prd_path = Path(task_json_path).parent / "prd.md" + if not prd_path.is_file(): + print(f"No prd.md found at {prd_path}", file=sys.stderr) + sys.exit(1) + + description = prd_path.read_text(encoding="utf-8").strip() + _linearis("issues", "update", issue, "-d", description) + print(f"Synced prd.md to {issue} description") + + +# ─── Parent Issue Resolution ───────────────────────────────────────────────── + + +def _resolve_parent_linear_issue(task: dict) -> str | None: + """Find parent task's Linear issue identifier.""" + parent_name = task.get("parent") + if not parent_name: + return None + + task_json_path = os.environ.get("TASK_JSON_PATH", "") + if not task_json_path: + return None + + current_task_dir = Path(task_json_path).parent + tasks_dir = current_task_dir.parent + parent_json = tasks_dir / parent_name / "task.json" + + if parent_json.exists(): + try: + with open(parent_json, encoding="utf-8") as f: + parent_task = json.load(f) + return _get_linear_issue(parent_task) + except (json.JSONDecodeError, OSError): + pass + return None + + +# ─── Main ───────────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + action = sys.argv[1] if len(sys.argv) > 1 else "" + actions = { + "create": cmd_create, + "start": cmd_start, + "archive": cmd_archive, + "sync": cmd_sync, + } + fn = actions.get(action) + if fn: + fn() + else: + print(f"Unknown action: {action}", file=sys.stderr) + print(f"Valid actions: {', '.join(actions)}", file=sys.stderr) + sys.exit(1) diff --git a/.trellis/scripts/init_developer.py b/.trellis/scripts/init_developer.py new file mode 100755 index 0000000..9fb53f5 --- /dev/null +++ b/.trellis/scripts/init_developer.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +""" +Initialize developer for workflow. + +Usage: + python3 init_developer.py <developer-name> + +This creates: + - .trellis/.developer file with developer info + - .trellis/workspace/<name>/ directory structure +""" + +from __future__ import annotations + +import sys + +from common.paths import ( + DIR_WORKFLOW, + FILE_DEVELOPER, + get_developer, +) +from common.developer import init_developer + + +def main() -> None: + """CLI entry point.""" + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} <developer-name>") + print() + print("Example:") + print(f" {sys.argv[0]} john") + sys.exit(1) + + name = sys.argv[1] + + # Check if already initialized + existing = get_developer() + if existing: + print(f"Developer already initialized: {existing}") + print() + print(f"To reinitialize, remove {DIR_WORKFLOW}/{FILE_DEVELOPER} first") + sys.exit(0) + + if init_developer(name): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.trellis/scripts/task.py b/.trellis/scripts/task.py new file mode 100755 index 0000000..7e82eac --- /dev/null +++ b/.trellis/scripts/task.py @@ -0,0 +1,602 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Task Management Script. + +Usage: + python3 task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] [--package <pkg>] [--no-start] + 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 active task + python3 task.py current [--source] [--json] # 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 set-meta <dir> <key> <value> # Set a task metadata key + 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 + python3 task.py remove-subtask <parent-dir> <child-dir> # Unlink child from parent +""" + +from __future__ import annotations + +import argparse +import json +import sys + +from common.log import Colors, colored +from common.paths import ( + DIR_WORKFLOW, + DIR_TASKS, + FILE_TASK_JSON, + get_repo_root, + get_developer, + get_tasks_dir, + get_current_task, +) +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_set_meta, + cmd_add_subtask, + cmd_remove_subtask, +) +from common.task_context import ( + cmd_add_context, + cmd_validate, + cmd_list_context, +) + + +# ============================================================================= +# Command: start / finish +# ============================================================================= + +def cmd_start(args: argparse.Namespace) -> int: + """Set active task.""" + repo_root = get_repo_root() + task_input = args.dir + + if not task_input: + print(colored("Error: task directory or name required", Colors.RED)) + return 1 + + # Resolve task directory (supports task name, relative path, or absolute path) + full_path = resolve_task_dir(task_input, repo_root) + + if not full_path.is_dir(): + print(colored(f"Error: Task not found: {task_input}", Colors.RED)) + print("Hint: Use task name (e.g., 'my-task') or full path (e.g., '.trellis/tasks/01-31-my-task')") + return 1 + + # Convert to relative path for storage + try: + task_dir = full_path.relative_to(repo_root).as_posix() + except ValueError: + task_dir = str(full_path) + + task_json_path = full_path / FILE_TASK_JSON + + if not resolve_context_key(): + # Degraded mode: no session identity available. + # Hook didn't inject TRELLIS_CONTEXT_ID (common on Windows + Claude Code, + # --continue resume path, fork distribution, hooks disabled, etc.). Skip + # per-session pointer write; AI continues based on conversation context. + print(colored( + "ℹ Session identity not available; active-task pointer not persisted " + "this session (degraded mode). AI continues based on conversation context.", + Colors.YELLOW, + )) + print(colored( + "Hint: run inside an AI IDE/session that exposes session identity, " + "or set TRELLIS_CONTEXT_ID before running task.py start.", + Colors.YELLOW, + )) + + # Still flip task.json status: planning → in_progress so downstream phases proceed. + 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 (degraded)", Colors.GREEN)) + run_task_hooks("after_start", task_json_path, repo_root) + return 0 + + 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}") + + 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)) + + run_task_hooks("after_start", task_json_path, repo_root) + return 0 + else: + print(colored("Error: Failed to set current task", Colors.RED)) + return 1 + + +def cmd_finish(args: argparse.Namespace) -> int: + """Clear active task.""" + repo_root = get_repo_root() + active = clear_active_task(repo_root) + current = active.task_path + + if not current: + print(colored("No current task set", Colors.YELLOW)) + return 0 + + # Resolve task.json path before clearing + task_json_path = repo_root / current / FILE_TASK_JSON + + print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN)) + print(f"Source: {active.source}") + + if task_json_path.is_file(): + run_task_hooks("after_finish", task_json_path, repo_root) + return 0 + + +def cmd_current(args: argparse.Namespace) -> int: + """Show active task.""" + repo_root = get_repo_root() + active = resolve_active_task(repo_root) + + if getattr(args, "json", False): + task_obj = None + if active.task_path: + data = read_json(repo_root / active.task_path / FILE_TASK_JSON) or {} + task_obj = { + "dir": active.task_path, + "id": data.get("id") or data.get("name"), + "title": data.get("title"), + "status": data.get("status"), + "parent": data.get("parent"), + "children": data.get("children", []), + "branch": data.get("branch"), + "base_branch": data.get("base_branch"), + } + print(json.dumps({ + "current_task": task_obj, + "source": active.source, + "stale": active.stale, + }, ensure_ascii=False)) + return 0 if active.task_path else 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 + + if active.task_path: + print(active.task_path) + return 0 + + return 1 + + +# ============================================================================= +# Command: list +# ============================================================================= + +def _display_status(t, all_statuses: dict) -> str: + """Return the status label to show for a task in `list` output. + + A parent task's stored status stays "planning" until someone runs + `task.py start` on the parent directly, even while its children are + actively being worked — a misleading label for anyone scanning the + list (#399 item 3). Show "active" instead when at least one child is + past planning; the stored status.json value is left untouched. + """ + if t.status == "planning" and t.children: + child_in_flight = any( + all_statuses.get(c) not in (None, "planning") for c in t.children + ) + if child_in_flight: + return "active" + return t.status + + +def cmd_list(args: argparse.Namespace) -> int: + """List active tasks.""" + repo_root = get_repo_root() + tasks_dir = get_tasks_dir(repo_root) + current_task = get_current_task(repo_root) + developer = get_developer(repo_root) + filter_mine = args.mine + filter_status = args.status + as_json = getattr(args, "json", False) + + # 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()} + + if as_json: + if filter_mine and not developer: + print(json.dumps({"error": "No developer set"}), file=sys.stderr) + return 1 + + items = [] + for dir_name in sorted(all_tasks.keys()): + t = all_tasks[dir_name] + if filter_mine and (t.assignee or "-") != developer: + continue + if filter_status and t.status != filter_status: + continue + items.append({ + "dir": f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}", + "id": t.raw.get("id") or dir_name, + "title": t.title, + "status": t.status, + "display_status": _display_status(t, all_statuses), + "priority": t.priority, + "assignee": t.assignee or None, + "parent": t.parent, + "children": list(t.children), + "package": t.package, + }) + print(json.dumps({"tasks": items}, ensure_ascii=False)) + return 0 + + if filter_mine: + if not developer: + print(colored("Error: No developer set. Run init_developer.py first", Colors.RED), file=sys.stderr) + return 1 + print(colored(f"My tasks (assignee: {developer}):", Colors.BLUE)) + else: + print(colored("All active tasks:", Colors.BLUE)) + print() + + # Display tasks hierarchically + count = 0 + + def _print_task(dir_name: str, indent: int = 0) -> None: + nonlocal count + t = all_tasks[dir_name] + + # Apply --mine filter + if filter_mine and (t.assignee or "-") != developer: + return + + # Apply --status filter + if filter_status and t.status != filter_status: + return + + relative_path = f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}" + marker = "" + if relative_path == current_task: + marker = f" {colored('<- current', Colors.GREEN)}" + + # Children progress + progress = children_progress(t.children, all_statuses) + status_label = _display_status(t, all_statuses) + + # Package tag + pkg_tag = f" @{t.package}" if t.package else "" + + prefix = " " * indent + " - " + + if filter_mine: + print(f"{prefix}{dir_name}/ ({status_label}){pkg_tag}{progress}{marker}") + else: + print(f"{prefix}{dir_name}/ ({status_label}){pkg_tag}{progress} [{colored(t.assignee or '-', Colors.CYAN)}]{marker}") + count += 1 + + # Print children indented + for child_name in t.children: + if child_name in all_tasks: + _print_task(child_name, indent + 1) + + # Display only top-level tasks: those without a parent, plus orphans + # whose recorded parent is not (or no longer) in the active set — a + # dangling parent ref must still render flat instead of disappearing. + for dir_name in sorted(all_tasks.keys()): + parent = all_tasks[dir_name].parent + if not parent or parent not in all_tasks: + _print_task(dir_name) + + if count == 0: + if filter_mine: + print(" (no tasks assigned to you)") + else: + print(" (no active tasks)") + + print() + print(f"Total: {count} task(s)") + return 0 + + +# ============================================================================= +# Command: list-archive +# ============================================================================= + +def cmd_list_archive(args: argparse.Namespace) -> int: + """List archived tasks.""" + repo_root = get_repo_root() + tasks_dir = get_tasks_dir(repo_root) + archive_dir = tasks_dir / "archive" + month = args.month + + print(colored("Archived tasks:", Colors.BLUE)) + print() + + if month: + month_dir = archive_dir / month + if month_dir.is_dir(): + print(f"[{month}]") + for d in sorted(month_dir.iterdir()): + if d.is_dir(): + print(f" - {d.name}/") + else: + print(f" No archives for {month}") + else: + if archive_dir.is_dir(): + for month_dir in sorted(archive_dir.iterdir()): + if month_dir.is_dir(): + month_name = month_dir.name + count = sum(1 for d in month_dir.iterdir() if d.is_dir()) + print(f"[{month_name}] - {count} task(s)") + + return 0 + + +# ============================================================================= +# Help +# ============================================================================= + +def show_usage() -> None: + """Show usage help.""" + 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 create <title> --no-start Create without making it active in this session + 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 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 set-meta <dir> <key> <value> Set/overwrite a task metadata key + 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>] [--json] List tasks + python3 task.py list-archive [YYYY-MM] List archived tasks + +Monorepo options: + --package <pkg> Package name (validated against config.yaml packages) + +List options: + --mine, -m Show only tasks assigned to current developer + --status, -s <s> Filter by status (planning, in_progress, review, completed) + --json Output machine-readable JSON (also available on `current`) + +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 "Add login feature" --meta linear=ENG-123 --meta epic=auth + python3 task.py create "Child task" --slug child --parent .trellis/tasks/01-21-parent + 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 current --source + python3 task.py finish + python3 task.py archive add-login + python3 task.py add-subtask parent-task child-task # Link existing tasks + python3 task.py remove-subtask parent-task child-task + python3 task.py list # List all active tasks + python3 task.py list --mine # List my tasks only + python3 task.py list --mine --status in_progress # List my in-progress tasks +""") + + +# ============================================================================= +# Main Entry +# ============================================================================= + +def main() -> int: + """CLI entry point.""" + # 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 planning when needed.", + file=sys.stderr, + ) + print("See .trellis/workflow.md planning artifact guidance or run:", file=sys.stderr) + print( + " python3 ./.trellis/scripts/get_context.py --mode phase --step 1", + 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", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + subparsers = parser.add_subparsers(dest="command", help="Commands") + + # create + p_create = subparsers.add_parser("create", help="Create new task") + p_create.add_argument("title", help="Task title") + p_create.add_argument("--slug", "-s", help="Task slug without the MM-DD date prefix") + p_create.add_argument("--assignee", "-a", help="Assignee developer") + p_create.add_argument("--priority", "-p", default="P2", help="Priority (P0-P3)") + p_create.add_argument("--description", "-d", help="Task description") + p_create.add_argument("--parent", help="Parent task directory (establishes subtask link)") + p_create.add_argument("--package", help="Package name for monorepo projects") + p_create.add_argument( + "--base-branch", + help="PR target branch (overrides origin/HEAD detection and the checked-out-branch fallback)", + ) + p_create.add_argument( + "--meta", + action="append", + help="Task metadata key=value (repeatable)", + ) + p_create.add_argument( + "--no-start", + action="store_true", + help="Create the task without making it active in this session", + ) + + # 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)") + p_add.add_argument("path", help="File path to add") + p_add.add_argument("reason", nargs="?", help="Reason for adding") + + # validate + p_validate = subparsers.add_parser("validate", help="Validate context files") + p_validate.add_argument("dir", help="Task directory") + + # list-context + p_listctx = subparsers.add_parser("list-context", help="List context entries") + p_listctx.add_argument("dir", help="Task directory") + + # start + p_start = subparsers.add_parser("start", help="Set 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") + p_current.add_argument("--json", action="store_true", + help="Output machine-readable JSON") + + # finish + subparsers.add_parser("finish", help="Clear active task") + + # set-branch + p_branch = subparsers.add_parser("set-branch", help="Set git branch") + p_branch.add_argument("dir", help="Task directory") + p_branch.add_argument("branch", help="Branch name") + + # set-base-branch + p_base = subparsers.add_parser("set-base-branch", help="Set PR target branch") + p_base.add_argument("dir", help="Task directory") + p_base.add_argument("base_branch", help="Base branch name (PR target)") + + # set-scope + p_scope = subparsers.add_parser("set-scope", help="Set scope") + p_scope.add_argument("dir", help="Task directory") + p_scope.add_argument("scope", help="Scope name") + + # set-meta + p_setmeta = subparsers.add_parser("set-meta", help="Set/overwrite a task metadata key") + p_setmeta.add_argument("dir", help="Task directory") + p_setmeta.add_argument("key", help="Metadata key") + p_setmeta.add_argument("value", help="Metadata value") + + # archive + p_archive = subparsers.add_parser("archive", help="Archive task") + 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 + p_list = subparsers.add_parser("list", help="List tasks") + p_list.add_argument("--mine", "-m", action="store_true", help="My tasks only") + p_list.add_argument("--status", "-s", help="Filter by status") + p_list.add_argument("--json", action="store_true", help="Output machine-readable JSON") + + # add-subtask + p_addsub = subparsers.add_parser("add-subtask", help="Link child task to parent") + p_addsub.add_argument("parent_dir", help="Parent task directory") + p_addsub.add_argument("child_dir", help="Child task directory") + + # remove-subtask + p_rmsub = subparsers.add_parser("remove-subtask", help="Unlink child task from parent") + p_rmsub.add_argument("parent_dir", help="Parent task directory") + p_rmsub.add_argument("child_dir", help="Child task directory") + + # list-archive + p_listarch = subparsers.add_parser("list-archive", help="List archived tasks") + p_listarch.add_argument("month", nargs="?", help="Month (YYYY-MM)") + + args = parser.parse_args() + + if not args.command: + show_usage() + return 1 + + commands = { + "create": cmd_create, + "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, + "set-meta": cmd_set_meta, + "archive": cmd_archive, + "add-subtask": cmd_add_subtask, + "remove-subtask": cmd_remove_subtask, + "list": cmd_list, + "list-archive": cmd_list_archive, + } + + if args.command in commands: + return commands[args.command](args) + else: + show_usage() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.trellis/spec/blender/asset-generation.md b/.trellis/spec/blender/asset-generation.md new file mode 100644 index 0000000..2017503 --- /dev/null +++ b/.trellis/spec/blender/asset-generation.md @@ -0,0 +1,218 @@ +# 资产生成 + +> 适用:改动 Blender 侧的几何构建、材质、实例化,或往场景里加新资产。 +> 贯穿全篇的约束是两条:**压低对象数**(GLB 要能在浏览器里跑)和 +> **构建必须确定性**(parity 校验的前提)。 + +--- + +## MeshBatch:几何构建的主力 + +`mesh.py:1-9` 说明了它为什么存在:场景的绝大部分是平面多边形和拉伸棱柱, +**把它们批进一个 mesh datablock 能同时压低 Blender 对象数和导出 glTF 的节点数**。 + +用法固定为「累积 → 一次 `finish()`」: + +```python +batch = MeshBatch("Lake Surface", water_c, water_mat) +batch.add_polygon(ring, 0.10) +batch.finish() # water.py:12-14 +``` + +### 三条内建行为 + +1. **自动去掉重复的闭合点**(`mesh.py:41-42, 55-56`)。传闭合环或开放环都行, + 与 `geom.py` 的宽容度一致 +2. **退化输入静默返回**:`len(ring) < 3` 直接 return,不抛 +3. **空批次 `finish()` 返回 `None`**(`mesh.py:66-67`),不产生空对象 + +### 名字前缀是承重的 + +```python +# Foliage reads as blobby volume, so it wants smooth normals; the built +# environment wants its facets. The name prefix is the discriminator. +if self.name.startswith("Tree_") or self.name.startswith("Scrub_"): + for polygon in mesh.polygons: + polygon.use_smooth = True # mesh.py:72-77 +``` + +**改植被对象的命名前缀会静默改变着色**。加新的植被类资产时要么沿用 +`Tree_` / `Scrub_` 前缀,要么显式扩展这个判断。 + +### 单材质约束 + +一个 `MeshBatch` 只挂一个材质(`mesh.py:64`)。需要多材质就开多个 batch—— +这也正是[材质顺序决定 GLB 索引](../pipeline/layer-registry.md#顺序是承重的)的地方。 + +### 便捷包装 + +`make_prism` / `add_roof`(`mesh.py:83, 89`)是「单个形体」的一次性包装,内部就是 +`MeshBatch` + `finish()`。只放一个形体时用它们,批量累积时直接用 `MeshBatch`。 + +--- + +## 材质:声明与构建分离 + +``` +catalog.MATERIALS 声明「是什么」 纯 Python,无 bpy + │ + ▼ materials.from_spec(spec) materials.py:198 +真实的 bpy.types.Material 只在 Blender 内 +``` + +这个拆分让 catalog 能被任何不启动 Blender 的工具读取(`materials.py:3-7`)。 + +### `kind` 选构建器 + +| `kind` | 走哪条路 | 必填字段 | +|---|---|---| +| `solid` | `make_material()` | `name`、`color` | +| `textured` | `make_textured_material()` | `name`、`diffuse`、`normal`、`scale` | + +`solid` 可以再叠 `procedural`(噪声驱动的基色和凹凸,`from_spec` 里判断)。 +可选字段一律 `spec.get(key, 默认值)`——**加新的可选字段不要改已有条目**。 + +### 加一种材质 + +1. `catalog.MATERIALS` **末尾追加**一个条目(顺序决定 GLB 材质索引) +2. 若 `kind` 是 `textured`,贴图放 `assets/textures/`(`materials.py:14` 的 + `TEXTURE_ROOT`) +3. `generate_scene.py` 里用 `material_from_spec(catalog.MATERIALS["<key>"])` 取 +4. **若这个材质在 Cesium 里需要调色,去 `export_cesium.py` 的四张表加**—— + 见下文,那边按材质名字符串匹配 + +### 颜色是线性 RGB + +`catalog` 里的 `color` 是 Blender 的线性值,**不是** sRGB hex,也**不**从 +`scripts/lib/scene-layers.js` 换算。两套配色独立调过,理由见 +[图层表](../pipeline/layer-registry.md#为什么颜色刻意不同步)。 + +--- + +## 实例化:树 + +`tree.py:1-8` 的模式——**import 一次 → bake 朝向 → 每棵树只 link 一个轻对象复用 +同一个 datablock**: + +> Nothing is duplicated per tree, so the .blend and the exported GLB carry each +> mesh and each texture exactly once no matter how many trees are planted. + +`TreeVariant` 是 `(meshes, height, base_z)` 三元组(`tree.py:62-65`): + +- `height` — 变体自身的高度,目标高度除以它得到缩放系数 +- `base_z` — 变体自身的地面线,**取负乘以缩放**就能把树干落到 `z=0`, + 不管源文件把原点放在哪(`tree.py:301-303`) + +`assemble()` 返回种植数量,**0 表示模型缺失或 style 未知**,调用方据此回退到程序化 +树(`tree.py:277-279`)。 + +### 材质在本地重建,不沿用源文件 + +`tree.py:12-16`:两个 vendored 模型的材质都不能直接用。apple 的贴图 57% 是透明的 +(那是叶片卡),没有 alpha-clip 设置的话整个树冠会渲染成一块。 + +**vendored 资产的材质一律重建**,不要 `append` 源文件的材质。 + +### 已删除的第三种 style 有记录 + +`tree.py:18-25` 记着 `polyhaven` style 被删的原因(LOD1 对象不是整棵树,是给几何节点 +散布用的树枝和叶簇,直接种出来是一地树枝,连同 78MB 资产一起删了)。 + +**这类"试过、不行、为什么"的记录要保留。** 删掉它,下一个人会重新引入同一个资产。 + +--- + +## 确定性:用无理数周期代替 RNG + +这是全仓最容易被无意破坏的约定。`tree.py:290-292`: + +```python +# Irrational periods stand in for an RNG: no repeat over any realistic +# tree count, and a pure function of the index, so rebuilding an area +# plants the identical forest. +scale_wobble = 1.0 + SCALE_JITTER * math.sin(index * 2.399963) +yaw = ((index * GOLDEN_TURN) % 1.0) * math.tau +tilt_x = TILT_JITTER * math.sin(index * 1.114517) +tilt_y = TILT_JITTER * math.cos(index * 0.927295) +``` + +黄金角 `GOLDEN_TURN`(`tree.py:47-49`)让相邻的树朝向永不重复也永不成规律—— +一排树看起来像种的,不像盖章盖的。 + +**规则**:需要"随机"外观时,用 `index` 的纯函数(无理数周期 / 黄金角), +或者收一个显式 `seed`(`geom.sample_polygon_interior` 就是这么做的)。 + +**绝不要**用无种子的 `random` 或任何时间相关的量—— +重建同一片区域必须得到逐字节相同的结构,否则 +[parity 校验](../guides/artifact-parity-guide.md)永久性地红。 + +--- + +## Cesium 导出:一层独立的调色 + +`export_cesium.py:1-9`:创作用的场景刻意使用了一些 Blender 专有节点 +(草地 tint、程序化树冠变化),而 glTF 的材质词汇小得多。所以导出器 +**新建临时的、仅供导出的 PBR 材质**,展 UV,把引用到的图片全部内嵌进 GLB。 + +导出材质带 `EXPORT_PREFIX = "Cesium "` 前缀(`export_cesium.py:30`), +这样第二遍扫到实例化网格的共享材质槽时能认出自己的产物、跳过不重复处理。 + +模型保持在**局部 ENU 坐标系**(X 东、Y 北、Z 上),靠伴生 JSON 配合 +`Cesium.Transforms.eastNorthUpToFixedFrame` 摆放。 + +### 四张覆盖表(按材质名字符串) + +| 表 | 位置 | 作用 | +|---|---|---| +| `EXPORT_TINTS` | `:68` | 往某个颜色混合 | +| `EXPORT_METALLIC_OVERRIDES` | `:80` | 平铺的金属度覆盖 | +| `EXPORT_BASE_COLOR_OVERRIDES` | `:86` | 直接替换基色 | +| `EXPORT_EMISSION_OVERRIDES` | `:95` | 自发光兜底 | + +⚠️ `export_cesium.py` **不 import `catalog`**,靠材质名字符串匹配。 +`catalog.CESIUM_EXPORT` 是**死代码**。改材质名前先读 +[模块结构](./module-structure.md#已知现状两个入口靠材质名字符串对接)。 + +### 为什么新资产总是"发黑" + +`export_cesium.py:38-54` 记录了这个反复出现的问题: + +> Cesium 的默认光照偏白,**场景里每一个材质都被手工提亮过**——草往亮绿混 72%、 +> 带肋墙面往白混 86%、建筑自发光 0.18。一个没调过的新资产是唯一如实渲染的东西, +> 放在旁边就显得发黑。 + +所以**加新资产时,"它在 Blender 里看着对"不代表在 Cesium 里对**,必须去四张表里 +给它配一份调校。 + +抠图植被走的是另一套(`FOLIAGE_ALBEDO_GAIN = 2.1` + `FOLIAGE_SATURATION = 1.75`, +`:55, 66`),用**增益**而不是 tint——因为那是一张同时装着叶片、树皮、果实的图集, +往绿色混会把树干也染绿。增益保留色相关系,只把整体曝光抬到和邻居一致。 + +`FOLIAGE_EMISSION = 0.25` 的职责只是给背光面兜底,**不是主要提亮手段** +(`:32-36`)。想让植被更亮就调增益,别调自发光。 + +--- + +## 反模式 + +| 反模式 | 后果 | +|---|---| +| 每个形体建一个对象而不用 `MeshBatch` | 对象数与 glTF 节点数爆炸 | +| 改 `Tree_` / `Scrub_` 命名前缀 | 平滑着色静默失效 | +| 用无种子 `random` 或时间量做抖动 | parity 校验永久红 | +| 每棵树复制一份 mesh/贴图 | .blend 与 GLB 体积按棵数线性膨胀 | +| 直接 append vendored 资产的材质 | alpha-clip 缺失,树冠渲染成一块 | +| 删掉"试过不行"的注释 | 下一个人重新踩同一个坑 | +| 从 `scene-layers.js` 的 hex 换算 Blender 颜色 | 抹掉独立调过的配色 | +| 加新资产不配 Cesium 调色 | Cesium 里显得发黑 | +| 靠调 `FOLIAGE_EMISSION` 提亮植被 | 用错了旋钮,该调 albedo gain | +| 在 `MATERIALS` 中间插入条目 | GLB 材质索引整体平移 | + +--- + +## 相关 + +- [模块结构](./module-structure.md):往哪儿放新代码 +- [测试](./testing.md):纯几何部分怎么测 +- [图层表](../pipeline/layer-registry.md):道路九层的材质从哪来 +- [产物一致性指南](../guides/artifact-parity-guide.md):改完怎么验证产物没变 diff --git a/.trellis/spec/blender/index.md b/.trellis/spec/blender/index.md new file mode 100644 index 0000000..a96de0d --- /dev/null +++ b/.trellis/spec/blender/index.md @@ -0,0 +1,146 @@ +# Blender:Python 场景生成层 + +> 覆盖 `blender/**/*.py`。 +> 运行时:**两个**——Blender 内嵌 Python(bpy 层)和系统 Python(纯 Python 层)。 +> 这条内部边界是本层最重要的结构约束。 + +--- + +## 先读哪一篇 + +| 你要做的事 | 读 | +|---|---| +| 新建模块、挪代码、加一种 OSM 要素 | [模块结构](./module-structure.md) ← **先确认放在哪一层** | +| 改几何构建、材质、实例化、Cesium 调色 | [资产生成](./asset-generation.md) | +| 改 `geom.py` / `osm.py` 或加纯函数 | [测试](./testing.md) | +| 改材质名、动 `MATERIALS` 顺序 | [模块结构 · 材质名对接](./module-structure.md#已知现状两个入口靠材质名字符串对接) | +| 声称"纯重构,产物不变" | [产物一致性指南](../guides/artifact-parity-guide.md) | + +--- + +## 依赖分层 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 纯 Python 层 —— 无 bpy,系统 python 可跑可测 │ +│ │ +│ osmassets/osm.py OSM XML 解析 + 局部米制投影 │ +│ osmassets/geom.py 平面几何(米制) │ +│ osmassets/catalog.py 图层与材质的声明 │ +│ │ +│ ▲ blender/tests/test_pure.py 覆盖这一层(51 个用例) │ +└─────────────────────────────────────────────────────────┘ + ▲ 只能单向依赖 +┌─────────────────────────────────────────────────────────┐ +│ bpy 层 —— 只在 Blender 内运行,无单元测试 │ +│ │ +│ osmassets/mesh.py MeshBatch 等几何构建 │ +│ osmassets/materials.py 材质构建(消费 catalog 的声明) │ +│ osmassets/tree.py 树实例化 │ +│ osmassets/water.py grass.py scrub.py 要素装配 │ +│ │ +│ generate_scene.py export_cesium.py 两个入口 │ +│ tools/scene_digest.py 结构摘要工具 │ +│ │ +│ ▲ 回归防线是 parity 校验,不是单元测试 │ +└─────────────────────────────────────────────────────────┘ +``` + +**在纯 Python 层里 `import bpy` 会静默废掉整个测试套件**——它不会失败, +而是 import 阶段就崩,看起来像环境问题。 + +**推论**:能挪进纯 Python 层的逻辑就挪。一个函数只要不碰 `bpy`,放进 `geom.py` +就立刻获得被测试覆盖的资格。 + +--- + +## 两个入口 + +| | `generate_scene.py` | `export_cesium.py` | +|---|---|---| +| 行数 | 999 | 624 | +| 调用 | `--background --factory-startup --python` | `--background --python` | +| 输入 | `--osm` + `--geojson`(可选) | `--blend` | +| 输出 | `--output`(.blend)、`--render`(.png) | `--glb`、`--metadata`(.json) | +| 完成标记 | `SCENE_DONE` | `CESIUM_EXPORT_DONE` | +| 由谁调起 | `build-area.js` 的 `blender` 阶段 | `build-area.js` 的 `cesium` 阶段 | + +两个 stdout 标记是 [parity 契约](../guides/artifact-parity-guide.md)的一部分 +(`scripts/parity.js:121` 解析它们),**改动打印格式等于改动契约**。 + +### `--factory-startup` 只在 generate 阶段用 + +它屏蔽本机 Blender 的 preferences 和 addon,保证场景生成不受用户配置影响。 +副作用是脚本自己的目录不在 `sys.path` 上,所以两个入口开头都有那段 +`sys.path.insert` 样板 + `# noqa: E402`——**不是可以整理掉的坏味道**。 + +--- + +## 场景构建的输入约定 + +`generate_scene.py:9-12` 记录了一个容易踩的坑: + +> **范围只认 OSM 的 `bounds` 元素**,不用全部节点算包围盒。OSM 导出可能带上 +> 请求范围之外的 relation 成员,用全部节点会得到一个大得离谱的模型。 + +`--geojson` 是可选的。给了就用 osm2streets 的精细道路面、人行道、车道标线、 +斑马线;不给则回退到简单的 OSM `highway` 折线(`generate_scene.py:14-16`, +回退逻辑在 `:849`)。 + +植被映射(`generate_scene.py:18-20`): + +| OSM 标签 | 产物 | +|---|---| +| `natural=tree`(节点) | 单棵树 | +| `natural=tree_row`(way) | 等距成排的树 | +| `landuse=grass` | 绿地 + 可选草簇散布 | +| `natural=scrub` | 低矮灌木覆盖 | +| `amenity=fountain` | 低模喷泉水池 | + +--- + +## 三条贯穿全层的约定 + +1. **构建必须确定性** + 用 `index` 的纯函数(无理数周期 / 黄金角)或显式 `seed` 代替 RNG, + 绝不用无种子 `random` 或时间量。重建同一片区域必须得到相同结构, + 否则 parity 校验永久红。见[资产生成](./asset-generation.md#确定性用无理数周期代替-rng)。 + +2. **压低对象数** + `MeshBatch` 批处理 + 树实例化共享 datablock。GLB 要在浏览器里跑, + 节点数和贴图数都是硬成本。 + +3. **退化输入返回空,不抛异常** + 一个坏多边形不该中断整片区域的构建。要素模块 `len(ring) < 3` 直接返回 0, + `MeshBatch` 静默 return,`geom.py` 的函数返回 `[]`。 + +--- + +## 文件速查 + +| 文件 | 行数 | 层 | +|---|---|---| +| `generate_scene.py` | 999 | bpy · 入口 | +| `export_cesium.py` | 624 | bpy · 入口 | +| `osmassets/tree.py` | 318 | bpy | +| `osmassets/geom.py` | 241 | 纯 | +| `osmassets/materials.py` | 218 | bpy | +| `osmassets/catalog.py` | 195 | 纯 | +| `osmassets/mesh.py` | 126 | bpy | +| `osmassets/osm.py` | 89 | 纯 | +| `osmassets/water.py` / `grass.py` / `scrub.py` | 15 / 22 / 13 | bpy | +| `tools/scene_digest.py` | 171 | bpy · 工具 | +| `tests/test_pure.py` | 372 | 纯 · 测试 | + +`blender/README.md` 是面向使用者的运行说明,与本目录互补——**用法写那边, +改法写这边**。 + +--- + +## 技术选型现状 + +- **Blender 4.x**,`bpy` + `mathutils`;`export_cesium.py` 额外用 `numpy`(Blender 自带) +- **纯 Python 层只用标准库**(`math`、`json`、`os`、`xml.etree`), + 这是它能用系统 python 跑的前提——**不要给它加第三方依赖** +- **无类型标注、无 lint 配置**。保持现状;引入工具链是独立决定 +- **`unittest` 而非 pytest**,零依赖跑得起来 diff --git a/.trellis/spec/blender/module-structure.md b/.trellis/spec/blender/module-structure.md new file mode 100644 index 0000000..ef52969 --- /dev/null +++ b/.trellis/spec/blender/module-structure.md @@ -0,0 +1,185 @@ +# osmassets 模块结构 + +> 适用:在 `blender/` 下新增或移动代码。 +> 核心是一条**按依赖切的边界**——切错了,整个测试套件就跑不起来。 + +--- + +## 按依赖分包,不按功能 + +`osmassets/__init__.py:3-12` 写明了这个包的切分原则: + +> The package is split by dependency, not by feature: +> `osm` and `geom` are pure Python. They import no `bpy` and can be run and +> tested with a plain interpreter. Everything else may touch `bpy` and only +> runs inside Blender. + +``` +┌─ 纯 Python 层(无 bpy,可用系统 python 直接跑和测) +│ osmassets/osm.py OSM XML 解析 + 局部米制投影 +│ osmassets/geom.py 平面几何:裁剪、采样、面积、点在多边形内 +│ osmassets/catalog.py 图层与材质的声明(只有 json/os,无 bpy) +│ +└─ bpy 层(只能在 Blender 内运行) + osmassets/mesh.py MeshBatch、prism、polyline + osmassets/materials.py 材质构建 + osmassets/tree.py 树实例化 + osmassets/water.py grass.py scrub.py 要素装配 + generate_scene.py export_cesium.py 两个入口脚本 + tools/scene_digest.py 结构摘要工具 +``` + +**这条线是几何可测试的唯一前提。** 拆分之前,验证 `clip_polygon` 或 `sample_tree_row` +的唯一办法是渲染整片区域然后看图(`__init__.py:9-12`、`test_pure.py:5-8`)。 + +> **在纯 Python 模块里写 `import bpy` 会静默废掉 51 个单元测试**——它们不会失败, +> 而是 import 阶段就崩,看起来像环境问题。 + +`catalog.py` 属于纯 Python 层是刻意的:它只声明"是什么",让任何不启动 Blender 的 +工具也能读到场景的材质定义(`materials.py:3-7`)。 + +--- + +## 各模块职责 + +| 模块 | 层 | 职责 | +|---|---|---| +| `osm.py` | 纯 | `parse_osm()` 读 OSM XML → (bounds, ways, points);`Projector` 局部米制投影;`parse_height()`、`tags()` | +| `geom.py` | 纯 | 平面几何全家桶。**输入输出一律是投影后的米**,例外只有 `geometry_rings` / `feature_in_bounds`(收原始 GeoJSON 的经纬度) | +| `catalog.py` | 纯 | `ROAD_LAYERS`、`MATERIALS`、`road_material_specs()`、`check_layers()` | +| `mesh.py` | bpy | `MeshBatch`、`make_prism`、`add_roof`、`add_wall_panel`、`add_polyline`、集合管理 | +| `materials.py` | bpy | 把 `catalog` 的规格变成真实材质:`from_spec()`、贴图、程序化噪声、tint、alpha-clip | +| `tree.py` | bpy | 两个 vendored 模型 → 一套可实例化的运行时形状 | +| `water.py` / `grass.py` / `scrub.py` | bpy | 单一 OSM 要素的装配 | + +### `geom.py` 的两条隐含约定 + +- **环是 `(x, y)` 元组的列表**。重复的闭合点"到处容忍但从不要求" + (`geom.py:8-9`)——新函数要维持这个宽容度 +- **单位是米**,除非函数名另有说明 + +--- + +## 要素模块的统一形状 + +`water.py` / `grass.py` / `scrub.py` 三个要素模块签名一致: + +```python +def assemble(ring, [way_id,] scene_xmin, scene_xmax, scene_ymin, scene_ymax, + <颜色/材质...>, [回调...]): + ring = clip_polygon(ring, scene_xmin, scene_xmax, scene_ymin, scene_ymax) + if len(ring) < 3: + return 0[, ...] + ... + return <计数>[, <焦点用的点集>] +``` + +四条约定: + +1. **自己裁剪**。收边界参数而不是收已裁剪的环,不假设调用方做过 + (三个 `assemble` 的第一行都是 `clip_polygon`) +2. **退化输入返回零,不抛异常**。`len(ring) < 3` 直接返回计数 0 +3. **返回计数**供调用方汇总统计;需要参与相机取景的还返回点集(`grass.py` / `scrub.py` + 的 `focus`) +4. **不自己找数据**。ring 由 `generate_scene.py` 传入,模块只负责装配 + +### 加一种新 OSM 要素 + +目标形态(`docs/refactor-plan.md`):**新增一个模块 + 注册一行,不改 `build()`**。 + +1. 新建 `osmassets/<feature>.py`,写 `assemble(...)`,签名照抄上面 +2. 只 import 需要的:`from osmassets.geom import clip_polygon`、 + `from osmassets.mesh import MeshBatch` +3. 材质规格加进 `catalog.MATERIALS`(**追加到末尾**,顺序决定 GLB 材质索引) +4. `generate_scene.py` 的要素分发处加一行调用 +5. 纯几何部分若有新函数,放 `geom.py` 并**补 `blender/tests/test_pure.py`** + +--- + +## 两个入口脚本 + +| | `generate_scene.py` (999行) | `export_cesium.py` (624行) | +|---|---|---| +| 调用 | `--background --factory-startup --python` | `--background --python` | +| 输入 | `--osm` + `--geojson` | `--blend` | +| 输出 | `--output`(.blend) + `--render`(.png) | `--glb` + `--metadata`(.json) | +| 完成标记 | `SCENE_DONE` | `CESIUM_EXPORT_DONE` | + +**完成标记是 parity 契约的一部分**(`parity.js:121` 解析它们),改动打印格式等于改动 +契约。 + +### `sys.path` 那段样板不能删 + +两个脚本开头都有(`generate_scene.py:30-35`、`export_cesium.py:19-24`): + +```python +# --factory-startup does not put the script's own directory on sys.path, so the +# osmassets package next to this file is not importable without this. +_HERE = os.path.dirname(os.path.abspath(__file__)) +if _HERE not in sys.path: + sys.path.insert(0, _HERE) +``` + +因此其后的 import 全部带 `# noqa: E402`。这不是可以"整理"掉的坏味道。 + +### CLI 参数解析 + +两个脚本各有一份 `cli_args()`,都从 `--` 之后取参数: + +```python +argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] +``` + +`export_cesium.py:118-124` 对三个必填项逐个 `raise RuntimeError`。**必填项显式抛错, +不要给静默默认值**——Blender 子进程里一个错的默认路径会写到意想不到的地方。 + +--- + +## ⚠️ 已知现状:两个入口靠材质名字符串对接 + +**这是当前实际状态,不是设计目标。改动材质名之前必读。** + +`export_cesium.py` **不 import `catalog`**(它只 import +`from osmassets.materials import link_alpha_clip`)。它自己维护四张以**材质名字符串** +为键的覆盖表: + +| 表 | 位置 | +|---|---| +| `EXPORT_TINTS` | `export_cesium.py:68` | +| `EXPORT_METALLIC_OVERRIDES` | `:80` | +| `EXPORT_BASE_COLOR_OVERRIDES` | `:86` | +| `EXPORT_EMISSION_OVERRIDES` | `:95` | + +后果: + +- **改 `catalog.MATERIALS` 里的 `name` 会静默断开这些覆盖**。没有任何校验, + 材质只是悄悄退回未调过的样子 +- `catalog.CESIUM_EXPORT`(`catalog.py:142`)**是死代码**——定义了但全仓无人引用。 + 它是一次未完成的迁移,不要以为改它会生效 +- `"Office White Metal Facade"` 在四张表里都有,但 `catalog` 里**已无此材质** + (`docs/refactor-plan.md` 记为缺陷 D1,本轮只记录不修) + +**改材质名时**:四张表 + `catalog.MATERIALS` + `catalog.ROAD_LAYERS` 全部 grep 一遍。 + +--- + +## 反模式 + +| 反模式 | 后果 | +|---|---| +| 在 `osm.py` / `geom.py` / `catalog.py` 里 `import bpy` | 51 个单元测试整体崩,且像环境问题 | +| 按功能而非依赖新建模块(把几何和 bpy 混在一起) | 该几何从此不可测 | +| 删掉 `sys.path.insert` 样板或 `# noqa: E402` | Blender 里 import 不到 osmassets | +| 要素模块假设 ring 已裁剪 | 越界几何进场景 | +| 要素模块对退化输入抛异常 | 一个坏多边形中断整片区域 | +| 改材质名只改一处 | Cesium 侧调色静默失效 | +| 以为改 `catalog.CESIUM_EXPORT` 会影响导出 | 它是死代码 | + +--- + +## 相关 + +- [资产生成](./asset-generation.md):`MeshBatch`、材质、实例化 +- [测试](./testing.md):纯 Python 层怎么测 +- [图层表](../pipeline/layer-registry.md):`catalog.ROAD_LAYERS` 与 JS 侧的对账 +- [产物一致性指南](../guides/artifact-parity-guide.md):bpy 层的回归靠它兜底 diff --git a/.trellis/spec/blender/testing.md b/.trellis/spec/blender/testing.md new file mode 100644 index 0000000..a512b9d --- /dev/null +++ b/.trellis/spec/blender/testing.md @@ -0,0 +1,180 @@ +# 测试 + +> 适用:改动 `osmassets/osm.py`、`osmassets/geom.py`,或往里加新的纯函数。 + +--- + +## 怎么跑 + +```bash +python3 -m unittest discover blender/tests +``` + +**不需要 Blender**,用系统 Python 就行。当前 51 个用例,运行约 0.01 秒。 + +这是全仓唯一的自动化测试。快到没有理由不在每次改动后跑一遍。 + +能这么跑的前提是 `osmassets` 的[依赖分层](./module-structure.md)—— +`test_pure.py:19` 手动把 `blender/` 塞进 `sys.path`,然后只 import 纯 Python 模块。 + +--- + +## 最重要的一条:期望值必须从几何推导 + +`test_pure.py:9-11` 写得很直白: + +> The expected values are derived from the geometry, not captured from the +> implementation — **a test that just records current output would ratify a bug.** + +具体做法是在测试里写清楚**为什么**是这个数: + +```python +def test_half_outside_polygon_is_cut_at_the_boundary(self): + clipped = clip_polygon(SQUARE, 0.0, 5.0, 0.0, 10.0) + self.assertTrue(all(x <= 5.0 + 1e-9 for x, _ in clipped)) + # A 10x10 square clipped to half its width is a 5x10 rectangle. + self.assertAlmostEqual(polygon_area(clipped), 50.0, places=6) +``` + +反例——**不要这样写**: + +```python +def test_clip(self): + # 跑一遍把输出粘过来 + self.assertEqual(clip_polygon(SQUARE, 0.0, 5.0, 0.0, 10.0), + [(0.0, 0.0), (5.0, 0.0), (5.0, 10.0), (0.0, 10.0)]) +``` + +这种测试在实现正确时和实现错误时**同样会通过**。它固化的是当前行为,不是需求。 + +**自检**:把被测的那个特性从实现里删掉,测试还能过吗?能过就是无效测试。 + +--- + +## 每个几何函数都要覆盖退化输入 + +这是本套测试最系统的部分。`geom.py` 的函数会收到真实 OSM 数据里的各种畸形几何, +所以每个函数都有一个 `test_degenerate_input`: + +| 退化情形 | 例子 | +|---|---| +| 空输入 | `clip_polygon([], ...)` → `[]` (`:73`) | +| 点数不足成面 | `clip_polygon([(0,0),(1,1)], ...)` → `[]` (`:74`) | +| 完全在裁剪框外 | `clip_polygon(SQUARE, 20,30,20,30)` → `[]` (`:70`) | +| 零长线段 | `sample_tree_row` 跳过而非除零 (`:203`) | +| 重复顶点 | `distance_to_ring` 不除零 (`:178`) | +| 参数为零 | `sample_ring_boundary(SQUARE, spacing=0.0)` → `[]` (`:123`) | +| 单点输入 | `sample_polygon_interior([(0,0)], ...)` → `[]` (`:146`) | + +**加新几何函数就配一个 `test_degenerate_input`。** 约定是"返回空/零"而不是抛异常—— +一个坏多边形不该中断整片区域的构建。 + +### 除零守卫要指名道姓 + +覆盖某个具体守卫时,注释写清楚针对哪一行: + +```python +def test_axis_aligned_edge_does_not_divide_by_zero(self): + # A vertical edge crossing the x clip plane exercises the b[0] == a[0] + # guard in the intersection lambdas. +``` + +这样守卫被误删时,失败的测试能直接说明它保护的是什么(`test_pure.py:76-78`)。 + +--- + +## 其余几条约定 + +### 随机采样必须验 seed 可复现 + +```python +def test_seed_is_deterministic(self): + first = sample_polygon_interior(SQUARE, spacing=3.0, seed=7) + second = sample_polygon_interior(SQUARE, spacing=3.0, seed=7) + self.assertEqual(first, second) +``` + +不可复现的采样会让 [parity 校验](../guides/artifact-parity-guide.md)永久性地红。 +任何带随机的新函数都要收 `seed` 参数并配这个测试(`test_pure.py:135-138`)。 + +### 绕向无关的性质要两个方向都测 + +`sample_ring_boundary` 的 `inset` 对顺时针和逆时针都必须往内缩: + +```python +ccw = sample_ring_boundary(SQUARE, spacing=10.0, inset=1.0) +cw = sample_ring_boundary(list(reversed(SQUARE)), spacing=10.0, inset=1.0) +self.assertTrue(all(point_in_polygon((x, y), SQUARE) for x, y, _, _ in ccw)) +self.assertTrue(all(point_in_polygon((x, y), SQUARE) for x, y, _, _ in cw)) +``` + +同理 `polygon_area` 对绕向不敏感、`signed_polygon_area` 敏感,两者分别验 +(`test_pure.py:88-91, 109-115`)。 + +### 跨边界的连续性要单独测 + +真实几何常见的坑是"分段处理时每段各自重新开始": + +```python +def test_spacing_carries_across_segment_joins(self): + # Two 3m segments with 4m spacing: the second sample must land 1m into + # the second segment, not restart at its origin. +``` + +(`test_pure.py:190-196`) + +### 解析器的容错语义要写死 + +`parse_osm` 的两档行为必须都有测试(`test_pure.py:343-356`): + +- **坏节点跳过,不致命**:`lon='oops'` 的节点被忽略,其余照常解析 +- **缺 `bounds` 直接抛 `RuntimeError`**:没有 bounds 就无法建立投影,继续下去毫无意义 + +分档理由见 `generate_scene.py:9-12`:OSM 导出可能带上区域外的 relation 成员, +所以范围只认 `bounds` 元素,不用全部节点算包围盒。 + +### 用真实格式的 fixture + +`OSM_SAMPLE`(`test_pure.py:285`)是一段真的 OSM XML,故意塞进了坏节点、 +`action='delete'` 的 way、引用了不存在节点的 way。写进临时文件再解析, +`tearDown` 里 `os.unlink`。 + +**别 mock 解析器。** 解析器的价值就在于处理真实世界的脏数据。 + +--- + +## 命名 + +- 类名 = 被测函数的驼峰 + `Test`:`ClipPolygonTest`、`SampleTreeRowTest` +- 方法名是**陈述句,说明这个行为是什么**,不是 `test_case_1`: + `test_polygon_keeps_only_the_exterior_ring`、 + `test_trailing_point_is_skipped_when_it_would_double_plant` + +方法名读起来就是这个函数的规格说明。 + +--- + +## 测不到的部分怎么办 + +`bpy` 层(`mesh.py`、`materials.py`、`tree.py`、两个入口脚本)**没有单元测试**, +也不打算有——它们需要真实的 Blender 运行时。 + +这一层的回归防线是 **parity 校验**:结构摘要比对,而不是单元测试。 +见[产物一致性指南](../guides/artifact-parity-guide.md)。 + +**推论**:能挪进纯 Python 层的逻辑就挪。一个函数只要不碰 `bpy`, +放进 `geom.py` 就立刻获得测试覆盖的资格。 + +--- + +## 反模式 + +| 反模式 | 后果 | +|---|---| +| 把当前输出粘成期望值 | 实现有 bug 时测试照样绿 | +| 新几何函数不配退化输入测试 | 真实数据一进来就崩 | +| 退化输入抛异常而不是返回空 | 一个坏多边形中断整片区域 | +| 带随机的函数不收 `seed` | parity 校验永久红 | +| mock 掉 OSM 解析 | 测不到它唯一的价值 | +| 在测试里 import `bpy` 侧模块 | 整个套件无法运行 | +| 只测一种绕向 | 反向多边形进来才发现 | diff --git a/.trellis/spec/config/index.md b/.trellis/spec/config/index.md new file mode 100644 index 0000000..d6efe37 --- /dev/null +++ b/.trellis/spec/config/index.md @@ -0,0 +1,187 @@ +# Config:区域配置 + +> 覆盖 `config/areas/*.json`、`config/examples/template.json`、`config/default.json`。 +> 无运行时——这是**管线三层共同消费的契约**,改一个字段会同时影响 Node、Blender、 +> 预览页。 + +--- + +## 两层配置 + +用户只写第一层,第二层是机器生成的中间产物: + +``` +config/areas/<id>.json ← 你写的 + │ build-area.js: normalizeAreaConfig() 补默认值 + 推导 14 个输出路径 + ▼ +<areaDir>/_pipeline/osm2streets-qgis.config.json ← 生成的,不要手改 + │ + ▼ build-osm2streets-qgis.js / reimport-gpkg.js +``` + +派生配置落在 `_pipeline/` 而不是临时目录——**构建失败时它还在**,可以直接拿去复现。 + +`config/default.json` 和 `config/hanyang-block.json` 是**低层脚本** +(`npm run build:qgis`)用的旧格式配置,与 `config/areas/` 不是一回事。 +新工作一律用 `config/areas/`。 + +--- + +## 新增一个区域 + +```bash +cp config/examples/template.json config/areas/my-area.json +``` + +改 `id` 和 `input` 就能跑。其余全有默认值。 + +--- + +## 字段全表 + +### 顶层 + +| 字段 | 必填 | 默认 | 说明 | +|---|---|---|---| +| `id` | ✅ | — | 区域标识。**同时是默认输出目录名和全部产物的文件名 stem** | +| `input` | ✅ | — | OSM XML 的**绝对路径**。不存在直接抛错 | +| `outputRoot` | | `<repo>/outputs` | 输出根目录 | +| `qgisApp` | | `/Applications/QGIS.app` | 也可用环境变量 `QGIS_APP` | +| `blenderApp` | | `/Applications/Blender.app` | | +| `stages` | | 见下 | 各阶段默认开关 | +| `qgis` | | 见下 | QGIS/osm2streets 旋钮 | +| `osm2streets` | | 见下 | 透传给 osm2streets 的选项 | +| `blender` | | 见下 | Blender 侧选项 | +| `outputs` | | 从 `id` 推导 | 输出路径覆盖,逃生舱 | + +**路径一律绝对**。`normalizeAreaConfig` 对每一项都做 `path.resolve`,相对路径会 +相对于**进程 cwd** 解析,不是相对于配置文件——所以别用。 + +### `stages` + +| 字段 | 默认 | 说明 | +|---|---|---| +| `intermediates` | `true` | 旧名 `qgis` 仍被接受 | +| `blender` | `true` | | +| `cesium` | `true` | | + +`reimport` 和 `preview` **在这里配也没用**——`normalizeAreaConfig:117-118` 把它们 +硬编码为 `false`,只能靠 `--stages` 显式请求。 + +> 恢复动作(reimport)和补丁动作(preview)不该被一份配置文件变成默认行为。 + +`--stages` 会整体覆盖这里的默认值。 + +### `qgis` + +| 字段 | 默认 | 说明 | +|---|---|---| +| `arrowScale` | `0.8` | 导出前对 osm2streets 车道箭头多边形的缩放 | +| `arrowMergeTriangles` | `true` | 把箭头的三角网合并成一个合法多边形。**保留原箭头形状和转向**,同时消掉共享三角边处的渲染缝隙 | +| `arrowOutlineSimplifyMeters` | `0.05` | 去掉合并后箭头外轮廓上的亚分米级折角。默认值刚好去掉两个畸形尾顶点而**不动箭头头部**,剩下的尾边与杆身垂直 | +| `intersectionCornerSourceMaxDimensionMeters` | `2.6` | 只保留小尺寸的 `sidewalk corner` 多边形。**大的路口标记多边形不当人行道处理**,因为它们会盖住可行驶的路口 | +| `clipPad` | `0.002` | 送进 osm2streets 的裁剪框外扩(度) | +| `canvasPad` | `0.001` | QGIS 画布范围外扩(度) | +| `previewPad` | `0.0007` | 预览图范围外扩(度) | +| `canvasExtent` | `null` | 显式画布范围,覆盖 `canvasPad` | +| `previewExtent` | `null` | 显式预览范围,覆盖 `previewPad` | +| `layerPrefix` | `"osm2streets"` | QGIS 图层名前缀 | + +三个 pad 单位是**度不是米**,且必须 `>= 0`(`build-osm2streets-qgis.js:49-53` 校验)。 +`arrowScale` 必须 `> 0`。 + +> 这四个 arrow/corner 旋钮的默认值都是调出来的,**改之前先看 README 里记的理由**。 +> 尤其 `arrowOutlineSimplifyMeters`——调大会开始削箭头头部。 + +### `osm2streets` + +原样透传给 `JsStreetNetwork` 构造函数(`build-osm2streets-qgis.js:77`)。默认: + +```json +{ + "debug_each_step": false, + "dual_carriageway_experiment": false, + "sidepath_zipping_experiment": false, + "inferred_sidewalks": true, + "osm2lanes": true +} +``` + +⚠️ **给了就整体替换,不做逐字段合并**(`build-area.js:132`:`raw.osm2streets || {...}`)。 +只想改一个开关也必须把五个字段全写上,否则其余四个会退到 osm2streets 自己的默认值。 + +### `blender` + +| 字段 | 默认 | 说明 | +|---|---|---| +| `treeStyle` | `"natural"` | 合法值见 `generate_scene.py:144` 的 `TREE_STYLES`(`natural`、`procedural`,加上 `tree.py` 注册的模型 style) | +| `officeOverrides` | `""` | 旧名 `office_overrides` 仍被接受 | + +### `outputs`(逃生舱) + +默认全部从 `id` 推导为 `<outputRoot>/<id>/<fileStem>.<ext>`。需要定制时逐项覆盖: + +```json +{ + "outputs": { + "areaDir": "/absolute/path/to/custom-area", + "blend": "/absolute/path/to/custom.blend", + "glb": "/absolute/path/to/custom.glb", + "cesiumPreview": "/absolute/path/to/custom-preview.html" + } +} +``` + +可覆盖的键(`build-area.js:87-102`):`areaDir`、`fileStem`、`geojsonDir`、`gpkg`、 +`qgisProject`、`qgisPreview`、`blend`、`render`、`glb`、`metadata`、`cesiumPreview`、 +`vehicleRoute`、`vehicleModel`、`pipelineDir`。 + +**优先改 `fileStem` 或 `areaDir`**——它们能一次性影响全部派生路径。逐个覆盖容易漏。 + +--- + +## 加一个配置字段 + +1. `normalizeAreaConfig`(`build-area.js:74`)里加进对应的分组,**用 `??` 不用 `||`** + (`false` / `0` 可能是合法值) +2. 只写两级 fallback:`raw.<group>?.<key> ?? 默认值`。 + **不要**制造新的顶层平铺别名——那三级写法是历史兼容,不是模式 +3. 若要传给低层脚本,加进 `writeDerivedConfig`(`:189`)的 `derivedConfig` 对象 +4. 若是数值,在消费侧加 `Number.isFinite` + 范围校验,**在任何副作用之前** +5. 更新 `config/examples/template.json` +6. 更新本文档的字段表 + +若新字段产出新文件,同时在 `outputs` 里加一行路径推导。 + +--- + +## 已沉淀的区域 + +- `config/areas/nantaizi-lake-innovation-valley.json`(默认构建目标) +- `config/areas/hanyang-block.json` + +两者都是 parity 校验的样本区域(`parity.js:26` 的 `DEFAULT_AREAS`)—— +**改动它们会影响基线比对**。 + +--- + +## 反模式 + +| 反模式 | 后果 | +|---|---| +| 用相对路径 | 相对 cwd 解析,换个目录跑就错 | +| 手改 `_pipeline/*.config.json` | 下次构建被覆盖 | +| 只写 `osm2streets` 的一个字段 | 其余四个静默退到 osm2streets 默认值 | +| 布尔字段用 `\|\|` 兜底 | `false` 被翻转 | +| 给新字段造顶层平铺别名 | 扩大历史包袱 | +| 逐个覆盖 `outputs` 而不用 `fileStem` | 漏掉某个产物路径 | +| 在 `stages` 里配 `reimport` / `preview` | 无效,被硬编码为 false | +| 加数值字段不做范围校验 | 错配置在中途才崩,输出已被破坏 | + +--- + +## 相关 + +- [CLI 与阶段](../pipeline/cli-and-stages.md):配置怎么被读取和派生 +- [外部工具调用](../pipeline/external-tools.md):`qgisApp` / `blenderApp` 怎么用 +- README「区域配置」节:面向使用者的说明 diff --git a/.trellis/spec/guides/artifact-parity-guide.md b/.trellis/spec/guides/artifact-parity-guide.md new file mode 100644 index 0000000..55fb112 --- /dev/null +++ b/.trellis/spec/guides/artifact-parity-guide.md @@ -0,0 +1,201 @@ +# 产物一致性(Parity)指南 + +> **触发条件**:任何声称"纯重构、产物不变"的改动。 +> +> 这条管线的产物是 `.blend` / `.glb` / `.png`——**二进制、无法 code review、 +> 肉眼看不出 5% 的几何漂移**。parity 校验是这一层唯一的回归防线。 + +--- + +## 为什么不能直接比字节 + +三类文件全都**不是位级可复现**的,同一份代码跑两次就会不一样: + +| 产物 | 为什么不稳定 | +|---|---| +| `.blend` | 内嵌绝对路径;图片按哈希表顺序打包 | +| `.png` | EEVEE 渲染非位级可复现 | +| `.glb` | glTF 导出器会去重相同的 accessor,而 `smart_project` 的 UV 带浮点噪声——实测两次跑出 399 vs 398 个 accessor、差 720 字节,而 node/mesh/primitive/material/image **完全一致** | + +所以比对的是**结构摘要**,不是字节。 + +--- + +## 三件套 + +| 工具 | 位置 | 作用 | +|---|---|---| +| 场景摘要 | `blender/tools/scene_digest.py` | 在 Blender 内打开 `.blend`,输出稳定 JSON:对象名/顶点数/面数/材质槽/自定义属性、材质参数、场景属性 | +| GLB 摘要 | `scripts/glb-digest.js` | 纯 Node 读 GLB 的 JSON chunk,输出 node/mesh/material 清单与 PBR 参数,附 buffer 字节长度 | +| 驱动 | `scripts/parity.js` | 跑构建 → 采集摘要 → 快照 / 比对 | + +```bash +node scripts/parity.js capture <label> [--areas a,b] [--stages blender,cesium] +node scripts/parity.js compare <labelA> <labelB> +``` + +基线落在 `outputs/_refactor-baseline/<label>/`,在 `.gitignore` 里—— +**本地草稿,不是产物,不入库**(`parity.js:16-17`)。 + +默认样本区域两个(`parity.js:26`):`nantaizi-lake-innovation-valley`(主, +OSM + osm2streets GeoJSON 齐全)、`hanyang-block`(次)。 + +--- + +## 必须先做 control 实验 + +**这是最容易被跳过、跳过之后整个校验就是假的一步。** + +用**未改动**的代码连跑两次,diff 两份摘要。这一步确定哪些字段天然不确定, +把它们列入忽略名单。 + +```bash +node scripts/parity.js capture control-1 +node scripts/parity.js capture control-2 +node scripts/parity.js compare control-1 control-2 # 必须全绿 +``` + +没做这步就开始改代码,你会分不清一个差异是"重构引入的 bug"还是"本来就每次都不一样"。 + +已完成的 control 结论(`docs/refactor-plan.md`): + +- `.blend` **结构摘要两次完全一致** ← 这是主校验信号,可信 +- `.blend` 文件 sha256 不一致 +- 渲染 PNG sha256 不一致 +- GLB 结构(node / mesh / primitive / material / image)两次完全一致, + 但 accessor 数 399 vs 398、buffer 差 720 字节 + +--- + +## 忽略名单:必须附理由 + +`parity.js:25-54` 的 `IGNORED_PATHS`,每一条上面都写着为什么被忽略: + +``` +files.blend.sha256 / files.glb.{sha256,bytes} / files.render.{sha256,bytes} +glbDigest.fileBytes / glbDigest.buffers / glbDigest.counts.accessors +capturedAt / durationMs / label +``` + +这些字段**仍然被记录**——人读快照时想看到它们——只是不参与比对 +(`parity.js:25-27`)。 + +> **往忽略名单里加东西是有代价的动作。** +> 加之前先确认这个字段是**真的**每次都变(用 control 实验证明), +> 而不是你的改动让它变了。注释里必须写清楚证据。 + +--- + +## 真正的契约 + +忽略名单之外剩下的就是契约,**改动它们 = 改动产物**: + +| 契约项 | 谁产生 | +|---|---| +| `SCENE_DONE` stdout 标记及其 JSON 内容 | `generate_scene.py` | +| `CESIUM_EXPORT_DONE` stdout 标记及其 JSON 内容 | `export_cesium.py` | +| `.blend` 全量结构摘要(对象、网格、材质、自定义属性) | `scene_digest.py` | +| GLB 的 node / mesh / material / image 结构 | `glb-digest.js` | +| `<area>.json` 放置元数据 | `export_cesium.py` | + +**改动 stage 的打印格式会静默破坏 parity 契约**——`parity.js:118-121` 解析这两个标记。 + +--- + +## 摘要工具本身的两条约定 + +改 `scene_digest.py` / `glb-digest.js` 时: + +1. **浮点数四舍五入到 6 位**(`scene_digest.py:17-18`)。Blender 会把浮点数 + round-trip 过单精度,repr 的最后几位不是有意义的信号 +2. **不稳定字段属于 `UNSTABLE_*` / `IGNORED_*` 名单,不属于摘要** + (`scene_digest.py:11-13`)。Blender 自己加的对象自定义属性 + (`_RNA_UI`、`cycles`)就是这么排除的(`scene_digest.py:24-25`) + +> 否则校验就是噪音,然后就会被忽略。一个天天报红的检查等于没有检查。 + +--- + +## 什么时候必须跑 + +| 改动 | 要不要跑 | +|---|---| +| 挪函数、拆模块、改导入 | **必须**——纯重构的定义就是产物不变 | +| 调整 `ROAD_LAYERS` / `MATERIALS` 的**顺序** | **必须**——会平移 GLB 材质索引 | +| 改材质名 | **必须**——可能静默断开 Cesium 侧的四张覆盖表 | +| 改几何构建、采样、实例化逻辑 | **必须** | +| 改 stage 的 stdout 打印 | **必须**——标记本身是契约 | +| 改 `.trellis/` 下的文档 | 不用 | +| 改 README / changelog | 不用 | +| **有意**改变产物(新功能、修渲染 bug) | 跑,但目的是**看清差异范围**,不是要全绿 | + +最后一行很重要:parity 不只是"证明没变"的工具,也是"确认只变了预期的那部分"的工具。 +加一种新植被,应该只看到新增对象,不该看到道路网格的顶点数也动了。 + +--- + +## 有意改变产物时怎么做 + +1. 先 capture 一份改动前的基线 +2. 改 +3. capture 改动后 +4. compare,**逐条读差异** +5. 差异要么是预期的,要么就是 bug——**没有第三种** +6. 把结论写进 `docs/changelog.md` + +--- + +## 风险高的改动要分次提交 + +`docs/refactor-plan.md` 对风险最高的一期写着: + +> 逐要素分次提交,每次单独跑 parity。 + +一次改十个要素然后发现摘要有差异,你不知道是哪个引起的。**一次一个,每次跑校验。** + +--- + +## 当前重构进度(`docs/refactor-plan.md`) + +那份计划是**临时工作文档**,P3 收尾后会并入 changelog 并删除。当前状态: + +| 期 | 内容 | 状态 | +|---|---|---| +| P0 | 抽纯函数到 `osmassets/{osm,geom}.py` | ✅ 已完成 | +| P1 | `catalog.py` 单一定义源 + `check_layers` | ✅ 已完成 | +| P2 | 要素注册表 | ⚠️ **部分**——`water/grass/scrub/tree.py` 已拆出,但**没有 `features/` 注册表**,`building` / `fountain` / `roads` 仍在 `generate_scene.py` 里 | +| P3 | 材质契约化(自定义属性传递 spec) | ❌ **未做**——`export_cesium.py` 仍不 import `catalog`,靠四张材质名表;`catalog.CESIUM_EXPORT` 是死代码 | + +### 已知缺陷(记录在案,本轮不修) + +| # | 位置 | 现象 | +|---|---|---| +| D1 | `export_cesium.py:74,82,91,100` | `"Office White Metal Facade"` 四张表里都有,但 `catalog` 里已无此材质——死条目 | +| D2 | `scene-layers.js` vs `catalog.py` | 同一批图层的颜色两侧各自手调,无一致性保证(**这是刻意的**,见[图层表](../pipeline/layer-registry.md#为什么颜色刻意不同步)) | +| D3 | `generate_scene.py` `tuft_density_wave` | 注释仍在跟已删除的 hedge banding 作对比 | + +**碰到它们不要顺手修**——修复会改变产物或扩大 diff,属于独立决定。 + +--- + +## 反模式 + +| 反模式 | 后果 | +|---|---| +| 跳过 control 实验直接开始改 | 分不清真回归和天然噪声 | +| 因为"老是报红"往忽略名单里加字段 | 把真回归一起忽略掉 | +| 忽略名单不写理由 | 下一个人无法判断该不该移出来 | +| 直接 diff 文件字节 | 永远红,然后所有人都不看了 | +| 一次改十个地方再跑校验 | 差异定位不到具体改动 | +| 改 stage 打印格式 | 静默破坏契约 | +| 摘要里保留不稳定字段 | 检查变噪音,最终被忽略 | +| 顺手修 D1–D3 | 改变产物或扩大 diff | + +--- + +## 相关 + +- [模块结构](../blender/module-structure.md):bpy 层为什么没有单元测试 +- [测试](../blender/testing.md):纯 Python 层的防线 +- [资产生成](../blender/asset-generation.md):为什么构建必须确定性 +- [图层表](../pipeline/layer-registry.md):顺序为什么是承重的 diff --git a/.trellis/spec/guides/code-reuse-thinking-guide.md b/.trellis/spec/guides/code-reuse-thinking-guide.md new file mode 100644 index 0000000..4aa219e --- /dev/null +++ b/.trellis/spec/guides/code-reuse-thinking-guide.md @@ -0,0 +1,158 @@ +# 代码复用思考指南 + +> 目的:在新增 helper、常量、配置字段或枚举表之前,先判断这个项目里"应该复用"和 +> "刻意重复"的边界。这里的关键不是追求抽象,而是避免事实漂移。 + +--- + +## 先搜索,再决定 + +改任何值或新增类似逻辑前先跑: + +```bash +grep -rn "关键字或现有值" scripts blender config +``` + +本项目的重复有两类: + +- **危险重复**:同一事实被多处维护,漏改会静默错产物 +- **可接受重复**:运行时边界不同或独立入口需要保留,抽象会扩大耦合 + +判断之前不要凭直觉抽取。 + +--- + +## 必须复用的事实源 + +### 九个 osm2streets 图层 + +JS 侧只认 `scripts/lib/scene-layers.js:15` 的 `SCENE_LAYERS`。 +需要文件名、合并场景、style JSON、QGIS 颜色时,使用同文件导出的派生函数: + +- `layerFile(layer)`(`scene-layers.js:103`) +- `mergeScene(getCollection)`(`scene-layers.js:109`) +- `sceneStyle()`(`scene-layers.js:126`) +- `qgisRgba(hex, alpha)`(`scene-layers.js:143`) + +不要在 `build-osm2streets-qgis.js`、`reimport-gpkg.js` 或 QGIS 项目生成代码里再枚举 +九个图层。旧问题正是同一顺序复制到四处,漏一处不报错,只让 Blender/Cesium 场景错栈。 + +Python 侧必须有 `blender/osmassets/catalog.py:28` 的 `ROAD_LAYERS`,因为它还声明 +Blender 高度与线性颜色。两侧靠 `catalog.check_layers()` 对账集合和顺序;颜色故意不同步。 + +### 区域输出路径 + +输出路径只在 `scripts/build-area.js:74` 的 `normalizeAreaConfig()` 推导。 +低层脚本读取 `_pipeline/osm2streets-qgis.config.json`,不要重新读取 +`config/areas/*.json` 或在阶段函数里现场拼路径。 + +新增产物时,在 `normalizeAreaConfig` 的 `outputs` 里加一项,再按需写入 +`writeDerivedConfig()`(`build-area.js:189`)。这样 `intermediates`、`reimport`、 +`blender`、`cesium`、`preview` 仍然只通过磁盘产物耦合。 + +### 材质声明 + +Blender 内材质声明集中在 `catalog.MATERIALS`(`catalog.py:56`)。 +真实 `bpy.types.Material` 由 `materials.from_spec()`(`materials.py:198`)构建。 + +注意当前有一个未完成迁移:`export_cesium.py:68`、`:80`、`:86`、`:95` 的四张表 +仍按材质名字符串匹配。改材质名时不能只改 `catalog`;必须全仓 grep 材质名。 + +--- + +## 可接受的重复 + +### 三份 `parseArgs` + +`parseArgs` 现在重复在三个独立入口: + +- `scripts/build-area.js:50` +- `scripts/build-osm2streets-qgis.js:153` +- `scripts/reimport-gpkg.js:93` + +语义一致:`--kebab-case value` 变 `kebabCase: "value"`,无值 flag 变字符串 `"true"`。 + +这份重复目前是可接受技术债,因为三个脚本都能独立运行。改其中一处解析语义时,不要顺手 +只改一份;要么保持三份一致,要么把"抽公共模块"作为独立重构并跑 parity。 + +### JS 与 Python 的图层颜色 + +`scene-layers.js` 的颜色是 QGIS 2D 调试 sRGB hex;`catalog.py` 的颜色是 Blender +线性 RGB。`catalog.py:11-15` 明确说颜色不是同步目标。 + +把两边颜色抽成同一个表不是复用,是破坏两个运行时各自调过的视觉结果。 + +--- + +## 重复模式检查 + +### 看到第二份枚举表 + +问: + +- 这份表是否已经能从 `SCENE_LAYERS`、`ROAD_LAYERS`、`MATERIALS` 或配置派生? +- 如果必须跨语言重复,是否已有对账机制? +- 追加顺序是否影响 GLB 材质索引? + +没有对账机制的重复表必须特别谨慎。材质名覆盖就是当前已知风险: +`generate_scene.py` 创建材质,`export_cesium.py` 靠字符串覆盖,没有校验。 + +### 看到多个模块同样预处理 + +`water.py:9`、`grass.py:9`、`scrub.py:8` 都调用 `clip_polygon`,这是对要素模块签名的 +统一要求:模块接收边界、自己裁剪、退化输入返回 0。 + +新增第四个要素模块时先照这个形状写,不要把裁剪逻辑上移到调用方。否则旧模块和新模块 +的边界会不同,真实 OSM 的越界几何会按要素类型表现不一致。 + +### 看到多个地方解析同一格式 + +优先找已有解析器: + +- OSM XML → `osmassets/osm.py:parse_osm()` +- 米制几何 → `osmassets/geom.py` +- GeoJSON 场景合并 → `scene-layers.js:mergeScene(getCollection)` +- 区域配置 → `build-area.js:normalizeAreaConfig()` + +如果确实需要新解析器,把输入格式、容错语义和调用者写清楚,并给纯 Python 逻辑补测试。 + +--- + +## 什么时候抽象 + +抽象只在满足至少一条时做: + +- 同一事实会被三处以上消费,且有真实漏改风险 +- 同一段校验逻辑跨多个入口影响产物安全 +- 抽出来后能保留运行时边界,比如纯 Python 逻辑进入 `geom.py` 后可被 + `python3 -m unittest discover blender/tests` 覆盖 + +不要因为代码相似就抽象: + +- 三份 `parseArgs` 当前保持独立入口价值 +- `ROAD_LAYERS` 与 `SCENE_LAYERS` 跨语言且承载不同字段 +- 每个要素模块各自调用 `clip_polygon` 是模块边界,不是可消除重复 + +--- + +## 提交前自检 + +- [ ] 已 grep 关键值或新字段 +- [ ] 没有新增第二份九图层枚举 +- [ ] 没有在阶段函数里重新拼输出路径 +- [ ] 改材质名时已检查 `catalog.py`、`generate_scene.py`、`export_cesium.py` +- [ ] 新纯几何逻辑放进 `geom.py` 并补 `test_pure.py` +- [ ] 声称产物不变的重构已按[产物一致性指南](./artifact-parity-guide.md)校验 + +--- + +## 反模式 + +| 反模式 | 后果 | +|---|---| +| 新增一份图层名列表 | 回到旧的四份同步,漏改静默错栈 | +| 把两套颜色表统一 | 破坏 QGIS 与 Blender 各自调过的视觉结果 | +| 低层脚本直接读 `config/areas/*.json` | 两层配置边界失效 | +| 只改一份 `parseArgs` 的语义 | 三个入口行为分裂 | +| 把要素模块裁剪逻辑挪到调用方 | 不同要素的越界处理开始漂移 | +| 只在 `catalog.CESIUM_EXPORT` 加导出覆盖 | 当前不会生效;导出器没读它 | diff --git a/.trellis/spec/guides/cross-layer-thinking-guide.md b/.trellis/spec/guides/cross-layer-thinking-guide.md new file mode 100644 index 0000000..c630c48 --- /dev/null +++ b/.trellis/spec/guides/cross-layer-thinking-guide.md @@ -0,0 +1,217 @@ +# 跨层思考指南 + +> **目的**:动手前把数据流走一遍,把"没想到"变成"想过了"。 +> +> 本项目的层是**跨语言、跨进程、跨运行时**的,边界比普通应用多得多。 + +--- + +## 本项目的层与边界 + +``` +config/areas/*.json JSON 数据 + ↓ ① +build-area.js Node(宿主机) + ↓ ② 派生配置 JSON +build-osm2streets-qgis.js Node + osm2streets WASM + ↓ ③ 子进程 + 环境变量 +ogr2ogr / ogrinfo / QGIS Python GDAL/QGIS 运行时 + ↓ ④ GeoJSON / GeoPackage 文件 +generate_scene.py Blender 内嵌 Python + ↓ ⑤ .blend 文件 + 材质名字符串 +export_cesium.py Blender 内嵌 Python + ↓ ⑥ GLB + JSON +cesium-preview.js 浏览器 +``` + +| # | 边界 | 常见问题 | +|---|---|---| +| ① | 用户配置 → 归一化 | `??` vs `\|\|`、相对路径、字段整体替换 | +| ② | 两层配置 | 低层脚本读错配置源 | +| ③ | Node → 外部进程 | 环境变量缺失、退出码与信号、0 字节产物 | +| ④ | 文件交换 | 图层集合/顺序漂移、精度丢失 | +| ⑤ | Python → Python | **材质名字符串**,无校验 | +| ⑥ | Blender → 浏览器 | 坐标系约定、材质在两种光照下的差异 | + +--- + +## 什么时候该读这篇 + +- [ ] 改动同时出现在 `scripts/` 和 `blender/` 里 +- [ ] 你在改九个 osm2streets 图层中的任何一个 +- [ ] 你在改材质名、材质顺序 +- [ ] 你在往配置里加字段 +- [ ] 你在改任何被 `execFileSync` / `spawnSync` 调起的东西 +- [ ] 你在改 stage 的 stdout 打印 +- [ ] 你要新增一种在 Blender 里生成、要在 Cesium 里看的资产 + +--- + +## Step 1:画数据流 + +对每一个箭头问三件事: + +- **格式是什么**——JSON?GeoJSON FeatureCollection?GeoPackage 图层?字符串键? +- **可能出什么错**——文件不存在?0 字节?字段名对不上? +- **谁负责校验**——上游写的时候,还是下游读的时候? + +本项目的答案通常是:**上游写完就走,下游读的时候校验**。因为上游经常是外部工具 +(ogr2ogr、Blender),改不动。 + +## Step 2:找出"约定型"边界 + +最危险的不是有 schema 的边界,是**靠约定连接**的边界: + +| 边界 | 靠什么连接 | 有没有校验 | +|---|---|---| +| `scene-layers.js` ↔ `catalog.py` | 图层 `id` 的集合与顺序 | ✅ `check_layers()`(warn) | +| `generate_scene.py` ↔ `export_cesium.py` | **材质名字符串** | ❌ **无** | +| GeoJSON 文件名 ↔ 图层 id | `layerFile()` 拼 `<id>.geojson` | 部分(reimport 会检查 gpkg 图层是否齐全) | +| stage stdout ↔ `parity.js` | `SCENE_DONE` / `CESIUM_EXPORT_DONE` 字面量 | ❌ 无 | +| GLB 材质索引 ↔ `MATERIALS` 顺序 | 隐式的创建顺序 | ❌ 无(靠 parity 事后发现) | + +**没有校验的那几行就是本项目最容易静默出错的地方。** + +## Step 3:定契约 + +对每个边界写清楚:输入格式、输出格式、能出什么错。 +本项目已定的契约见[产物一致性指南](./artifact-parity-guide.md#真正的契约)。 + +--- + +## 本项目真实踩过的坑 + +### 坑 1:同一份事实存了四份 + +九个图层的顺序曾同时存在于 z_index 表、样式 JSON、QGIS 工程、README。 +改一处漏三处,**不报错**,只是下游场景悄悄错栈。 + +**修法**:`scripts/lib/scene-layers.js` 单一事实源 + 四个派生函数。 +跨语言那一份(`catalog.py`)无法消除,改用运行时对账。 + +→ [图层表](../pipeline/layer-registry.md) + +### 坑 2:外部工具失败但留下了文件 + +`ogr2ogr` 遇到不存在的图层退出码非零,**但已经创建了一个 0 字节文件**。 +直接覆盖目标目录就会用空文件冲掉好数据,而且看起来像成功。 + +**修法**:staging 目录 → 全部校验 → 才落盘。 + +→ [外部工具调用](../pipeline/external-tools.md#2-导出失败会留下-0-字节文件) + +### 坑 3:为了"输出干净"加了个参数 + +给 `ogr2ogr` 显式设 `COORDINATE_PRECISION`,触发了 GDAL 的精度裁剪, +7 个箭头多边形丢了 28 个顶点。默认行为本来就能完整往返双精度。 + +**教训**:跨边界时,**显式设置一个"看起来更安全"的参数,可能触发上游的另一条代码路径**。 + +### 坑 4:新资产在 Blender 里对、在 Cesium 里发黑 + +场景里每一个材质都被手工提亮过(草往亮绿混 72%、建筑自发光 0.18), +因为 Cesium 默认光照偏白。新资产没调过,是唯一如实渲染的东西, +放在旁边就显得发黑。 + +**教训**:**同一份数据在两个运行时里的"正确"可能不一样**。 +第二个运行时如果有一整套补偿,新东西必须也进那套补偿。 + +→ [资产生成](../blender/asset-generation.md#为什么新资产总是发黑) + +### 坑 5:两个 Python 脚本靠字符串对接 + +`export_cesium.py` 不 import `catalog`,靠材质名字符串匹配四张覆盖表。 +改个材质名,Cesium 侧的调色**静默失效**。`catalog.CESIUM_EXPORT` 想解决这个问题, +但迁移没做完,它现在是死代码。 + +**教训**:**字符串键的跨模块耦合必须配一个对账机制**,否则重命名就是定时炸弹。 + +--- + +## 加东西时的检查清单 + +### 加一个图层 + +- [ ] `scene-layers.js:SCENE_LAYERS` 末尾追加 +- [ ] `catalog.py:ROAD_LAYERS` 末尾追加,**顺序一致** +- [ ] 确认 osm2streets 拆分结果里有对应的 `splitKey` +- [ ] 跑一次构建,确认日志里没有 `Layer catalog warning:` +- [ ] 跑 parity,确认只多了预期的对象 + +### 加一个材质 + +- [ ] `catalog.MATERIALS` **末尾**追加(中间插入会平移 GLB 材质索引) +- [ ] 若在 Cesium 里需要调色,去 `export_cesium.py` 的四张表加 +- [ ] 跑 parity + +### 加一个配置字段 + +- [ ] `normalizeAreaConfig` 里用 `??` 不用 `||` +- [ ] 需要传给低层脚本?加进 `writeDerivedConfig` +- [ ] 数值?在消费侧加 `Number.isFinite` + 范围校验,**在任何副作用之前** +- [ ] 更新 `config/examples/template.json` +- [ ] 更新 [config spec](../config/index.md) 的字段表 + +### 加一个 stage + +- [ ] `normalizeAreaConfig` 的 `stages` + `resolveStages` 的 `aliases` +- [ ] 想清楚进不进 `all`(恢复类/补丁类不进) +- [ ] 与已有 stage 有覆盖关系?加互斥检查 +- [ ] 产出新文件?加进 `outputs` 路径推导 +- [ ] 阶段函数开头 `ensureFile` 校验依赖产物 + +### 加一种 OSM 要素 + +- [ ] 新模块放 `osmassets/`,`assemble(...)` 签名照抄现有三个 +- [ ] 只 import 需要的,**纯几何逻辑放 `geom.py` 并补测试** +- [ ] 材质加进 `catalog.MATERIALS` 末尾 +- [ ] `generate_scene.py` 分发处加一行 +- [ ] 需要"随机"外观?用 index 的纯函数或显式 seed,**不要用 `random`** +- [ ] 跑 parity + +--- + +## 通用的四个错误 + +### 隐式格式假设 + +跨边界时假设"上游肯定给的是 X 格式"。本项目的做法是**读的时候验**: +`reimport-gpkg.js:175` 明确检查 `type === "FeatureCollection" && Array.isArray(features)`。 + +### 校验散在各处 + +同一个约束在三个地方各写一遍,改的时候漏一个。 +本项目把参数校验集中在脚本开头(`build-osm2streets-qgis.js:41-70`), +**在任何副作用之前一次验完**。 + +### 抽象泄漏 + +低层脚本如果直接读 `config/areas/*.json`,两层配置的边界就白设了。 +它们只该读派生配置。 + +### 每个消费方各自解析同一份数据 + +看到两处代码用各自的方式从同一份 payload 里挖同一个字段, +就该有一个共享的解析函数了。`mergeScene(getCollection)` 用回调而不是数组, +就是为了让 build 和 reimport 共用一套合并逻辑。 + +--- + +## 一条铁律 + +> **改任何值之前,先全仓 grep 一遍。** + +```bash +grep -rn "要改的值" scripts blender config +``` + +本项目跨两种语言,IDE 的"查找引用"帮不上忙。这一个习惯能挡掉大部分 +"忘了同步 X" 的 bug。 + +--- + +## 相关 + +- [代码复用思考指南](./code-reuse-thinking-guide.md) +- [产物一致性指南](./artifact-parity-guide.md) +- [图层表](../pipeline/layer-registry.md) diff --git a/.trellis/spec/guides/index.md b/.trellis/spec/guides/index.md new file mode 100644 index 0000000..7614be5 --- /dev/null +++ b/.trellis/spec/guides/index.md @@ -0,0 +1,76 @@ +# 思考指南索引 + +> 目的:在改代码前补一遍"跨层会不会断、重复事实会不会漂、产物是否仍一致"。 +> 本目录不替代包级 spec;它用于那些单看一个文件容易误判的改动。 + +--- + +## 可用指南 + +| 指南 | 关注点 | 什么时候读 | +|---|---|---| +| [跨层思考指南](./cross-layer-thinking-guide.md) | JS、GDAL/QGIS、Blender Python、浏览器之间的数据契约 | 改图层、材质名、配置字段、stage 输出、外部工具调用 | +| [代码复用思考指南](./code-reuse-thinking-guide.md) | 单一事实源、重复解析、可接受重复与应抽取重复的边界 | 改 `parseArgs`、图层表、配置归一化、几何工具 | +| [产物一致性指南](./artifact-parity-guide.md) | `.blend` / `.glb` / metadata 的结构摘要校验 | 任何声称"纯重构、产物不变"的改动 | + +--- + +## 本项目触发点 + +### 读跨层思考指南 + +- [ ] 改 `scripts/lib/scene-layers.js:15` 的 `SCENE_LAYERS` +- [ ] 改 `blender/osmassets/catalog.py:28` 的 `ROAD_LAYERS` 或 `catalog.py:56` 的 `MATERIALS` +- [ ] 改 `blender/export_cesium.py:68` 等四张按材质名字符串匹配的覆盖表 +- [ ] 改 `build-area.js:74` 的 `normalizeAreaConfig()` 或 `config/examples/template.json` +- [ ] 改任何 `execFileSync` / `spawnSync` 调起的脚本或参数 +- [ ] 改 `SCENE_DONE` / `CESIUM_EXPORT_DONE` 的 stdout 标记 + +### 读代码复用思考指南 + +- [ ] 准备新增第二份或第三份图层、材质、配置字段枚举 +- [ ] 修改三份重复的 `parseArgs` 之一: + `build-area.js:50`、`build-osm2streets-qgis.js:153`、`reimport-gpkg.js:93` +- [ ] 多个要素模块都要做同一件几何预处理,比如 + `water.py:9`、`grass.py:9`、`scrub.py:8` 都先 `clip_polygon` +- [ ] 低层脚本想直接读取 `config/areas/*.json`,绕开派生配置 +- [ ] 新增 helper 前没有先 `grep -rn` 找现有函数 + +### 读产物一致性指南 + +- [ ] 挪函数、拆模块、改导入,且声称产物不变 +- [ ] 重排 `ROAD_LAYERS` / `MATERIALS` +- [ ] 改材质名或导出调色逻辑 +- [ ] 改几何、采样、实例化、UV、材质构建 +- [ ] 改 `scripts/parity.js`、`scripts/glb-digest.js`、`blender/tools/scene_digest.py` + +--- + +## 改值前的固定动作 + +```bash +grep -rn "要改的值" scripts blender config +``` + +本仓库跨 JS、Blender Python、浏览器 JS 和 JSON,很多连接靠字符串或文件名约定。 +例如 `scene-layers.js` 与 `catalog.py` 只靠 `id` 集合和顺序对账; +`generate_scene.py` 与 `export_cesium.py` 的材质覆盖目前靠材质名字符串,没有自动校验。 + +--- + +## 审查 AI 结果时 + +- 先看它有没有读到对应包的 index 和本目录指南 +- 对任何"行为没变"的结论,要求说明是否需要 parity;需要却没跑就是风险 +- 对任何"可以合并重复"的建议,先判断重复是不是刻意边界: + 三份 `parseArgs` 目前是可接受技术债,JS/Python 图层颜色则是刻意不同步 +- 对任何"加精度、加默认值、直接覆盖文件"的建议,回到真实代码注释验证; + `reimport-gpkg.js:152-156` 和 `reimport-gpkg.js:11-13` 都是反直觉约束 + +--- + +## 维护规则 + +- 发现新的跨层坑,优先补到相关指南,再补包级 spec +- 指南只写本项目已发生或代码已体现的约束,不写通用工程格言 +- 每条新约束至少带两个真实路径或函数名,方便后续 grep 定位 diff --git a/.trellis/spec/index.md b/.trellis/spec/index.md new file mode 100644 index 0000000..232a737 --- /dev/null +++ b/.trellis/spec/index.md @@ -0,0 +1,85 @@ +# Trellis 项目规范索引 + +> 本目录面向 AI 执行者,记录本仓库真实代码里的工程约束。 +> 用法说明仍放在 README;这里写的是**改代码前必须知道什么**。 + +--- + +## 项目边界 + +本仓库不是前端应用,而是 **OSM → QGIS/Blender/Cesium 的资产生成管线**: + +- `scripts/build-area.js:74` 的 `normalizeAreaConfig()` 归一化区域配置并调度阶段 +- `scripts/lib/scene-layers.js:15` 的 `SCENE_LAYERS` 是 osm2streets 九个 2D 图层的 JS 侧事实源 +- `blender/osmassets/catalog.py:28` 的 `ROAD_LAYERS` 是 Blender 侧道路图层与材质顺序事实源 +- `scripts/lib/cesium-preview.js:1` 是无构建步骤的浏览器预览 IIFE + +因此有效 spec layer 只有 `pipeline`、`blender`、`preview`、`config`, +另有跨层思考指南 `guides`。 + +--- + +## 先读哪个包 + +| 你要改的内容 | 先读 | +|---|---| +| `scripts/*.js`、构建阶段、QGIS/GDAL/Blender 子进程、GeoPackage 往返 | [pipeline](./pipeline/index.md) | +| `blender/**/*.py`、`osmassets` 模块、材质、几何、导出 | [blender](./blender/index.md) | +| `scripts/lib/cesium-preview.js` / `.css`、预览 HTML 注入 | [preview](./preview/index.md) | +| `config/areas/*.json`、`config/examples/template.json`、区域字段默认值 | [config](./config/index.md) | +| 跨语言、跨进程、声称纯重构或产物不变的改动 | [guides](./guides/index.md) | + +跨层改动至少读两个包的 index,再读 `guides/index.md`。例如: + +- 加一个 osm2streets 图层:读 `pipeline/layer-registry.md` + + `blender/asset-generation.md` + `guides/artifact-parity-guide.md` +- 改材质名:读 `blender/module-structure.md` + + `blender/asset-generation.md` + `guides/cross-layer-thinking-guide.md` +- 加区域配置字段:读 `config/index.md` + + `pipeline/cli-and-stages.md` + +--- + +## 四条全仓硬约束 + +1. **改值先 grep** + 本项目跨 JS、Blender Python、浏览器 JS 和 JSON,IDE 引用不可靠。 + 改 `SCENE_LAYERS`、`ROAD_LAYERS`、材质名、stage 标记或配置字段前,先: + + ```bash + grep -rn "要改的值" scripts blender config + ``` + +2. **顺序可能是契约** + `catalog.py:16-18` 明确说 `ROAD_LAYERS` / `MATERIALS` 的顺序决定导出 GLB 的材质索引。 + 列表默认只能末尾追加;中间插入或重排必须跑 parity。 + +3. **外部工具产物先 staging 后覆盖** + `reimport-gpkg.js:11-13` 记录了 `ogr2ogr` 失败会留下 0 字节文件。 + 任何从外部工具生成文件再覆盖已有产物的代码,都要先写临时目录、校验、再拷回。 + +4. **纯重构必须证明产物一致** + `scripts/parity.js`、`scripts/glb-digest.js`、`blender/tools/scene_digest.py` + 是结构摘要三件套;不要用二进制字节 diff 代替。 + +--- + +## 验证入口 + +- spec layer 扫描: + `python3 ./.trellis/scripts/get_context.py --mode packages` +- 纯 Python 测试: + `python3 -m unittest discover blender/tests` +- 纯重构产物一致性: + `node scripts/parity.js capture <label>`,再 + `node scripts/parity.js compare <before> <after>` + +--- + +## 维护本目录 + +- 每个包目录必须有 `index.md` +- 每个规范文件至少引用两个真实项目文件或函数,优先写 `path:line` + 标识符 +- 文档语言保持中文;标识符、路径、命令和代码片段保持英文原样 +- 不写任何占位提示或未来补写标记;规范文件必须是可执行的当前约束 +- README 面向使用者,spec 面向修改者;不要把命令教程整段复制到 spec diff --git a/.trellis/spec/pipeline/cli-and-stages.md b/.trellis/spec/pipeline/cli-and-stages.md new file mode 100644 index 0000000..1278d7e --- /dev/null +++ b/.trellis/spec/pipeline/cli-and-stages.md @@ -0,0 +1,190 @@ +# CLI 与构建阶段 + +> 适用:新增/修改构建阶段、CLI 参数、区域配置字段。 + +--- + +## 三个入口脚本 + +| 脚本 | 角色 | 入口方式 | +|---|---|---| +| `scripts/build-area.js` | **主入口**。读区域配置,按阶段调度 | `npm run build` / `build:area` | +| `scripts/build-osm2streets-qgis.js` | intermediates 阶段的实现 | 由 build-area 调起;`npm run build:qgis` 可单跑 | +| `scripts/reimport-gpkg.js` | reimport 阶段的实现 | 由 build-area 调起 | + +`scripts/parity.js` 和 `scripts/glb-digest.js` 是校验工具,不属于构建链,见 +[产物一致性指南](../guides/artifact-parity-guide.md)。 + +全部是 CommonJS(`package.json` 的 `"type": "commonjs"`),无构建步骤、无 TypeScript、 +零运行时依赖(唯一依赖 `osm2streets-js-node` 只被 `build-osm2streets-qgis.js` 用)。 + +--- + +## CLI 参数解析 + +三个脚本各有一份**完全相同**的 `parseArgs` +(`build-area.js:50`、`build-osm2streets-qgis.js:153`、`reimport-gpkg.js:93`): + +```js +--kebab-case value → { kebabCase: "value" } +--flag → { flag: "true" } // 后面没值或紧跟另一个 -- +``` + +两条必须知道的语义: + +- **值永远是字符串**,`--flag` 得到的是字符串 `"true"` 不是布尔 `true`。消费方要么 + `Number(...)` 要么显式比较 +- **不做校验**。未知参数被静默收集,缺失参数由下游的 `requireText` / `Number.isFinite` + 报错 + +> 这份重复是已知的、**当前被接受的**技术债:三个脚本要能各自独立运行,抽公共模块的 +> 收益还不抵引入一层依赖。改其中一份时**不要**顺手把另外两份重构掉——那是独立的决定, +> 且会扩大 diff。真要抽取,三处一起改并跑 parity。 + +--- + +## 两层配置 + +``` +config/areas/<id>.json 用户写的区域配置(面向人) + │ build-area.js: normalizeAreaConfig() —— 补默认值、推导全部输出路径 + ▼ +area(内存中的归一化对象) + │ writeDerivedConfig() + ▼ +<areaDir>/_pipeline/osm2streets-qgis.config.json 派生配置(面向机器) + │ --config + ▼ +build-osm2streets-qgis.js / reimport-gpkg.js +``` + +**低层脚本从不读区域配置**,只读派生配置。这条边界让低层脚本能被独立调试,也让 +"输出路径怎么算出来的"只有一处答案(`normalizeAreaConfig`,`build-area.js:74`)。 + +派生配置**落在 `_pipeline/` 目录里而不是临时目录**——构建失败时它还在,可以直接拿去 +复现(`writeDerivedConfig`,`build-area.js:189`)。 + +### 输出路径全部从 `id` 推导 + +`normalizeAreaConfig` 一次性算出 14 个输出路径(`build-area.js:87-102`),规则统一是 +`<outputRoot>/<id>/<fileStem>.<ext>`,`fileStem` 默认等于 `id`。 + +每一项都可以被 `outputs.*` 单独覆盖,写法固定: + +```js +gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`)), +``` + +**加新产物就加这一行**,不要在阶段函数里现拼路径。 + +### 缺失值:区分"必填"和"有默认" + +| 场景 | 写法 | 出处 | +|---|---|---| +| 必填,缺了直接死 | `requireText(raw.id, "id")` | `build-area.js:143` | +| 有默认值 | `raw.qgisApp \|\| "/Applications/QGIS.app"` | `:107` | +| 有默认值且 `false`/`0` 合法 | `raw.stages?.blender ?? true` | `:114` | +| 兼容旧字段名 | `raw.qgis?.arrowScale ?? raw.arrowScale ?? 0.8` | `:121` | + +**`??` 和 `||` 不能混用**:`arrowMergeTriangles` 用 `??`,因为 `false` 是合法值, +用 `||` 会把关掉的开关重新打开。 + +第三列的三级 fallback 是刻意的向后兼容:旧配置把 QGIS 旋钮平铺在顶层,新配置收进 +`qgis: {}`。加新旋钮时**只写两级**(`raw.qgis?.x ?? 默认值`),不要制造新的平铺别名。 + +--- + +## 五个阶段 + +| 阶段 | 做什么 | 读 | 写 | +|---|---|---|---| +| `intermediates` | OSM → osm2streets GeoJSON → GeoPackage → QGIS 工程 + 预览图 | `.osm` | `osm2streets_web_out/`、`.gpkg`、`.qgz`、`-preview.png` | +| `reimport` | GeoPackage → GeoJSON(**反向**) | `.gpkg` | `osm2streets_web_out/` | +| `blender` | OSM + GeoJSON → 场景 | `.osm`、`osm2streets_web_out/` | `.blend`、`.png` | +| `cesium` | 场景 → GLB + 元数据 + 预览页 | `.blend` | `.glb`、`.json`、预览 HTML 及其静态资源 | +| `preview` | 只补生成预览页 | `.glb`、`.json` | 预览 HTML 及其静态资源 | + +调度是顶层的五个 `if`(`build-area.js:32-46`),顺序固定,**阶段之间不传内存状态, +只通过磁盘产物耦合**。这就是单跑某个阶段能work 的原因。 + +`cesium` 阶段结束时会直接调 `writeCesiumPreview(area)`(`build-area.js:285`),所以 +`preview` 只在"已有 GLB、只想重生成 HTML"时才需要单独跑。 + +### 别名 + +`resolveStages`(`build-area.js:157`)接受一张别名表,同一个阶段有多个叫法 +(`qgis`/`osm2streets`/`geojson` → `intermediates`,`gpkg` → `reimport`, +`scene` → `blender`,`glb` → `cesium`,`html`/`cesiumPreview` → `preview`)。 + +未知阶段名**抛错并列出合法值**(`:181`),不静默忽略。 + +### `all` 不含 `reimport` + +```js +// 'reimport' is deliberately absent from 'all': it is a recovery step for +// hand-edited GeoPackages, never part of a full build. build-area.js:159-160 +all: ["intermediates", "blender", "cesium"], +``` + +`preview` 同样不在 `all` 里——`cesium` 已经包含它。 + +### `intermediates` 与 `reimport` 互斥 + +在任何阶段执行**之前**就检查并抛错(`build-area.js:19-26`): + +> intermediates 从 OSM 重建 GeoPackage,正好会抹掉 reimport 要读回的手工修改。 + +这是**显式拒绝而不是警告**——两者同时开,无论谁先跑,另一个的工作都白做。 + +`normalizeAreaConfig` 里 `stages.reimport` 和 `stages.preview` 硬编码为 `false` +(`build-area.js:117-118`),**不能从配置文件打开**,只能靠 `--stages` 显式请求。 +恢复动作和补丁动作都不该被一份配置文件变成默认行为。 + +--- + +## 加一个新阶段 + +1. `normalizeAreaConfig` 的 `stages` 里加一项(默认值想清楚是 `true` 还是硬编码 + `false`) +2. `resolveStages` 的 `aliases` 里注册名字(以及别名) +3. 决定要不要进 `all`——**恢复类/补丁类动作不进** +4. 顶层加一个 `if (stages.x) doX(area)`,位置按数据依赖排 +5. 写 `doX(area)`:先 `ensureFile` 校验依赖产物,`mkdirSync` 建目录, + `console.log("Stage: x")`,再 `runCommand` +6. 若与已有阶段存在"互相覆盖"关系,在顶层加互斥检查 +7. 若产出新文件,在 `normalizeAreaConfig` 的 `outputs` 里加路径 + +--- + +## 输出约定 + +- 开头三行固定打印 area / config / output 路径(`build-area.js:29-31`) +- 每个阶段进入时打印 `Stage: <name>` +- 结尾 `console.log("Done.")` +- 外部工具的输出透传,不加工 + +parity 校验依赖 stage 的 stdout 标记来判断阶段是否跑到(如 `SCENE_DONE` / +`CESIUM_EXPORT_DONE`),**改动这些打印等于改动 parity 契约**。 + +--- + +## 反模式 + +| 反模式 | 后果 | +|---|---| +| 在阶段函数里现拼输出路径 | 路径规则出现第二份定义 | +| 低层脚本直接读 `config/areas/*.json` | 打破两层配置边界 | +| 布尔配置用 `\|\|` 兜底 | `false` 被翻转成默认值 | +| 让 `reimport` / `preview` 能从配置文件默认开启 | 恢复动作变成常规行为 | +| 新阶段忘了 `ensureFile` 前置校验 | 单跑时报底层堆栈而非人话 | +| 改 stage 的 stdout 标记 | 静默破坏 parity 契约 | +| 顺手把三份 `parseArgs` 合并 | 扩大 diff,且三个脚本的独立性是刻意的 | + +--- + +## 相关 + +- [外部工具调用](./external-tools.md):阶段内部如何调 QGIS/Blender +- [图层表](./layer-registry.md):intermediates 与 reimport 共同维护的九个图层 +- [区域配置](../config/index.md):字段全表 +- README「主流程」节:面向使用者的命令示例(**不要**复制到这里) diff --git a/.trellis/spec/pipeline/external-tools.md b/.trellis/spec/pipeline/external-tools.md new file mode 100644 index 0000000..207efff --- /dev/null +++ b/.trellis/spec/pipeline/external-tools.md @@ -0,0 +1,200 @@ +# 外部工具调用 + +> 适用:任何调用 QGIS / GDAL / Blender 子进程的代码。 +> 这一层是管线里**唯一**能启动外部进程的地方,也是踩过坑最多的地方——下面每条约束 +> 都对应一次实际的数据损坏或静默错误。 + +--- + +## QGIS 工具链 + +### 可执行文件路径 + +一律从配置的 `qgisApp` 推导,不写死绝对路径、不依赖 `PATH`: + +```js +const qgisMacOS = path.join(qgisApp, "Contents", "MacOS"); +const qgisPython = path.join(qgisMacOS, "python3.12"); // build-osm2streets-qgis.js:24 +const ogr2ogr = path.join(qgisMacOS, "ogr2ogr"); // :25 +const ogrinfo = path.join(qgisMacOS, "ogrinfo"); // reimport-gpkg.js:33 +``` + +**启动前必须 `existsSync` 校验并抛出带路径的错误** +(`build-osm2streets-qgis.js:59-63`、`reimport-gpkg.js:37-41`)。让它在第一步就失败, +而不是在 `execFileSync` 里抛一个没有上下文的 ENOENT。 + +### GDAL 环境变量(必须) + +```js +function qgisEnv() { // build-osm2streets-qgis.js:231 + return { + PROJ_LIB: path.join(qgisApp, "Contents", "Resources", "qgis", "proj"), + GDAL_DATA: path.join(qgisApp, "Contents", "Resources", "qgis", "gdal"), + }; +} +``` + +`reimport-gpkg.js:132` 有一份等价实现(`gdalEnv()`)。**每次 `execFileSync` 都要带上**, +写法固定为 `env: { ...process.env, ...qgisEnv() }`。 + +漏掉不会立刻崩——GDAL 会退回内置的残缺数据,坐标系解析结果**静默出错**。 + +### 在 QGIS 的 Python 里跑脚本 + +`normalize-lane-arrows.py` 依赖 `osgeo.ogr`,只能用 QGIS 自带的解释器。除了 +`qgisEnv()` 还要额外注入三项(`build-osm2streets-qgis.js:1287-1305`): + +| 变量 | 值 | 作用 | +|---|---|---| +| `QT_QPA_PLATFORM` | `offscreen` | 无头环境下不尝试连显示服务 | +| `PYTHONHOME` | `<qgisApp>/Contents/Frameworks` | 指向 QGIS 的 Python 运行时 | +| `PYTHONPATH` | `<...>/Resources/python` + `/plugins` | 找得到 `osgeo` 与插件 | + +新增 QGIS-Python 脚本时照抄这套环境,不要只带 `qgisEnv()`。 + +--- + +## `ogr2ogr` 的三个陷阱 + +### 1. 不要设 `COORDINATE_PRECISION` + +`reimport-gpkg.js:152-156` 有一段专门的注释说明: + +> 默认行为已经能完整往返双精度。**显式设置反而会触发 GDAL 的精度裁剪那一遍**, +> 把在给定分辨率下collapse 的顶点丢掉——实测在 7 个 lane-arrow 多边形上丢了 28 个点。 + +看到有人"为了输出干净"加上这个参数,删掉它。 + +### 2. 导出失败会留下 0 字节文件 + +`ogr2ogr` 遇到不存在的图层**退出码非零,但已经创建了一个空文件**。直接写目标目录 +就会用空文件覆盖掉好数据,而且看起来像成功。 + +所以 reimport 的落盘是三段式(`reimport-gpkg.js:11-13, 61-91`): + +``` +1. 全部导出到 mkdtemp 的 staging 目录 +2. 逐个 JSON.parse + 校验 type === "FeatureCollection" && Array.isArray(features) +3. 全部通过后,才逐个 copyFileSync 到 outDir +``` + +任一图层失败 → 整批不落盘。**任何"从外部工具产出文件再覆盖已有数据"的新代码都照这个 +模式写。** + +补充两点: + +- 用 `copyFileSync` **不用** `rename`:staging 目录可能在另一个文件系统上 + (`reimport-gpkg.js:72-73`) +- `finally` 里 `rmSync(stagingDir, {recursive: true, force: true})`,失败路径也要清理 + +### 3. 建包与追加是两组参数 + +第一个图层创建 GeoPackage,其余追加(`build-osm2streets-qgis.js:108-111`): + +```js +SCENE_LAYERS.forEach((layer, index) => { + importLayer(gpkgPath, ..., layer.id, index > 0, ogrEnv); // update = index > 0 +}); +``` + +`importLayer`(`:1306`)在 `update` 为真时补 `-update -overwrite`。重建前先 +`unlinkSync` 掉旧的 gpkg(`:104-106`),不要依赖 `-overwrite` 清理整个文件。 + +--- + +## 前置校验的顺序 + +`build-osm2streets-qgis.js:41-70` 的开头是一段密集的校验,顺序是刻意的: + +1. **数值参数**先验(`Number.isFinite` + 范围),错的配置立刻死 +2. **输入文件**存在性 +3. **外部可执行文件**存在性 +4. 全部通过后才 `mkdirSync` 建输出目录 + +原则:**在做任何有副作用的事情之前,把能验的都验完**。不要先建目录再发现 QGIS 装错了。 + +数值校验用 `Number.isFinite` 而不是 `!isNaN`——后者对 `Infinity` 返回 false, +而 `Infinity` 是个合法的 `Number()` 结果。 + +--- + +## Blender 调用 + +### 两种调用姿势 + +| 阶段 | 参数 | 出处 | +|---|---|---| +| `blender` | `--background --factory-startup --python generate_scene.py --` | `build-area.js:242-247` | +| `cesium` | `--background --python export_cesium.py --` | `build-area.js:276-280` | + +**`--factory-startup` 只在 generate 阶段用**:它屏蔽用户的 preferences 和 addon, +保证场景生成不受本机 Blender 配置影响。export 阶段不带,因为它要读已经建好的 `.blend`。 + +`--` 之后才是脚本自己的参数,Blender 不解析它们。脚本侧用 +`sys.argv[sys.argv.index("--") + 1:]` 取。 + +可执行文件路径同样从配置推导: +`path.join(area.blenderApp, "Contents", "MacOS", "Blender")`(`build-area.js:291`)。 + +### 调用前的资源校验 + +`ensureFile()`(`build-area.js:295`)在每个阶段开头把依赖逐个验一遍,带 label: + +```js +ensureFile(blenderExecutable(area), "Blender executable"); +ensureFile(area.outputs.blend, "Blend scene"); // cesium 阶段依赖上一阶段产物 +ensureFile(path.join(repoRoot, "blender", "export_cesium.py"), "Cesium exporter"); +``` + +阶段间依赖靠这个显式表达,**不靠隐式的执行顺序**。`--stages cesium` 单跑时,缺 +`.blend` 会得到一句人话错误而不是 Blender 的堆栈。 + +--- + +## 子进程失败处理 + +统一走 `runCommand`(`build-area.js:301-311`): + +```js +const result = spawnSync(command, commandArgs, { stdio: "inherit" }); +if (result.error) throw result.error; +if (result.status !== 0) { + const signal = result.signal ? ` signal=${result.signal}` : ""; + throw new Error(`Stage '${stage}' failed with status=${result.status}${signal}`); +} +``` + +三个要点: + +- **`stdio: "inherit"`**:外部工具的输出直接透传,不缓冲、不吞。这条管线的调试 + 高度依赖 QGIS/Blender 自己打的日志 +- **`result.error` 和 `result.status` 分别检查**:前者是启动失败(ENOENT 等), + 后者是运行失败,混在一起会丢信息 +- **带上 `signal`**:Blender 被 OOM killer 干掉时 `status` 是 null,只有 `signal` + 能说明发生了什么 + +`build-osm2streets-qgis.js` / `reimport-gpkg.js` 内部用 `execFileSync`(同步、非零 +自动抛),也一律 `stdio: "inherit"`。 + +--- + +## 反模式 + +| 反模式 | 后果 | +|---|---| +| 给 `ogr2ogr` 加 `COORDINATE_PRECISION` | 静默丢顶点 | +| 外部工具产物直接写目标目录 | 失败时用 0 字节文件覆盖好数据 | +| `execFileSync` 不带 `qgisEnv()` | 坐标系静默出错 | +| `stdio: "pipe"` 或吞掉输出 | 失去唯一的调试信息来源 | +| 只判 `status !== 0`,不看 `result.error` / `signal` | 启动失败和被信号杀死都变成同一句错 | +| 用 `rename` 从临时目录搬文件 | 跨文件系统时 EXDEV | +| 依赖 `PATH` 里的 `ogr2ogr` | 抓到系统 GDAL,版本与 QGIS 不匹配 | +| 先建目录/删文件再校验参数 | 配置写错也会破坏已有输出 | + +--- + +## 相关 + +- [CLI 与阶段](./cli-and-stages.md):这些调用被哪个阶段发起 +- [图层表](./layer-registry.md):进出 GeoPackage 的九个图层从哪来 +- [区域配置](../config/index.md):`qgisApp` / `blenderApp` 的配置位置 diff --git a/.trellis/spec/pipeline/index.md b/.trellis/spec/pipeline/index.md new file mode 100644 index 0000000..1d2b111 --- /dev/null +++ b/.trellis/spec/pipeline/index.md @@ -0,0 +1,97 @@ +# Pipeline:Node 构建管线 + +> 覆盖 `scripts/*.js` 与 `scripts/lib/scene-layers.js`。 +> 运行时:宿主机 Node(CommonJS,无构建步骤)。 +> 这是管线里**唯一**能启动外部进程的层。 + +--- + +## 先读哪一篇 + +| 你要做的事 | 读 | +|---|---| +| 改九个 osm2streets 图层(增/删/改顺序/改色) | [图层表](./layer-registry.md) ← **最容易出静默错误** | +| 调 QGIS / GDAL / Blender 子进程 | [外部工具调用](./external-tools.md) | +| 加阶段、加 CLI 参数、改配置字段 | [CLI 与阶段](./cli-and-stages.md) | +| 改预览页生成 | [../preview/](../preview/index.md) | +| 声称"纯重构,产物不变" | [产物一致性指南](../guides/artifact-parity-guide.md) | + +--- + +## 数据流全景 + +``` +config/areas/<id>.json + │ + ▼ build-area.js — normalizeAreaConfig() 推导全部输出路径 + _pipeline/osm2streets-qgis.config.json (派生配置) + │ + ├─[intermediates]─▶ build-osm2streets-qgis.js + │ osm2streets-js-node 解析 .osm + │ → splitLayers() 拆成九个图层 + │ → normalize-lane-arrows.py(QGIS Python) + │ → osm2streets_web_out/*.geojson + │ → osm2streets_scene.geojson + _scene_style.json + │ → ogr2ogr 导入 <id>.gpkg + │ → QGIS 生成 .qgz + -preview.png + │ + ├─[reimport]──────▶ reimport-gpkg.js (反向,与 intermediates 互斥) + │ ogr2ogr 从 .gpkg 导出 → 校验 → 覆写 *.geojson + │ → 重建 scene.geojson + scene_style.json + │ + ├─[blender]───────▶ Blender + blender/generate_scene.py + │ 读 .osm + osm2streets_web_out/ + │ → <id>.blend + <id>.png + │ + ├─[cesium]────────▶ Blender + blender/export_cesium.py + │ 读 .blend → <id>.glb + <id>.json + │ → 并自动执行 preview + │ + └─[preview]───────▶ 生成 <id>-cesium-preview.html + + 拷贝 lib/cesium-preview.{js,css} + + 车辆巡航路线与模型 +``` + +**阶段之间只通过磁盘产物耦合**,不传内存状态。这是单跑任意阶段能work 的前提。 + +--- + +## 三条贯穿全层的约定 + +1. **单一事实源优先于同步** + 九个图层的定义在 `lib/scene-layers.js`,四个派生函数覆盖了全部合法用法。看到第二处 + 枚举这些图层,就是 bug 温床。详见 [图层表](./layer-registry.md)。 + +2. **有副作用之前先把能验的都验完** + 数值参数 → 输入文件 → 外部可执行文件 → 才 `mkdirSync`。 + 见 `build-osm2streets-qgis.js:41-70`。 + +3. **外部工具的产物先落 staging,校验通过才覆盖** + `ogr2ogr` 失败会留 0 字节文件。详见 [外部工具调用](./external-tools.md)。 + +--- + +## 文件速查 + +| 文件 | 行数 | 职责 | +|---|---|---| +| `build-area.js` | 774 | 主入口:配置归一化、阶段调度、Cesium 预览页与车辆巡航生成 | +| `build-osm2streets-qgis.js` | 1468 | intermediates:osm2streets 解析、图层拆分、人行道转角合成、GeoPackage 与 QGIS 工程生成 | +| `reimport-gpkg.js` | 179 | reimport:GeoPackage → GeoJSON 反向导出 | +| `lib/scene-layers.js` | 164 | 九个图层的单一事实源 + 四个派生函数 | +| `lib/cesium-preview.js` / `.css` | 672 / 230 | 预览页运行时,见 [../preview/](../preview/index.md) | +| `normalize-lane-arrows.py` | 182 | 合并 osm2streets 的三角网箭头(跑在 QGIS Python 里) | +| `parity.js` | 270 | 产物一致性校验驱动 | +| `glb-digest.js` | 121 | GLB 结构摘要 | + +--- + +## 技术选型现状 + +- **CommonJS,无构建、无 TypeScript、无 lint 配置**。保持现状;引入工具链是独立决定, + 不要夹带在功能改动里 +- **零运行时依赖**(`osm2streets-js-node` 是唯一 dependency)。加依赖前先确认标准库 + 真的做不到 +- **同步 API 优先**(`execFileSync` / `spawnSync` / `readFileSync`)。这是一次性跑完 + 的批处理工具,不是服务,异步只会增加错误处理复杂度 +- **macOS 专用路径假设**(`.app/Contents/MacOS/...`)。跨平台不在当前范围内 diff --git a/.trellis/spec/pipeline/layer-registry.md b/.trellis/spec/pipeline/layer-registry.md new file mode 100644 index 0000000..8d11576 --- /dev/null +++ b/.trellis/spec/pipeline/layer-registry.md @@ -0,0 +1,164 @@ +# 图层表:跨语言的单一事实源 + +> 适用:改动 osm2streets 九个渲染图层的任何一方——新增图层、删图层、调顺序、调 +> 颜色、调高度。**动手前必读**,这里的错误不会报错,只会让产物静默错栈。 + +--- + +## 一句话 + +九个图层在 **JS 和 Python 各存一份表**,两份**故意只同步"集合与顺序"、不同步颜色**, +一致性靠运行时的 `catalog.check_layers()` 用产物文件对账。 + +--- + +## 两份表分别管什么 + +| | JS 侧 | Python 侧 | +|---|---|---| +| 位置 | `scripts/lib/scene-layers.js:15` `SCENE_LAYERS` | `blender/osmassets/catalog.py:28` `ROAD_LAYERS` | +| 服务于 | 2D 调试链路:GeoJSON 拆分、GeoPackage 导入、QGIS 工程符号 | 3D 场景链路:Blender 材质与几何高度 | +| 关键字段 | `id`、`splitKey`、`zIndex`、`title`、`fill`/`outline`(sRGB hex) | `id`、`material`、`color`(线性 RGB)、`z`(米) | +| 消费点 | `build-osm2streets-qgis.js:91,98,109,1315`、`reimport-gpkg.js:53,63,77` | `generate_scene.py:759,844` | + +`id` 是两侧唯一的连接键,同时也是 GeoJSON 文件名的 stem(`layerFile()` 拼 +`<id>.geojson`,见 `scene-layers.js:103`)。 + +## 这份表从前复制了四遍 + +`scene-layers.js:3-13` 的注释写明了它存在的理由:同一批图层的顺序曾同时躺在 +merged-scene 的 z_index 表、场景样式 JSON、生成的 QGIS 工程、以及 README 的手工重建 +片段里。加一个图层要同步改四处,漏一处**不报错**,只是下游 Blender/Cesium 里的场景 +悄悄错栈。 + +`catalog.py:3-7` 记录的是 Python 侧的同一个病:九个图层的绘制顺序在 JS、Blender 高度 +在一个 `layer_z` dict、颜色在一个 `road_mats` dict——两种语言三份拷贝,手工对齐。 + +**推论**:看到任何地方开始第二次枚举这九个图层,那就是 bug 的温床,改成从这两份表 +之一派生。 + +## 为什么颜色刻意不同步 + +`catalog.py:11-15` 明确列为"deliberate non-goal": + +- `scene-layers.js` 的 `fill` 是给 **QGIS 2D 调试地图**用的 sRGB hex +- `catalog.py` 的 `color` 是给 **Blender 3D 场景**用的线性 RGB +- 两套值是**分别调出来的**,不存在换算关系 + +所以 `check_layers()` 只校验图层的**集合与顺序**——那是必须一致的部分——**不碰调色板**。 + +> 不要"顺手统一"两边的颜色。那不是清理重复,是把两个独立的设计意图合并成一个错的。 + +## 对账机制 + +桥梁是产物文件 `osm2streets_scene_style.json`(两侧常量都叫 `SCENE_STYLE_FILE`, +见 `scene-layers.js:101` 与 `catalog.py:49`): + +``` +JS 侧 sceneStyle() ──写──▶ osm2streets_scene_style.json ──读──▶ catalog.check_layers() +scene-layers.js:126 (落在 geojson 输出目录) catalog.py:162 +``` + +写入点:`build-osm2streets-qgis.js:100`(intermediates 阶段)、 +`reimport-gpkg.js:79`(reimport 阶段)。 +读取点:`generate_scene.py:842`,每次构建场景时执行。 + +`check_layers()` 报三类问题(`catalog.py:183-194`): + +1. style 里有、`ROAD_LAYERS` 里没有 → 该图层**到不了 3D 场景** +2. `ROAD_LAYERS` 里有、style 里没有 → **不会有 GeoJSON 产出**给它 +3. 集合相同但顺序不同 → 打印两侧的实际顺序 + +**这是 warn 不是 fail**(`catalog.py:168-169` 写明理由):过期或缺失的输出目录不该 +阻断一次重建。所以—— + +> 构建日志里的 `Layer catalog warning:` 不是噪音。它是这套双表设计**唯一**的自动 +> 报警,被忽略就等于没有。 + +## 顺序是承重的 + +`catalog.py:16-18`: + +- **材质创建顺序固定了导出 GLB 里的材质索引** +- 图层顺序固定了 mesh 创建顺序 + +所以 `ROAD_LAYERS` 和 `MATERIALS` 是 **list 不是 dict**,**追加是唯一安全的编辑**。 +在中间插入一个图层会平移其后所有材质索引——GLB 结构变了,parity 校验会红, +Cesium 侧引用的材质会错位。 + +JS 侧的 `zIndex` 同样兼作绘制顺序(`scene-layers.js:12`),**最小值先画、位于栈底**。 +它同时是写进每个 feature 的 `z_index` 属性(`mergeScene()`,`scene-layers.js:117-119`)。 + +## 派生函数:只加派生,不要加第二份枚举 + +`scene-layers.js` 导出的四个派生函数是这份表的全部合法用法: + +| 函数 | 位置 | 用途 | +|---|---|---| +| `layerFile(layer)` | `:103` | `<id>.geojson` 文件名 | +| `mergeScene(getCollection)` | `:109` | 合成 `osm2streets_scene.geojson`,逐 feature 打上 `render_layer` / `z_index` | +| `sceneStyle()` | `:126` | 生成对账用的 style JSON | +| `qgisRgba(hex, alpha)` | `:143` | hex → QGIS 要的 `"r,g,b,a"` 字符串 | + +`mergeScene` 收的是**回调**而不是数组,这样 build 阶段(从内存的 split 取)和 +reimport 阶段(从磁盘读回)能共用同一套合并逻辑(`scene-layers.js:107-108`)。 +新增第三种数据来源时沿用这个模式,不要复制合并循环。 + +`outline: null` 表示无描边,QGIS 侧由 `qgisRgba` 转成全透明(`scene-layers.js:13-14,145`)。 + +Python 侧同理:`road_material_specs()`(`catalog.py:156`)把 `ROAD_LAYERS` 转成 +`MATERIALS` 形状的规格,`generate_scene.py:759` 用 `zip` 与图层配对——保持这条派生链, +不要在 `generate_scene.py` 里另起一份材质名列表。 + +--- + +## 改动清单 + +### 新增一个图层 + +1. `scene-layers.js:SCENE_LAYERS` **末尾追加**:`id`、`splitKey`、`zIndex`(大于现有 + 最大值)、`title`、`fill`、`outline`、`outlineWidth` +2. 确认 osm2streets 的拆分结果里确实有 `splitKey` 对应的键 + (`build-osm2streets-qgis.js:92` 取 `split[layer.splitKey]`) +3. `catalog.py:ROAD_LAYERS` **末尾追加**:`id`(与第 1 步一致)、`material`(新名字)、 + `color`(线性 RGB,独立调)、`z`(米,高于前一层避免 z-fighting) +4. 跑一次 `intermediates` + `blender`,确认日志里**没有** `Layer catalog warning:` +5. 该图层的 GeoPackage 导入、QGIS 符号、场景合并、reimport 全部自动跟上,**无需**再 + 改 `reimport-gpkg.js` 或 QGIS 工程生成代码 + +### 删除一个图层 + +两侧同时删。只删一侧的话 `check_layers()` 会 warn,但构建**照常出产物**——一份少了 +该图层的产物。 + +### 调整顺序 + +改 `zIndex` 的同时必须把 `ROAD_LAYERS` 的**元素位置**也调成一致。注意这会移动材质 +索引,属于会改变产物的变更,**必须跑 parity 校验**,见 +[产物一致性指南](../guides/artifact-parity-guide.md)。 + +### 只调颜色 + +改一侧即可,不要同步到另一侧(见上文"为什么颜色刻意不同步")。 + +--- + +## 反模式 + +| 反模式 | 后果 | +|---|---| +| 在 `generate_scene.py` / QGIS 生成代码里硬编码图层名列表 | 回到"复制四份"的旧病 | +| 从 `scene-layers.js` 的 `fill` 换算 Blender 的 `color` | 抹掉两套独立调过的配色 | +| 在 `ROAD_LAYERS` / `MATERIALS` **中间**插入条目 | GLB 材质索引整体平移 | +| 把 `ROAD_LAYERS` / `MATERIALS` 改成 dict | 顺序语义丢失,见 `catalog.py:16-18` | +| 把 `check_layers()` 从 warn 改成 raise | 输出目录过期就无法重建 | +| 忽略 `Layer catalog warning:` | 双表设计唯一的报警失效 | + +--- + +## 相关 + +- [外部工具调用](./external-tools.md):图层如何进出 GeoPackage +- [CLI 与阶段](./cli-and-stages.md):哪个阶段写、哪个阶段读这些文件 +- [Blender 资产生成](../blender/asset-generation.md):`MATERIALS` 的其余部分 +- [产物一致性指南](../guides/artifact-parity-guide.md):改动顺序后如何验证 diff --git a/.trellis/spec/preview/index.md b/.trellis/spec/preview/index.md new file mode 100644 index 0000000..44a5a88 --- /dev/null +++ b/.trellis/spec/preview/index.md @@ -0,0 +1,219 @@ +# Preview:Cesium 预览层 + +> 覆盖 `scripts/lib/cesium-preview.js`(672 行)与 `cesium-preview.css`(230 行)。 +> 运行时:浏览器。全仓唯一的 DOM 环境。 + +--- + +## 定位 + +预览层是**验证性的,不是产物本身**。它加载 `cesium` 阶段导出的 `.glb` + `.json`, +用来确认资产在真实 Cesium 里的样子。改这一层**不会**改变 Blender/GLB 主资产。 + +车辆巡航同理——README 里写明它是"用于验证高精度巡航可用性的预览层功能"。 + +--- + +## 没有构建步骤 + +``` +scripts/lib/cesium-preview.js ─── 原样 copyFileSync ──▶ outputs/<area>/cesium-preview.js +scripts/lib/cesium-preview.css ─── 原样 copyFileSync ──▶ outputs/<area>/cesium-preview.css + (build-area.js:328-335) +<area>-cesium-preview.html ─── 模板字符串生成 ────▶ 同目录 + (build-area.js:697) +``` + +所以:**没有打包、没有转译、没有 npm 依赖、没有模块系统**。浏览器直接吃。 +写代码时只能用目标浏览器原生支持的语法,`Cesium` 从 CDN 全局引入。 + +整个文件是一个 IIFE + `"use strict"`(`cesium-preview.js:1-2`)。 + +--- + +## 参数注入 + +JS 不硬编码任何文件名,全部从 HTML 注入的全局对象读: + +```js +const config = window.OSM_ASSET_PREVIEW_CONFIG || {}; // :4 +// config.areaId / .glbName / .metadataName / .routeName / .vehicleModelName +``` + +生成侧在 `build-area.js:697 cesiumPreviewHtml()`,注入时**必须转义**: + +| 场景 | 用 | +|---|---| +| HTML 文本/属性 | `escapeHtml()`(`build-area.js:759`) | +| `<script>` 里的 JSON | `escapeScriptJson()`(`:767`) | + +`|| {}` 的兜底不能删——它让 JS 在没有配置块时也不至于在第一行就崩。 + +**加一个新的可配置项**:`cesiumPreviewHtml()` 里加进注入的 JSON,JS 侧从 `config` 读, +两边都要动。 + +--- + +## 加载流程 + +`main()`(`:30-52`)的顺序是刻意的: + +``` +setLoadingMessage("Loading scene") + → fetchJson(metadata) 必需,失败即终止 + → fetchOptionalJson(route) 可选,失败降级 + → scenePlacement(metadata) + → createViewer() +setLoadingMessage("Loading model") + → loadSceneAssets() 逐个资产加载,失败收集不中断 + → addVehicleCruises() / createCameraPresets() + → buildAssetToggles() / bindRuntimeControls() / startDiagnostics() + → cameras.overview() + → baseStatus = summaryText(...) +setLoadingMessage("Preparing view") + → await waitForStableFrames() 等画面稳定 + → document.body.classList.add("scene-ready") ← CSS 靠这个类收起遮罩 + → window.osmPreview = {...} +``` + +### 三档失败语义 + +这一层的错误处理分得很清楚,**新增加载逻辑时要先想清楚落在哪一档**: + +| 档 | 做法 | 出处 | +|---|---|---| +| **必需** | `fetchJson` 直接抛,预览起不来 | `:55-62` | +| **可选** | `fetchOptionalJson` 捕获 → `console.warn` → 返回 `null` | `:63-73` | +| **部分** | 逐条收集失败,汇总到诊断面板,其余照常显示 | `:163-165` | + +两段注释把理由写清楚了: + +> The route file is an extra on top of the scene, not a precondition for it. +> A missing or unreadable route costs the cruise controls, not the preview. + +> One broken entry in metadata.assets should not blank the whole preview, so +> failures are collected and surfaced in the diagnostics panel instead. + +### `scene-ready` 是加载态的唯一开关 + +`waitForStableFrames()`(`:107`)等若干帧稳定后才加 `scene-ready` 类,CSS 据此 +收起遮罩。**不要改成定时器或 `load` 事件**——材质编译完成之前画面是花的。 + +### `window.osmPreview` 是唯一对外句柄 + +```js +// Handle for the browser console and for headless checks: everything else +// in here is closed over by the IIFE and unreachable from outside. :50-51 +window.osmPreview = { viewer, metadata, placement, assets, cruise, cameras }; +``` + +调试和无头检查都靠它。**加新的顶层对象就往这里挂**,不要再开新全局。 + +--- + +## Viewer 配置:一切都关掉 + +`createViewer()`(`:74-97`)把 Cesium 的默认 UI 和地球全部关闭: + +```js +animation, timeline, baseLayerPicker, geocoder, +navigationHelpButton, sceneModePicker, infoBox, +selectionIndicator, baseLayer ← 全 false +globe.show = false ← 不显示地球 +skyAtmosphere / skyBox / sun / moon ← 全 false +backgroundColor = globe.baseColor = "#d9e0e2" ← PREVIEW_BACKGROUND +``` + +理由:这是**看单个园区资产**的预览,不是地图应用。留着地球和大气会干扰对 +材质与几何的判断,也让背景色不可控。 + +`depthTestAgainstTerrain = false` —— 没有地形,开着只会让模型被裁。 + +保留的只有 `homeButton` 和 `fullscreenButton`。 + +--- + +## DOM 与状态 + +### DOM 句柄集中在顶部 + +`:5-20` 一次性取完全部元素引用,**不在函数里现查**。新增控件时加在这一批里。 + +### status 的两类消息 + +```js +// Status carries two kinds of message: the scene summary, which is what the +// panel should read whenever nothing else is going on, and transient notes +// from a control the user just touched. Keep the summary so the transient +// note can be replaced instead of destroying it. :21-24 +let baseStatus = ""; +``` + +`baseStatus` 存场景摘要,瞬时提示用完要能回到它。**加新的瞬时提示时不要直接覆写 +`baseStatus`**。 + +### 诊断读数跟渲染循环,不跟定时器 + +```js +// Camera-dependent readouts have to track the camera, so refresh off the +// render loop rather than a fixed timer, throttled to stay off the hot path. :589-590 +``` + +相机相关的读数必须跟着渲染循环刷新并**做节流**。用 `setInterval` 会在相机快速移动时 +读到过期值,不节流会拖慢帧率。 + +### 单资产 vs 多资产的开关 + +```js +// A single-asset scene keeps the plain "Scene" checkbox; a multi-asset one +// gets a child checkbox per model with "Scene" acting as the master. :194-195 +``` + +`buildAssetToggles()` 按资产数量决定 UI 形态,`syncSceneMaster()` 维护主从关系。 + +--- + +## 坐标系 + +GLB 停留在**局部 ENU 坐标系**(X 东、Y 北、Z 上),靠伴生 JSON 的放置信息配合 +`Cesium.Transforms.eastNorthUpToFixedFrame` 摆到地球上 +(`blender/export_cesium.py:8-9`)。 + +`scenePlacement(metadata)`(`:131`)负责这一步。**改动导出侧的坐标约定必须同步改这里。** + +--- + +## 本地预览必须走 HTTP + +```bash +cd outputs/<area-id> +python3 -m http.server 8765 +# → http://localhost:8765/<area-id>-cesium-preview.html +``` + +`file://` 会被浏览器的同源策略拦掉 `fetch`,页面停在加载遮罩上。 + +--- + +## 反模式 + +| 反模式 | 后果 | +|---|---| +| 引入需要打包/转译的语法或 npm 依赖 | 没有构建步骤,直接跑不起来 | +| 在 JS 里硬编码 `.glb` / `.json` 文件名 | 换区域就失效,绕过 config 注入 | +| 注入 HTML 时不转义 | 区域名带特殊字符就破页面 | +| 把可选资源当必需资源加载 | 缺一个路线文件整个预览打不开 | +| 单个资产加载失败就中断全部 | 一条坏 metadata 让预览全白 | +| 用定时器代替 `waitForStableFrames` | 遮罩在材质编译完成前就收起,画面是花的 | +| 相机读数用 `setInterval` | 快速移动时读到过期值 | +| 诊断刷新不节流 | 拖慢帧率 | +| 再开一个全局变量 | 已有 `window.osmPreview` | +| 用 `file://` 打开 | fetch 被拦,卡在加载中 | + +--- + +## 相关 + +- [CLI 与阶段](../pipeline/cli-and-stages.md):`cesium` / `preview` 阶段如何生成这些文件 +- [资产生成](../blender/asset-generation.md):GLB 里的材质为什么要单独调色 +- README「实验:车辆巡航」节:面向使用者的说明 diff --git a/.trellis/tasks/00-bootstrap-guidelines/design.md b/.trellis/tasks/00-bootstrap-guidelines/design.md new file mode 100644 index 0000000..18d158e --- /dev/null +++ b/.trellis/tasks/00-bootstrap-guidelines/design.md @@ -0,0 +1,196 @@ +# 技术设计:Trellis spec 重建 + +> 对应 `prd.md`。本文件定义目录结构、每个文件的内容边界与取材来源。 + +--- + +## 1. 为什么按这四个包切分 + +Trellis 单仓模式下,`.trellis/spec/` 的每个子目录自动成为一个 "spec layer" +(`packages_context.py:30-41`,`guides` 被显式排除)。零配置,不需要动 +`config.yaml` 的 `packages:`(那是 monorepo 用的)。 + +切分依据是**运行时边界**,不是目录名: + +| 包 | 覆盖代码 | 运行时 | 切分理由 | +|---|---|---|---| +| `pipeline/` | `scripts/*.js`, `scripts/lib/scene-layers.js` | Node(宿主机) | 唯一能调外部进程(QGIS/Blender/ogr2ogr)的层 | +| `blender/` | `blender/**/*.py` | Blender 内嵌 Python + 系统 Python | 依赖 `bpy`,且内部还有一条纯 Python 子边界 | +| `preview/` | `scripts/lib/cesium-preview.{js,css}` | 浏览器 | 无构建步骤的 IIFE,唯一的 DOM 环境 | +| `config/` | `config/areas/*.json`, `config/examples/template.json` | 数据(无运行时) | 三层共同消费的契约,改一个字段会同时影响三层 | + +`scripts/normalize-lane-arrows.py` 虽是 Python,但跑在 QGIS 的 Python 里、由 +`build-osm2streets-qgis.js` 调起,属于管线的外部工具调用,归 `pipeline/`。 + +## 2. 目标结构 + +``` +.trellis/spec/ +├── index.md # 顶层索引:四个包 + guides 的路由表 +├── pipeline/ +│ ├── index.md # 包索引 + 五个 stage 的数据流全景 +│ ├── cli-and-stages.md +│ ├── layer-registry.md +│ └── external-tools.md +├── blender/ +│ ├── index.md # 包索引 + osmassets 依赖分层图 +│ ├── module-structure.md +│ ├── asset-generation.md +│ └── testing.md +├── preview/ +│ └── index.md +├── config/ +│ └── index.md +└── guides/ + ├── index.md # 更新:触发点换成本项目的 + ├── code-reuse-thinking-guide.md # 保留,补本项目实例 + ├── cross-layer-thinking-guide.md # 保留,补本项目实例 + └── artifact-parity-guide.md # 新增 +``` + +被删除:`.trellis/spec/frontend/` 全部 7 个文件(6 个模板 + index)。 + +## 3. 每个文件的内容边界与取材 + +### 3.1 `pipeline/cli-and-stages.md` + +| 要写什么 | 取材 | +|---|---| +| `--kebab-case` → `camelCase` 的参数解析约定,重复实现在三个脚本里 | `build-area.js:50`, `reimport-gpkg.js:93`, `build-osm2streets-qgis.js` | +| 五个 stage 的职责与依赖顺序 | `build-area.js:32-46` 的顶层调度 | +| `reimport` 不在 `all` 里,是恢复步骤不是构建步骤 | `build-area.js:157-160` 注释 | +| `intermediates` × `reimport` 互斥,显式抛错 | `build-area.js:19-26` | +| 配置归一化:从 `id` 推导默认输出路径,`outputs` 可覆盖 | `build-area.js:74` `normalizeAreaConfig` | +| 缺失必填项用 `requireText` 抛错而非默认值兜底 | `build-area.js:143`, `reimport-gpkg.js:125` | +| 外部命令统一走 `runCommand`,非零退出即终止 | `build-area.js:301` | + +**边界**:只写"怎么加一个 stage / 怎么加一个 CLI 参数",不复述 README 里的用法示例。 + +### 3.2 `pipeline/layer-registry.md` + +最重要的一篇。核心是"九个图层在两种语言里各存一份"这个刻意设计。 + +| 要写什么 | 取材 | +|---|---| +| `SCENE_LAYERS` 是 JS 侧唯一事实源,`zIndex` 兼作绘制顺序 | `scene-layers.js:3-13` 顶部注释 | +| 从前同一份表复制了四次(z_index 表 / 样式 JSON / QGIS 工程 / README),改一处漏一处会静默错栈 | `scene-layers.js:5-10` | +| `outline: null` = 无描边,QGIS 侧转成全透明 | `scene-layers.js:13-14` | +| `mergeScene(getCollection)` 用回调取集合,让 build(内存)和 reimport(磁盘)共用同一套合并 | `scene-layers.js:107-124` | +| Python 侧 `catalog.ROAD_LAYERS` 存的是另一组事实(Blender 高度 `z` + 线性色) | `catalog.py:25-47` | +| **颜色故意不同步**:JS 是 QGIS sRGB 调试色,Python 是 Blender 线性场景色,分别调过 | `catalog.py:11-15` | +| `check_layers()` 只校验图层**集合与顺序**,读的是产物 `osm2streets_scene_style.json` | `catalog.py:162-195` | +| 校验是 warn 不是 fail:过期或缺失的输出目录不该阻断重建 | `catalog.py:168-169` | +| **加一个图层的完整清单**:改 `scene-layers.js` + `catalog.py` 两处,顺序必须一致 | 综合 | + +### 3.3 `pipeline/external-tools.md` + +| 要写什么 | 取材 | +|---|---| +| QGIS 可执行文件路径推导(`Contents/MacOS/{ogr2ogr,ogrinfo}`),启动前 `existsSync` 校验 | `reimport-gpkg.js:30-41` | +| GDAL 必须注入 `PROJ_LIB` / `GDAL_DATA`,否则坐标系静默出错 | `reimport-gpkg.js:132-137` | +| **不设 `COORDINATE_PRECISION`**:会触发精度裁剪,实测丢 28 个顶点 | `reimport-gpkg.js:152-157` | +| `ogr2ogr` 失败会留 0 字节文件 → 必须 staging 目录先导出+校验,全通过才拷回 | `reimport-gpkg.js:11-13, 61-91` | +| 用 `copyFileSync` 不用 `rename`:临时目录可能跨文件系统 | `reimport-gpkg.js:72-73` | +| 导出后必须验 `type === "FeatureCollection"` 且 `features` 是数组 | `reimport-gpkg.js:168-179` | +| Blender 以 `--background --factory-startup` 调起,`--` 之后才是脚本参数 | `build-area.js:237-290`, README 低层命令 | +| 空图层是警告不是错误 | `reimport-gpkg.js:85-88` | + +### 3.4 `blender/module-structure.md` + +| 要写什么 | 取材 | +|---|---| +| **按依赖分包不按功能**:`osm.py`/`geom.py` 纯 Python,其余可 import `bpy` | `osmassets/__init__.py:1-12` | +| 这条线是几何可测试的前提,拆分前只能靠渲染整片区域来验证 | `__init__.py:9-12`, `test_pure.py:5-8` | +| 各模块职责一览(geom/osm/mesh/materials/catalog/tree/water/grass/scrub) | 各文件 docstring | +| 要素模块统一 `assemble(...)` 签名,返回计数供调用方汇总 | `water.py:7`, `grass.py:7`, `scrub.py:6` | +| 要素模块接收裁剪边界参数,自己调 `clip_polygon`,不假设调用方已裁剪 | 三个 `assemble` 的首行 | +| `catalog` 声明"是什么"、`materials` 负责"怎么建"——这个拆分让 catalog 能被无 Blender 环境读取 | `materials.py:1-7` | +| 新增一种 OSM 要素 = 新增一个模块 + 注册一行,不改 `build()` | `docs/refactor-plan.md` 目标节 | + +### 3.5 `blender/asset-generation.md` + +| 要写什么 | 取材 | +|---|---| +| `MeshBatch` 是主力:批成一个 mesh datablock,压低对象数和 glTF 节点数 | `mesh.py:1-9` | +| 累积几何后调一次 `finish()` | `mesh.py:6-8` | +| 树用实例化:import 一次 → bake 朝向 → 每棵树只链一个轻对象复用 datablock | `tree.py:1-8` | +| 材质在本地重建而非沿用源文件,因为两个源模型都不可直接用(apple 57% 贴图透明,需 alpha-clip) | `tree.py:12-16` | +| `MATERIALS` / `ROAD_LAYERS` 是 list,**顺序决定 GLB 材质索引**,只能追加 | `catalog.py:16-18` | +| `kind` 选择构建器:`solid` / `textured`,`procedural` 叠加噪声 | `catalog.py:52-55` | +| Cesium 侧的调色覆盖(`cesium` 字段 + `CESIUM_EXPORT`)为什么单独存在 | `catalog.py:139-153` | +| Cesium 默认光照偏白,场景内材质普遍手工提亮过;新资产不提亮会显得发黑 | `docs/changelog.md` 2026-07-31 条目 | + +### 3.6 `blender/testing.md` + +| 要写什么 | 取材 | +|---|---| +| 运行方式:`python3 -m unittest discover blender/tests`,不需要 Blender | `test_pure.py:1-3` | +| **期望值必须从几何推导,不能录制当前实现**——录制型测试会把 bug 固化成规范 | `test_pure.py:9-11` | +| 每个几何函数都覆盖退化输入(空、单点、共线、重复顶点、零长线段) | `test_pure.py` 各 `test_degenerate_*` | +| 覆盖除零守卫要写明针对哪一行代码 | `test_pure.py:76-78` | +| 随机采样函数必须验 seed 可复现 | `test_pure.py:135-138` | +| 解析器的容错语义:坏节点跳过不致命,缺 bounds 直接抛 | `test_pure.py:343-356` | +| 只能测纯 Python 侧;`bpy` 侧的回归靠 parity 校验 | 指向 `guides/artifact-parity-guide.md` | + +### 3.7 `preview/index.md` + +| 要写什么 | 取材 | +|---|---| +| 无构建步骤:IIFE + `"use strict"`,通过 `window.OSM_ASSET_PREVIEW_CONFIG` 接参 | `cesium-preview.js:1-4` | +| HTML 由 `build-area.js:697 cesiumPreviewHtml()` 生成,注入需转义(`escapeHtml` / `escapeScriptJson`) | `build-area.js:759-767` | +| DOM 句柄在顶部集中获取,不散在函数里 | `cesium-preview.js:5-20` | +| status 分两类消息(场景摘要 vs 瞬时提示),摘要要保留以便瞬时提示可被替换而非摧毁 | `cesium-preview.js:21-25` | +| `Cesium.Ion.defaultAccessToken = ""`:不依赖 Ion 服务 | `cesium-preview.js:27` | +| 必须用 HTTP 服务打开,`file://` 会被浏览器拦截 | README 预览节 | +| 车辆巡航是预览层实验功能,不影响 Blender/GLB 主资产 | README 实验节 | + +### 3.8 `config/index.md` + +| 要写什么 | 取材 | +|---|---| +| 字段全表与默认值 | `config/examples/template.json`, `build-area.js:74` | +| 路径必须绝对 | template.json | +| `outputs` 覆盖是逃生舱,默认从 `id` 推导 | README 区域配置节 | +| `qgis.*` 各旋钮的物理含义(arrowScale / arrowMergeTriangles / arrowOutlineSimplifyMeters / intersectionCornerSourceMaxDimensionMeters) | README QGIS knobs 节 | +| `arrowOutlineSimplifyMeters: 0.05` 这个默认值的来历(去掉两个畸形尾顶点而不动箭头头部) | README | +| `intersectionCornerSourceMaxDimensionMeters` 为什么要过滤大多边形(会盖住可行驶路口) | README | +| 新增区域:从 `config/examples/template.json` 复制,落到 `config/areas/` | README | +| 已沉淀的两个区域配置 | `config/areas/` | + +### 3.9 `guides/artifact-parity-guide.md`(新增) + +| 要写什么 | 取材 | +|---|---| +| 什么时候需要跑 parity:任何声称"纯重构"的改动 | `docs/refactor-plan.md` 硬约束节 | +| 三件套分工:`scene_digest.py`(.blend 结构)/ `glb-digest.js`(GLB 结构)/ `parity.js`(驱动+比对) | refactor-plan 工具表 | +| **必须先做 control 实验**(同代码跑两次)确定天然不稳定字段,跳过这步的 parity 校验是假的 | refactor-plan | +| 已确定的不稳定字段与原因:blend sha256(内嵌绝对路径+图片打包顺序)、PNG(EEVEE 非位级可复现)、accessor 数(UV 浮点噪声影响去重) | refactor-plan control 结论 | +| 忽略名单写在 `parity.js:IGNORED_PATHS` 且必须附原因 | `parity.js:28-32` | +| 真正的契约:stage stdout 标记 + .blend 全量结构摘要 + GLB node/mesh/material/image + `<area>.json` | refactor-plan | +| 基线落在 gitignore 的 `outputs/_refactor-baseline/`,是本地草稿不是产物 | `parity.js:16-17` | + +### 3.10 `guides/` 现有两篇的本地化 + +保留通用内容,把"触发点"清单换成本项目的真实场景: + +- `cross-layer-thinking-guide.md`:加图层表 JS↔Python 双向同步、GeoJSON→gpkg→GeoJSON 往返、材质名跨 `generate_scene.py`/`export_cesium.py` 对接 +- `code-reuse-thinking-guide.md`:加 `parseArgs` 在三个脚本重复实现、`clip_polygon` 在四个要素模块各调一次 + +### 3.11 `spec/index.md`(新增) + +一张路由表:改哪类代码 → 先读哪个包的 index。加一句"跨语言/跨层改动先读 `guides/`"。 + +## 4. 风险与对策 + +| 风险 | 对策 | +|---|---| +| 写成"应该怎么做"的理想规范,与代码实际不符 | 每条约定必须能追到 `path:line`;PRD 验收标准里已定"每文件至少 2 处真实引用" | +| 把 README 的用法说明搬进 spec,造成两处维护 | spec 只写"怎么改代码",用法一律指向 README | +| 删 `frontend/` 时误删 `guides/` | 只删 `.trellis/spec/frontend/` 单个目录,删后立即用 `--mode packages` 验证 | +| 行号引用随代码变动失效 | 行号只作定位提示,同时写函数名/常量名;关键处引用注释原文而非行号 | + +## 5. 兼容性 + +- 不动 `config.yaml`:单仓模式自动扫描 `spec/` 子目录 +- 不动 `.template-hashes.json`:只有 2 个条目,均与本任务文件无关,`trellis update` 不会覆盖 +- 现有任务的 `implement.jsonl` / `check.jsonl`:本任务尚未生成,无需迁移 diff --git a/.trellis/tasks/00-bootstrap-guidelines/implement.md b/.trellis/tasks/00-bootstrap-guidelines/implement.md new file mode 100644 index 0000000..53ab66a --- /dev/null +++ b/.trellis/tasks/00-bootstrap-guidelines/implement.md @@ -0,0 +1,154 @@ +# 执行计划:Trellis spec 重建 + +> 对应 `prd.md` / `design.md`。按序执行,每步带验证命令。 + +--- + +## 前置校验 + +```bash +# 确认当前 spec layers 是错的(预期输出含 frontend) +python3 ./.trellis/scripts/get_context.py --mode packages + +# 确认纯 Python 测试基线是绿的(本任务不该改代码,收尾要复验) +python3 -m unittest discover blender/tests -v 2>&1 | tail -5 +``` + +记录测试基线的用例数,收尾时比对。 + +--- + +## Step 1 — 删除错配脚手架 + +- [ ] 删除 `.trellis/spec/frontend/`(7 个文件:index + 6 个模板) + +**只删这一个目录**,`guides/` 必须保留。 + +```bash +rm -rf .trellis/spec/frontend +python3 ./.trellis/scripts/get_context.py --mode packages # Spec layers 应为空 +``` + +--- + +## Step 2 — `pipeline/` 包(4 个文件) + +按依赖顺序写,`layer-registry.md` 是核心,先写它。 + +- [ ] 2.1 `pipeline/layer-registry.md` — 取材 `scene-layers.js` 全文 + `catalog.py:1-19,25-47,162-195` +- [ ] 2.2 `pipeline/external-tools.md` — 取材 `reimport-gpkg.js` 全文 + `build-area.js:237-311` +- [ ] 2.3 `pipeline/cli-and-stages.md` — 取材 `build-area.js:1-50,74-215` +- [ ] 2.4 `pipeline/index.md` — 包索引 + 五个 stage 的数据流全景 + +**写 2.3 前需补读**:`build-osm2streets-qgis.js`(1468 行,尚未通读)确认 stage 内部 +细节与 `normalize-lane-arrows.py` 的调用方式。 + +验证: +```bash +grep -c "scripts/" .trellis/spec/pipeline/*.md # 每个文件应 >= 2 +grep -rn "To fill\|TODO\|待填" .trellis/spec/pipeline/ ; echo "exit=$?" # 应为 1(无匹配) +``` + +--- + +## Step 3 — `blender/` 包(4 个文件) + +- [ ] 3.1 `blender/module-structure.md` — 取材 `osmassets/__init__.py` + 各模块 docstring +- [ ] 3.2 `blender/testing.md` — 取材 `test_pure.py:1-11` + 各 `test_degenerate_*` +- [ ] 3.3 `blender/asset-generation.md` — 取材 `mesh.py`, `materials.py`, `tree.py`, `catalog.py` + changelog 2026-07-31 +- [ ] 3.4 `blender/index.md` — 包索引 + 依赖分层图(纯 Python / bpy 两层) + +**写 3.3 前需补读**:`generate_scene.py`(999 行)与 `export_cesium.py`(624 行)的 +结构,确认材质名跨文件对接的实际方式。 + +验证: +```bash +grep -c "blender/" .trellis/spec/blender/*.md +grep -rn "To fill\|TODO\|待填" .trellis/spec/blender/ ; echo "exit=$?" +``` + +--- + +## Step 4 — `preview/` 与 `config/`(各 1 个文件) + +- [ ] 4.1 `preview/index.md` — 取材 `cesium-preview.js` + `build-area.js:697-767` +- [ ] 4.2 `config/index.md` — 取材 `config/examples/template.json` + `build-area.js:74-142` + README 配置节 + +**写 4.1 前需补读**:`cesium-preview.js`(672 行)主体,目前只读了前 30 行。 + +--- + +## Step 5 — `guides/` 本地化 + 新增 + +- [ ] 5.1 新增 `guides/artifact-parity-guide.md` — 取材 `docs/refactor-plan.md` + `parity.js` + `glb-digest.js` + `scene_digest.py` +- [ ] 5.2 更新 `guides/cross-layer-thinking-guide.md` 的触发点清单(保留通用内容) +- [ ] 5.3 更新 `guides/code-reuse-thinking-guide.md` 的触发点清单 +- [ ] 5.4 更新 `guides/index.md` 的指南表格,加入新指南 + +--- + +## Step 6 — 顶层索引与任务元数据 + +- [ ] 6.1 新增 `.trellis/spec/index.md` — 四包路由表 +- [ ] 6.2 修正 `task.json`:`relatedFiles` 改为四个新包目录,`notes` 去掉 "(frontend project)" + +```bash +python3 ./.trellis/scripts/task.py set-meta ... # 或直接编辑 task.json +``` + +--- + +## Step 7 — 全量验收 + +对照 `prd.md` 验收标准逐条过: + +```bash +# 1. spec layers 正确 +python3 ./.trellis/scripts/get_context.py --mode packages +# 预期:Spec layers: blender, config, pipeline, preview + +# 2. 无占位文本 +grep -rn "To fill\|TODO\|待填\|FIXME\|<!-- fill" .trellis/spec/ ; echo "exit=$? (1=clean)" + +# 3. frontend 已清除 +test -d .trellis/spec/frontend && echo "FAIL: still exists" || echo "OK: removed" + +# 4. 每个包有 index.md +for d in pipeline blender preview config; do + test -f ".trellis/spec/$d/index.md" && echo "OK $d" || echo "FAIL $d" +done + +# 5. 关键约定可 grep 定位(8 条) +grep -rl "COORDINATE_PRECISION" .trellis/spec/ # 约定 3 +grep -rl "check_layers" .trellis/spec/ # 约定 1 +grep -rl "PROJ_LIB\|GDAL_DATA" .trellis/spec/ # 约定 4 相关 +grep -rl "互斥" .trellis/spec/ # 约定 5 +grep -rl "bpy" .trellis/spec/blender/ # 约定 6 +grep -rl "parity" .trellis/spec/ # 约定 8 + +# 6. 代码未被误改 +git status --porcelain -- scripts/ blender/ config/ # 应为空 +python3 -m unittest discover blender/tests 2>&1 | tail -3 +``` + +--- + +## 回滚点 + +本任务只新增/删除 `.trellis/spec/` 下的文件,且删除的 `frontend/` 是**未提交的 +未跟踪文件**(`.trellis/` 整体尚未入库)。 + +回滚代价:`frontend/` 的 7 个空模板删掉后无法从 git 恢复。但它们是 `trellis init` +生成的、内容为纯占位符,可用 `trellis init` 重新生成,或直接接受丢失——PRD 已确认 +它们对本项目无价值。 + +**保险起见**:Step 1 执行前先把 `.trellis/spec/frontend/` 打包到 +`/tmp/trellis-frontend-backup.tar.gz`,任务归档后再删。 + +--- + +## 审查门 + +- Step 2 完成后暂停,让用户看 `pipeline/layer-registry.md`——这篇最能反映 + spec 的目标风格。风格若不对,后面 7 个文件不必按同样方式写完再返工。 +- Step 7 全部通过后再报告完成。 diff --git a/.trellis/tasks/00-bootstrap-guidelines/prd.md b/.trellis/tasks/00-bootstrap-guidelines/prd.md new file mode 100644 index 0000000..b3f4271 --- /dev/null +++ b/.trellis/tasks/00-bootstrap-guidelines/prd.md @@ -0,0 +1,73 @@ +# 补全 Trellis 项目规范文档 + +**类型**:docs · **负责人**:dingkang · **创建**:2026-08-03 + +--- + +## 背景 + +`trellis init` 在本仓库生成了 `.trellis/` 脚手架(v0.6.12),但把项目误判成了前端项目: + +- `.trellis/spec/frontend/` 下 6 个文件全是 React/TypeScript 的空模板,状态栏统一写着 "To fill" +- 本仓库实际是 **OSM → QGIS/Blender/Cesium 的资产生成管线**,没有任何前端代码 +- `task.json` 的 `notes` 直接写着 "First-time setup task created by trellis init (frontend project)" + +后果是具体的:`trellis-implement` / `trellis-check` 子代理会按 `implement.jsonl` / `check.jsonl` 自动加载 spec 文件。当前 spec 为空且方向错误,等于子代理在无约束下写代码——而这个项目有大量**违反直觉、必须遵守**的约定(详见下方"关键约定"),一旦被子代理无意破坏,产物会静默出错而不是报错。 + +## 目标 + +用真实代码中提取的约定,替换错配的 spec 脚手架,使任何 AI 会话在动手前就能拿到本项目的实际工程约束。 + +## 非目标 + +- **不改动任何业务代码**。本任务只写 `.trellis/` 下的文档 +- **不修复代码中的已知缺陷**。`docs/refactor-plan.md` 记录的 D1/D2/D3 只做记录,不在此任务修 +- **不重写 README/changelog**。它们面向人类使用者,spec 面向 AI 执行者,两者共存不合并 +- **不引入新的检查脚本或 CI** + +## 交付物 + +| # | 交付物 | 说明 | +|---|---|---| +| D1 | 删除 `.trellis/spec/frontend/` | 6 个空的 React 模板文件,全部移除 | +| D2 | `.trellis/spec/pipeline/` | Node 构建管线约定,4 个文件 | +| D3 | `.trellis/spec/blender/` | Blender Python 约定,4 个文件 | +| D4 | `.trellis/spec/preview/` | Cesium 预览层约定,1 个文件 | +| D5 | `.trellis/spec/config/` | 区域配置 JSON 约定,1 个文件 | +| D6 | `.trellis/spec/guides/` 本地化 | 更新 index,新增产物一致性指南 | +| D7 | `.trellis/spec/index.md` | 顶层索引 | +| D8 | `task.json` 元数据修正 | `relatedFiles` / `notes` 去掉 frontend 误判 | + +结构详见 `design.md`。 + +## 关键约定(必须被 spec 覆盖) + +这些是从代码注释和 `docs/refactor-plan.md` 中确认的、**违反直觉且破坏后果静默**的约束。spec 的价值主要在这里: + +1. **图层表跨语言单一事实源** — 九个 osm2streets 图层的顺序在 `scripts/lib/scene-layers.js`(JS 侧)和 `blender/osmassets/catalog.py`(Python 侧)各存一份,靠 `catalog.check_layers()` 运行时校验。颜色**故意不同步**(QGIS sRGB 调试色 vs Blender 线性场景色)。 +2. **顺序是承重的** — `ROAD_LAYERS` / `MATERIALS` 是 list 不是 dict,因为材质创建顺序决定导出 GLB 里的材质索引。只能追加。 +3. **`ogr2ogr` 不能设 `COORDINATE_PRECISION`** — 显式设置会触发 GDAL 的精度裁剪,实测丢失 7 个箭头多边形的 28 个顶点。 +4. **`ogr2ogr` 导出失败会留下 0 字节文件** — 所以 `reimport` 必须先导到临时目录、全部校验通过才拷回,不能直接写目标目录。 +5. **`intermediates` 与 `reimport` 互斥** — 前者从 OSM 重建 gpkg,正好抹掉后者要读回的手工修改。代码里是显式抛错,不是警告。 +6. **`osmassets` 按依赖分包,不按功能** — `osm.py` / `geom.py` 是纯 Python(可用系统 python 测试),其余可以 import `bpy`。这条线一旦被破坏,`blender/tests/test_pure.py` 就跑不起来。 +7. **测试的期望值必须从几何推导** — `test_pure.py` 开头明确写着"记录当前输出的测试会把 bug 固化成规范"。 +8. **重构必须过 parity 校验** — `scripts/parity.js` + `glb-digest.js` + `scene_digest.py` 三件套,比对的是结构摘要而非字节。哪些字段天然不稳定已经用 control 实验确定并列入忽略名单。 + +## 验收标准 + +- [ ] `.trellis/spec/frontend/` 已删除,`python3 ./.trellis/scripts/get_context.py --mode packages` 输出的 Spec layers 为 `blender, config, pipeline, preview` +- [ ] 每个 spec 文件都包含**至少 2 处指向真实文件的引用**(`path:line` 或 `path` + 函数名),没有假想路径 +- [ ] 上方"关键约定" 8 条全部落到具体 spec 文件中,可通过 grep 定位 +- [ ] 没有任何文件残留 "To fill" / "TODO" / 模板占位文本 +- [ ] 文档语言为中文;标识符、路径、代码示例保持英文原样 +- [ ] 每个包目录有 `index.md`,且顶层 `.trellis/spec/index.md` 能索引到全部包 +- [ ] `blender/tests/test_pure.py` 仍能通过(确认本任务未误改代码) + +## 完成后 + +```bash +python3 ./.trellis/scripts/task.py finish +python3 ./.trellis/scripts/task.py archive 00-bootstrap-guidelines +``` + +归档后,新加入的开发者会拿到 `00-join-<slug>` 引导任务而不是这个 bootstrap 任务。 diff --git a/.trellis/tasks/00-bootstrap-guidelines/task.json b/.trellis/tasks/00-bootstrap-guidelines/task.json new file mode 100644 index 0000000..b540fc9 --- /dev/null +++ b/.trellis/tasks/00-bootstrap-guidelines/task.json @@ -0,0 +1,33 @@ +{ + "id": "00-bootstrap-guidelines", + "name": "00-bootstrap-guidelines", + "title": "Bootstrap Guidelines", + "description": "Fill in project development guidelines for AI agents", + "status": "in_progress", + "dev_type": "docs", + "scope": null, + "package": null, + "priority": "P1", + "creator": "dingkang", + "assignee": "dingkang", + "createdAt": "2026-08-03", + "completedAt": null, + "branch": null, + "base_branch": null, + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [ + ".trellis/spec/index.md", + ".trellis/spec/pipeline/", + ".trellis/spec/blender/", + ".trellis/spec/preview/", + ".trellis/spec/config/", + ".trellis/spec/guides/" + ], + "notes": "First-time setup task for project-specific Trellis spec guidelines.", + "meta": {} +} diff --git a/.trellis/workflow.md b/.trellis/workflow.md new file mode 100644 index 0000000..bf3d1c1 --- /dev/null +++ b/.trellis/workflow.md @@ -0,0 +1,709 @@ +# Development Workflow + +--- + +## Core Principles + +1. **Plan before code** — figure out what to do before you start +2. **Specs injected, not remembered** — guidelines are injected via hook/skill, not recalled from memory +3. **Persist everything** — research, decisions, and lessons all go to files; conversations get compacted, files don't +4. **Incremental development** — one task at a time +5. **Capture learnings** — after each task, review and write new knowledge back to spec + +--- + +## Trellis System + +### Developer Identity + +On first use, initialize your identity: + +```bash +python3 ./.trellis/scripts/init_developer.py <your-name> +``` + +Creates `.trellis/.developer` (gitignored) + `.trellis/workspace/<your-name>/`. + +### Spec System + +`.trellis/spec/` holds coding guidelines organized by package and layer. + +- `.trellis/spec/<package>/<layer>/index.md` — entry point with **Pre-Development Checklist** + **Quality Check**. Actual guidelines live in the `.md` files it points to. +- `.trellis/spec/guides/index.md` — cross-package thinking guides. + +```bash +python3 ./.trellis/scripts/get_context.py --mode packages # list packages / layers +``` + +**When to update spec**: new pattern/convention found · bug-fix prevention to codify · new technical decision. + +### Task System + +Every task has its own directory under `.trellis/tasks/{MM-DD-name}/` holding `task.json`, `prd.md`, optional `design.md`, optional `implement.md`, optional `research/`, and context manifests (`implement.jsonl`, `check.jsonl`) for sub-agent-capable platforms. + +```bash +# Task lifecycle +python3 ./.trellis/scripts/task.py create "<title>" [--slug <name>] [--parent <dir>] +python3 ./.trellis/scripts/task.py start <name> # set active task (session-scoped when available) +python3 ./.trellis/scripts/task.py current --source # show active task and source +python3 ./.trellis/scripts/task.py finish # clear active task (triggers after_finish hooks) +python3 ./.trellis/scripts/task.py archive <name> # move to archive/{year-month}/ +python3 ./.trellis/scripts/task.py list [--mine] [--status <s>] +python3 ./.trellis/scripts/task.py list-archive + +# Code-spec context (injected into implement/check agents via JSONL). +# `implement.jsonl` / `check.jsonl` are seeded on `task create` for sub-agent-capable +# platforms; the AI curates real spec + research entries during planning when needed. +python3 ./.trellis/scripts/task.py add-context <name> <action> <file> <reason> +python3 ./.trellis/scripts/task.py list-context <name> [action] +python3 ./.trellis/scripts/task.py validate <name> + +# Task metadata +python3 ./.trellis/scripts/task.py set-branch <name> <branch> +python3 ./.trellis/scripts/task.py set-base-branch <name> <branch> # PR target +python3 ./.trellis/scripts/task.py set-scope <name> <scope> + +# Hierarchy (parent/child) +python3 ./.trellis/scripts/task.py add-subtask <parent> <child> +python3 ./.trellis/scripts/task.py remove-subtask <parent> <child> + +# PR creation +python3 ./.trellis/scripts/task.py create-pr [name] [--dry-run] +``` + +> Run `python3 ./.trellis/scripts/task.py --help` to see the authoritative, up-to-date list. + +**Current-task mechanism**: `task.py create` creates the task directory and (when session identity is available) auto-sets the per-session active-task pointer so the planning breadcrumb fires immediately. `task.py start` writes the same pointer (idempotent if already set) and flips `task.json.status` from `planning` to `in_progress`. State is stored under `.trellis/.runtime/sessions/`. If no context key is available from hook input, `TRELLIS_CONTEXT_ID`, or a platform-native session environment variable, there is no active task and `task.py start` fails with a session identity hint. `task.py finish` deletes the current session file (status unchanged). `task.py archive <task>` writes `status=completed`, moves the directory to `archive/`, and deletes any runtime session files that still point at the archived task. + +### Workspace System + +Records every AI session for cross-session tracking under `.trellis/workspace/<developer>/`. + +- `journal-N.md` — session log. **Max 2000 lines per file**; a new `journal-(N+1).md` is auto-created when exceeded. +- `index.md` — personal index (total sessions, last active). + +```bash +python3 ./.trellis/scripts/add_session.py --title "Title" --commit "hash" --summary "Summary" +``` + +### Context Script + +```bash +python3 ./.trellis/scripts/get_context.py # full session runtime +python3 ./.trellis/scripts/get_context.py --mode packages # available packages + spec layers +python3 ./.trellis/scripts/get_context.py --mode phase --step <X.Y> # detailed guide for a workflow step +``` + +--- + +<!-- + WORKFLOW-STATE BREADCRUMB CONTRACT (read this before editing the tag blocks below) + + The [workflow-state:STATUS] blocks embedded in the ## Phase Index section + below are the SINGLE source of truth for the per-turn `<workflow-state>` + breadcrumb that every supported AI platform's UserPromptSubmit hook + reads. inject-workflow-state.py (Python platforms) and + inject-workflow-state.js (OpenCode plugin) only parse them — there is no + fallback dict baked into the scripts after v0.5.0-rc.0. + + STATUS charset: [A-Za-z0-9_-]+. When the hook can't find a tag, it + degrades to a generic "Refer to workflow.md for current step." line — + intentionally visible so users notice and fix a broken workflow.md. + + INVARIANT (test/regression.test.ts): + Every workflow-walkthrough step marked `[required · once]` must have a + matching enforcement line in its phase's [workflow-state:*] block. The + breadcrumb is the only per-turn channel; if a mandatory step isn't + mentioned there, the AI silently skips it (Phase 1 planning gate + skip and Phase 3.4 commit skip both manifested via this gap). + + TAG ↔ PHASE scoping: + [workflow-state:no_task] → no active task; before Phase 1 + [workflow-state:planning] → all of Phase 1 (status='planning') + [workflow-state:planning-inline] → Codex inline variant of Phase 1 + [workflow-state:in_progress] → Phase 2 + Phase 3.2-3.4 + (status stays 'in_progress' from + task.py start until task.py archive) + [workflow-state:in_progress-inline] → Codex inline variant of Phase 2/3 + [workflow-state:completed] → currently DEAD: cmd_archive flips + status and moves the dir in the same + call, so the resolver loses the + pointer (block kept for a future + explicit in_progress→completed + transition) + + Editing checklist: + - When you change a [workflow-state:STATUS] block, also check the + matching phase's `[required · once]` walkthrough steps for sync + - Run `trellis update` after editing to push the new bodies to + downstream user projects (block-level managed replacement) + - Full runtime contract: + .trellis/spec/cli/backend/workflow-state-contract.md +--> + +## Phase Index + +``` +Phase 1: Plan → classify, get task-creation consent, then write planning artifacts +Phase 2: Execute → implement only after task status is in_progress +Phase 3: Finish → verify, update spec, commit, and wrap up +``` + +### Request Triage + +- Simple conversation or small task: ask only whether this turn should create a Trellis task. If the user says no, skip Trellis for this session. +- Complex task: ask whether you may create a Trellis task and enter planning. If the user says no, do not do broad inline implementation; explain, clarify scope, or suggest a smaller split. +- User approval to create a task is not approval to start implementation. Planning still happens first. + +### Planning Artifacts + +- `prd.md` — requirements, constraints, and acceptance criteria. Do not put technical design or execution checklists here. +- `design.md` — technical design for complex tasks: boundaries, contracts, data flow, tradeoffs, compatibility, rollout / rollback shape. +- `implement.md` — execution plan for complex tasks: ordered checklist, validation commands, review gates, and rollback points. +- `implement.jsonl` / `check.jsonl` — spec and research manifests for sub-agent context. They do not replace `implement.md`. +- Lightweight tasks may be PRD-only. Complex tasks must have `prd.md`, `design.md`, and `implement.md` before `task.py start`. + +### Parent / Child Task Trees + +Use a parent task when one user request contains several independently verifiable deliverables. The parent task owns the source requirement set, the task map, cross-child acceptance criteria, and final integration review; it normally should not be the implementation target unless it also has direct work. + +Use child tasks for deliverables that can be planned, implemented, checked, and archived independently. Parent/child structure is not a dependency system: if one child must wait for another, write that ordering in the child `prd.md` / `implement.md` and keep each child's acceptance criteria testable. + +Create new children with `task.py create "<title>" --slug <name> --parent <parent-dir>`. Link existing tasks with `task.py add-subtask <parent> <child>`, and unlink mistakes with `task.py remove-subtask <parent> <child>`. + +<!-- Per-turn breadcrumb: shown when there is no active task (before Phase 1) --> + +[workflow-state:no_task] +No active task. First classify the current turn and ask for task-creation consent before creating any Trellis task. +Simple conversation / small task: ask only whether this turn should create a Trellis task. If the user says no, skip Trellis for this session. +Complex task: ask the user if you can create a Trellis task and enter the planning phase. If the user says no, explain, clarify scope, or suggest a smaller split. +[/workflow-state:no_task] + +### Phase 1: Plan +- 1.0 Create task `[required · once]` (only after task-creation consent) +- 1.1 Requirement exploration `[required · repeatable]` (`prd.md`; complex tasks also need `design.md` + `implement.md`) +- 1.2 Research `[optional · repeatable]` +- 1.3 Configure context `[required · once]` — Claude Code, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Grok, Kimi Code (sub-agent-dispatch platforms only; inline platforms skip) +- 1.4 Activate task `[required · once]` (review gate, then `task.py start`; status → in_progress) +- 1.5 Completion criteria + +<!-- Per-turn breadcrumb: shown throughout Phase 1 (status='planning') --> + +[workflow-state:planning] +Load `trellis-brainstorm`; stay in planning. +Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`. +Multi-deliverable scope: consider a parent task plus independently verifiable child tasks; dependencies must be written in child artifacts, not implied by tree position. +Sub-agent mode: curate `implement.jsonl` and `check.jsonl` as spec/research manifests before start. +[/workflow-state:planning] + +<!-- Per-turn breadcrumb: shown throughout Phase 1 when codex.dispatch_mode=inline. + Codex-only opt-in alternate to [workflow-state:planning]. The main agent + edits code directly in Phase 2, so jsonl curation is skipped — + the inline workflow loads `trellis-before-dev` instead of injecting JSONL + into a sub-agent. --> + +[workflow-state:planning-inline] +Load `trellis-brainstorm`; stay in planning. +Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`. +Multi-deliverable scope: consider a parent task plus independently verifiable child tasks; dependencies must be written in child artifacts, not implied by tree position. +Inline mode: skip jsonl curation; Phase 2 reads artifacts/specs via `trellis-before-dev`. +[/workflow-state:planning-inline] + +### Phase 2: Execute +- 2.1 Implement `[required · repeatable]` +- 2.2 Quality check `[required · repeatable]` +- 2.3 Rollback `[on demand]` + +<!-- Per-turn breadcrumb: shown while status='in_progress'. + Scope: all of Phase 2 + Phase 3.2-3.4 (status stays 'in_progress' from + task.py start until task.py archive; only archive flips it). The body + therefore must cover every required step from implementation through + commit, including Phase 3.3 spec update and Phase 3.4 commit. --> + +Sub-agent dispatch protocol applies to all platforms and all sub-agents, including native Codex `SubagentStart` context injection with child-side pull fallback, class-2 Gemini/Qoder/Copilot/Reasonix/Trae/Grok/Kimi Code, hook-backed ZCode/Snow, and `trellis-research`: every dispatch prompt starts with `Active task: <task path from task.py current>` before role-specific instructions. On Grok Build, use `spawn_subagent` with `subagent_type` set to the Trellis agent name (e.g. `trellis-implement`). On Kimi Code, dispatch the built-in `coder` / `explore` sub-agent with the matching `.kimi-code/skills/trellis-<role>/SKILL.md` instructions. + +[workflow-state:in_progress] +Tools: `trellis-implement` / `trellis-research` are sub-agent types only (Task/Agent tool, NOT Skill; there is no skill by these names). `trellis-update-spec` is a skill. `trellis-check` exists as both; prefer the Agent form when verifying after code changes. +Flow: `trellis-implement` -> `trellis-check` -> `trellis-update-spec` -> commit (Phase 3.4) -> `/trellis:finish-work`. +Main-session default: dispatch implement/check sub-agents. Sub-agent self-exemption: if already running as `trellis-implement`, do NOT spawn another `trellis-implement` or `trellis-check`; if already running as `trellis-check`, do NOT spawn another `trellis-check` or `trellis-implement`. Dispatch is main session only. +Dispatch prompt starts with `Active task: <task path from task.py current>`. Read context: jsonl entries -> `prd.md` -> `design.md if present` -> `implement.md if present`. +[/workflow-state:in_progress] + +<!-- Per-turn breadcrumb: shown while status='in_progress' when + codex.dispatch_mode=inline. Codex-only opt-in alternate to + [workflow-state:in_progress]. The main session edits code directly + instead of dispatching sub-agents. --> + +[workflow-state:in_progress-inline] +Flow: `trellis-before-dev` -> edit -> `trellis-check` -> validation -> `trellis-update-spec` -> commit (Phase 3.4) -> `/trellis:finish-work`. +Do not dispatch implement/check sub-agents in inline mode. +Read context: `prd.md` -> `design.md if present` -> `implement.md if present`, plus relevant spec/research loaded by skills. +[/workflow-state:in_progress-inline] + +### Phase 3: Finish +- 3.2 Debug retrospective `[on demand]` +- 3.3 Spec update `[required · once]` +- 3.4 Commit changes `[required · once]` +- 3.5 Wrap-up reminder + +> Note: step 3.1 was folded into 2.2 (last-iteration full-scope check) and 3.4 (commit preamble). Numbering kept stable to avoid breaking external references. + +<!-- Per-turn breadcrumb: shown while status='completed'. + Currently DEAD in normal flow: cmd_archive writes status='completed' in + the same call that moves the task dir to archive/, so the active-task + resolver loses the pointer and the hook never fires on archived tasks. + Block preserved for a future status-transition redesign (e.g. an + explicit in_progress→completed command). Edit through the same spec + channel as the live blocks. --> + +[workflow-state:completed] +Code committed. Run `/trellis:finish-work`; if dirty, return to Phase 3.4 first. +[/workflow-state:completed] + +### Rules + +1. Identify which Phase you're in, then continue from the next step there +2. Run steps in order inside each Phase; `[required]` steps can't be skipped +3. Phases can roll back (e.g., Execute reveals a prd defect → return to Plan to fix, then re-enter Execute) +4. Steps tagged `[once]` are skipped if the output already exists; don't re-run +5. Artifact presence informs the next step; missing `design.md` / `implement.md` is valid for lightweight tasks and incomplete planning for complex tasks. + +### Active Task Routing + +When a user request matches one of these intents inside an active task, route first, then load the detailed phase step if needed. + +[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code] + +- Planning or unclear requirements -> `trellis-brainstorm`. +- `in_progress` implementation/check -> dispatch `trellis-implement` / `trellis-check`. +- Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`. + +[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code] + +[codex-inline, Kilo, Antigravity, Devin] + +- Planning or unclear requirements -> `trellis-brainstorm`. +- Before editing -> `trellis-before-dev`; after editing -> `trellis-check`. +- Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`. + +[/codex-inline, Kilo, Antigravity, Devin] + +### Guardrails + +- Task creation approval is not implementation approval; implementation waits for `task.py start` after artifact review. +- PRD-only is valid for lightweight tasks; complex tasks need `design.md` + `implement.md`. +- Planning must be persisted to task artifacts; checks must run before reporting completion. + +### Loading Step Detail + +At each step, run this to fetch detailed guidance: + +```bash +python3 ./.trellis/scripts/get_context.py --mode phase --step <step> +# e.g. python3 ./.trellis/scripts/get_context.py --mode phase --step 1.1 +``` + +--- + +## Phase 1: Plan + +Goal: classify the request, get task-creation consent when a task is needed, and produce the planning artifacts required before implementation. + +#### 1.0 Create task `[required · once]` + +Create the task directory only after task-creation consent. The command sets status to `planning`, writes `task.json`, creates a default `prd.md`, and auto-targets the new task when session identity is available: + +```bash +python3 ./.trellis/scripts/task.py create "<task title>" --slug <name> +``` + +`--slug` is the human-readable name only. Do **not** include the `MM-DD-` date prefix; `task.py create` adds that prefix automatically. + +For task trees, create the parent task first and then create each child with `--parent <parent-dir>`. Do not start the parent just because children exist; start the child that owns the next independently verifiable deliverable. + +After this command succeeds, the per-turn breadcrumb auto-switches to `[workflow-state:planning]`, telling the AI to stay in planning. + +Run only `create` here — do not also run `start`. `start` flips status to `in_progress`, which switches the breadcrumb to the implementation phase before planning artifacts are reviewed. Save `start` for step 1.4. + +Skip when `python3 ./.trellis/scripts/task.py current --source` already points to a task. + +#### 1.1 Requirement exploration `[required · repeatable]` + +Load the `trellis-brainstorm` skill and explore requirements interactively with the user per the skill's guidance. + +The brainstorm skill will guide you to: +- Ask one question at a time +- Prefer researching over asking the user +- Prefer offering options over open-ended questions +- Update `prd.md` immediately after each user answer +- Split large scopes into a parent task plus child tasks when the deliverables can be verified independently +- Keep `prd.md` focused on requirements and acceptance criteria +- For complex tasks, produce `design.md` and `implement.md` before implementation starts + +When considering a parent/child split: +- Use a parent task when one request contains several independently verifiable deliverables. +- Parent tasks own source requirements, child-task mapping, cross-child acceptance criteria, and final integration review. +- Child tasks own actual deliverables that can be planned, implemented, checked, and archived independently. +- Parent/child structure is not a dependency system. If child B depends on child A, write that ordering in child B's `prd.md` / `implement.md`. +- Start the child task that owns the next deliverable. Do not start the parent unless the parent itself has direct implementation work. + +Return to this step whenever requirements change and revise the relevant artifact. + +#### 1.2 Research `[optional · repeatable]` + +Research can happen at any time during requirement exploration. It isn't limited to local code — you can use any available tool (MCP servers, skills, web search, etc.) to look up external information, including third-party library docs, industry practices, API references, etc. + +[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code] + +Spawn the research sub-agent: + +- **Agent type**: `trellis-research` +- **Task description**: Research <specific question> +- **Key requirement**: Research output MUST be persisted to `{TASK_DIR}/research/` + +[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code] + +[codex-inline, Kilo, Antigravity, Devin] + +Do the research in the main session directly and write findings into `{TASK_DIR}/research/`. `codex-inline` is the explicit mode that keeps work in the main session. + +[/codex-inline, Kilo, Antigravity, Devin] + +**Research artifact conventions**: +- One file per research topic (e.g. `research/auth-library-comparison.md`) +- Record third-party library usage examples, API references, version constraints in files +- Note relevant spec file paths you discovered for later reference + +Brainstorm and research can interleave freely — pause to research a technical question, then return to talk with the user. + +**Key principle**: Research output must be written to files, not left only in the chat. Conversations get compacted; files don't. + +#### 1.3 Configure context `[required · once]` + +[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code] + +Curate `implement.jsonl` and `check.jsonl` so the Phase 2 sub-agents get the right spec/research context. These files were seeded on `task create` with a single self-describing `_example` line; your job here is to fill in real entries. + +**Location**: `{TASK_DIR}/implement.jsonl` and `{TASK_DIR}/check.jsonl` (already exist). + +**Format**: one JSON object per line — `{"file": "<path>", "reason": "<why>"}`. Paths are repo-root relative. + +**What to put in**: +- **Spec files** — `.trellis/spec/<package>/<layer>/index.md` and any specific guideline files (`error-handling.md`, `conventions.md`, etc.) relevant to this task +- **Research files** — `{TASK_DIR}/research/*.md` that the sub-agent will need to consult + +**What NOT to put in**: +- Code files (`src/**`, `packages/**/*.ts`, etc.) — those are read by the sub-agent during implementation, not pre-registered here +- Files you're about to modify — same reason + +**Split between the two files**: +- `implement.jsonl` → specs + research the implement sub-agent needs to write code correctly +- `check.jsonl` → specs for the check sub-agent (quality guidelines, check conventions, same research if needed) + +These manifests do not replace `implement.md`. `implement.md` is the human-readable execution plan for a complex task; jsonl files only list context files to inject or load. + +**How to discover relevant specs**: + +```bash +python3 ./.trellis/scripts/get_context.py --mode packages +``` + +Lists every package + its spec layers with paths. Pick the entries that match this task's domain. + +**How to append entries**: + +Either edit the jsonl file directly in your editor, or use: + +```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>" +``` + +Delete the seed `_example` line once real entries exist (optional — it's skipped automatically by consumers). + +Ready gate: both `implement.jsonl` and `check.jsonl` must contain at least one real `{"file": "...", "reason": "..."}` entry before `task.py start`. The seed `_example` row alone is not ready. + +Skip this step only when both files already have real curated entries. + +[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code] + +[codex-inline, Kilo, Antigravity, Devin] + +Skip this step. Context is loaded directly by the `trellis-before-dev` skill in Phase 2. + +[/codex-inline, Kilo, Antigravity, Devin] + +#### 1.4 Activate task `[required · once]` + +After artifact review, flip the task status to `in_progress`: + +```bash +python3 ./.trellis/scripts/task.py start <task-dir> +``` + +For lightweight tasks, `prd.md` can be enough. For complex tasks, `prd.md`, `design.md`, and `implement.md` must exist and be reviewed before start. On sub-agent-dispatch platforms, `implement.jsonl` and `check.jsonl` must both have real curated entries before start. Runtime consumers tolerate missing or seed-only manifests for compatibility, but that tolerance is not a planning-ready state. + +After this command succeeds, the breadcrumb auto-switches to `[workflow-state:in_progress]`, and the rest of Phase 2 / 3 follows. + +If `task.py start` errors with a session-identity message (no context key from hook input, `TRELLIS_CONTEXT_ID`, or platform-native session env), follow the hint in the error to set up session identity, then retry. + +#### 1.5 Completion criteria + +| Condition | Required | +|------|:---:| +| `prd.md` exists | ✅ | +| User confirms task should enter implementation | ✅ | +| `task.py start` has been run (status = in_progress) | ✅ | +| `research/` has artifacts (complex tasks) | recommended | +| `design.md` exists (complex tasks) | ✅ | +| `implement.md` exists (complex tasks) | ✅ | + +[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code] + +| `implement.jsonl` and `check.jsonl` each contain at least one real curated entry (seed row does not count) | ✅ | + +[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code] + +--- + +## Phase 2: Execute + +Goal: turn reviewed planning artifacts into code that passes quality checks. + +#### 2.1 Implement `[required · repeatable]` + +[Claude Code, Cursor, OpenCode, codex-sub-agent, CodeBuddy, Droid, Pi, ZCode, Snow, Oh My Pi] + +Spawn the implement sub-agent: + +- **Agent type**: `trellis-implement` +- **Task description**: Implement the reviewed task artifacts, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check +- **Dispatch prompt guard**: The prompt MUST start with `Active task: <task path>`, then tell the spawned agent it is already the `trellis-implement` sub-agent and must implement directly, not spawn another `trellis-implement` / `trellis-check`. + +The platform hook/plugin auto-handles: +- Reads `implement.jsonl` and injects referenced spec/research files into the agent prompt +- Injects `prd.md`, `design.md` if present, and `implement.md` if present +- For Codex, `SubagentStart` supplies native context injection; the agent profile keeps child-side loading as the fallback + +[/Claude Code, Cursor, OpenCode, codex-sub-agent, CodeBuddy, Droid, Pi, ZCode, Snow, Oh My Pi] + +[Gemini, Qoder, Copilot, Reasonix, Trae, Grok, Kimi Code] + +Spawn the implement sub-agent: + +- **Agent type**: `trellis-implement` +- **Task description**: Implement the reviewed task artifacts, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check +- **Dispatch prompt guard**: The prompt MUST start with `Active task: <task path>`, then explicitly say the spawned agent is already `trellis-implement` and must implement directly without spawning another `trellis-implement` / `trellis-check`. + +The pull-based sub-agent definition auto-handles the context load requirement: +- Resolves the active task with `task.py current --source`, then reads `prd.md`, `design.md` if present, and `implement.md` if present +- Reads `implement.jsonl` and requires the agent to load each referenced spec/research file before coding + +[/Gemini, Qoder, Copilot, Reasonix, Trae, Grok, Kimi Code] + +[Kiro] + +Spawn the implement sub-agent: + +- **Agent type**: `trellis-implement` +- **Task description**: Implement the reviewed task artifacts, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check +- **Dispatch prompt guard**: Tell the spawned agent it is already the `trellis-implement` sub-agent and must implement directly, not spawn another `trellis-implement` / `trellis-check`. + +The platform prelude auto-handles the context load requirement: +- Reads `implement.jsonl` and injects referenced spec/research files into the agent prompt +- Injects `prd.md`, `design.md` if present, and `implement.md` if present + +[/Kiro] + +[codex-inline, Kilo, Antigravity, Devin] + +1. Load the `trellis-before-dev` skill to read project guidelines +2. Read `{TASK_DIR}/prd.md`, then `design.md` if present, then `implement.md` if present +3. Consult materials under `{TASK_DIR}/research/` +4. Implement the code per reviewed artifacts +5. Run project lint and type-check + +[/codex-inline, Kilo, Antigravity, Devin] + +#### 2.2 Quality check `[required · repeatable]` + +[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code] + +Spawn the check sub-agent: + +- **Agent type**: `trellis-check` +- **Task description**: Review all code changes against specs and task artifacts; fix any findings directly; ensure lint and type-check pass +- **Dispatch prompt guard**: The prompt MUST start with `Active task: <task path>`, then tell the spawned agent it is already the `trellis-check` sub-agent and must review/fix directly, not spawn another `trellis-check` / `trellis-implement`. + +The check agent's job: +- Review code changes against specs +- Review code changes against `prd.md`, `design.md` if present, and `implement.md` if present +- Auto-fix issues it finds +- Run lint and typecheck to verify + +[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code] + +[codex-inline, Kilo, Antigravity, Devin] + +Load the `trellis-check` skill and verify the code per its guidance: +- Spec compliance +- lint / type-check / tests +- Cross-layer consistency (when changes span layers) + +If issues are found → fix → re-check, until green. + +[/codex-inline, Kilo, Antigravity, Devin] + +**Final pass (before Phase 3.4 commit)**: the last 2.2 of a task must run full-scope, not just on the latest implement chunk. List all affected packages with `python3 ./.trellis/scripts/get_context.py --mode packages`, then load each package's spec index Quality Check section. This catches cross-layer / multi-package issues a mid-iteration local 2.2 cannot. + +#### 2.3 Rollback `[on demand]` + +- `check` reveals a prd defect → return to Phase 1, fix `prd.md`, then redo 2.1 +- Implementation went wrong → revert code, redo 2.1 +- Need more research → research (same as Phase 1.2), write findings into `research/` + +--- + +## Phase 3: Finish + +Goal: ensure code quality, capture lessons, record the work. + +#### 3.2 Debug retrospective `[on demand]` + +If this task involved repeated debugging (the same issue was fixed multiple times), load the `trellis-break-loop` skill to: +- Classify the root cause +- Explain why earlier fixes failed +- Propose prevention + +The goal is to capture debugging lessons so the same class of issue doesn't recur. + +#### 3.3 Spec update `[required · once]` + +Load the `trellis-update-spec` skill and review whether this task produced new knowledge worth recording: +- Newly discovered patterns or conventions +- Pitfalls you hit +- New technical decisions + +Update the docs under `.trellis/spec/` accordingly. Even if the conclusion is "nothing to update", walk through the judgment. + +#### 3.4 Commit changes `[required · once]` + +**Spec-sync preamble**: before drafting commits, ask: did this task fix a bug or surface non-obvious knowledge that should land in `.trellis/spec/` so future-you (or future-AI) doesn't repeat the mistake? If yes, return to Phase 3.3 first — spec writes belong in the same task's commit batch, not as a forgotten follow-up. + +The AI drives a batched commit of this task's code changes so `/finish-work` can run cleanly afterwards. Goal: produce work commits FIRST, then bookkeeping (archive + journal) commits land after — never interleaved. + +**Step-by-step**: + +1. **Inspect dirty state**: + ```bash + git status --porcelain + ``` + Snapshot every dirty path. If the working tree is clean, skip to 3.5. + +2. **Learn commit style** from recent history (so drafted messages blend in): + ```bash + git log --oneline -5 + ``` + Note the prefix convention (`feat:` / `fix:` / `chore:` / `docs:` ...), language (中文/English), and length style. + +3. **Classify dirty files into two groups**: + - **AI-edited this session** — files you wrote/edited via Edit/Write/Bash tool calls in this session. You know what changed and why. + - **Unrecognized** — dirty files you did NOT touch this session (could be the user's manual edits, leftover WIP from a previous session, or unrelated work). Do NOT silently include these. + +4. **Draft a commit plan**. Group AI-edited files into logical commits (1 commit per coherent change unit, not 1 commit per file). Each entry: `<commit message>` + file list. List unrecognized files separately at the bottom. + +5. **Present the plan once, ask for one-shot confirmation**. Format: + ``` + Proposed commits (in order): + 1. <message> + - <file> + - <file> + 2. <message> + - <file> + + Unrecognized dirty files (NOT in any commit — confirm include/exclude): + - <file> + - <file> + + Reply 'ok' / '行' to execute. Reply with edits, or '我自己来' / 'manual' to abort. + ``` + +6. **On confirmation**: run `git add <files>` + `git commit -m "<msg>"` for each batch in order. Do not amend. Do not push. + +7. **On rejection** (user replies "不行" / "我自己来" / "manual" / any pushback on the plan): stop. Do not attempt a second plan. The user will commit by hand; you skip ahead to 3.5 once they confirm. + +**Rules**: +- No `git commit --amend` anywhere — three-stage three-commit flow (work commits → archive commit → journal commit). +- Never push to remote in this step. +- If the user wants different message wording but accepts the file grouping, edit the message and re-confirm once — but if they reject the grouping, exit to manual mode. +- The batched plan is one prompt; do not prompt per commit. + +#### 3.5 Wrap-up reminder + +After the above, remind the user they can run `/finish-work` to wrap up (archive the task, record the session). + +--- + +## Customizing Trellis (for forks) + +This section is for developers who want to modify the Trellis workflow itself. All customization is done by editing this file; the scripts are parsers only. + +### Changing what a step means + +Edit the corresponding step's walkthrough body in the Phase 1 / 2 / 3 sections above. Critical invariants: +- No active task must triage first and ask for task-creation consent before creating a Trellis task. +- Planning must distinguish lightweight PRD-only tasks from complex tasks that require `prd.md`, `design.md`, and `implement.md` before start. +- Every required execution path must keep the Phase 3.4 commit reminder reachable before `/trellis:finish-work`. + +All tag blocks live in the `## Phase Index` section above, immediately after each phase summary: + +| Scope | Corresponding tag | +|---|---| +| No active task (before Phase 1) | `[workflow-state:no_task]` (after the Phase Index ASCII art) | +| All of Phase 1 (task created → ready for implementation) | `[workflow-state:planning]` (after Phase 1 summary) | +| Codex inline Phase 1 | `[workflow-state:planning-inline]` | +| Phase 2 + Phase 3.2–3.4 (implementation + check + wrap-up) | `[workflow-state:in_progress]` (after Phase 2 summary) | +| Codex inline Phase 2 + Phase 3.2–3.4 | `[workflow-state:in_progress-inline]` | +| After Phase 3.5 (archived) | `[workflow-state:completed]` (after Phase 3 summary; **currently DEAD**) | + +### Changing the per-turn prompt text + +Directly edit the body of the corresponding `[workflow-state:STATUS]` block. After editing, run `trellis update` (if you're a template maintainer) or restart your AI session (if you're customizing your own project) — no script changes required. + +### Adding a custom status + +Add a new block: + +``` +[workflow-state:my-status] +your per-turn prompt text +[/workflow-state:my-status] +``` + +Constraints: +- STATUS charset: `[A-Za-z0-9_-]+` (underscores and hyphens allowed, e.g. `in-review`, `blocked-by-team`) +- A lifecycle hook must write `task.json.status` to your custom value, otherwise the tag is never read +- Lifecycle hooks live in `task.json.hooks.after_*` and bind to one of `after_create / after_start / after_finish / after_archive` + +### Adding a lifecycle hook + +Add a `hooks` field to your `task.json`: + +```json +{ + "hooks": { + "after_finish": [ + "your-script-or-command-here" + ] + } +} +``` + +Supported events: `after_create / after_start / after_finish / after_archive`. Note that `after_finish` ≠ a status change (it only clears the active-task pointer); use `after_archive` for "task is done" notifications. + +### Full contract + +For the workflow state machine's runtime contract, the locations of all status writers, pseudo-statuses (`no_task` / `stale_<source_type>`), the hook reachability matrix, and other deep details, see: + +- `.trellis/spec/cli/backend/workflow-state-contract.md` — runtime contract + writer table + test invariants +- `.trellis/scripts/inject-workflow-state.py` — actual parser (reads workflow.md only, no embedded text) diff --git a/.trellis/workspace/dingkang/index.md b/.trellis/workspace/dingkang/index.md new file mode 100644 index 0000000..f4cc4ad --- /dev/null +++ b/.trellis/workspace/dingkang/index.md @@ -0,0 +1,40 @@ +# Workspace Index - dingkang + +> Journal tracking for AI development sessions. + +--- + +## Current Status + +<!-- @@@auto:current-status --> +- **Active File**: `journal-1.md` +- **Total Sessions**: 0 +- **Last Active**: - +<!-- @@@/auto:current-status --> + +--- + +## Active Documents + +<!-- @@@auto:active-documents --> +| File | Lines | Status | +|------|-------|--------| +| `journal-1.md` | ~0 | Active | +<!-- @@@/auto:active-documents --> + +--- + +## Session History + +<!-- @@@auto:session-history --> +| # | Date | Title | Commits | Branch | +|---|------|-------|---------|--------| +<!-- @@@/auto:session-history --> + +--- + +## Notes + +- Sessions are appended to journal files +- New journal file created when current exceeds 2000 lines +- Use `add_session.py` to record sessions diff --git a/.trellis/workspace/dingkang/journal-1.md b/.trellis/workspace/dingkang/journal-1.md new file mode 100644 index 0000000..6b16c90 --- /dev/null +++ b/.trellis/workspace/dingkang/journal-1.md @@ -0,0 +1,7 @@ +# Journal - dingkang (Part 1) + +> AI development session journal +> Started: 2026-08-03 + +--- + diff --git a/.trellis/workspace/index.md b/.trellis/workspace/index.md new file mode 100644 index 0000000..f132a77 --- /dev/null +++ b/.trellis/workspace/index.md @@ -0,0 +1,125 @@ +# Workspace Index + +> Records of all AI Agent work records across all developers + +--- + +## Overview + +This directory tracks records for all developers working with AI Agents on this project. + +### File Structure + +``` +workspace/ +|-- index.md # This file - main index ++-- {developer}/ # Per-developer directory + |-- index.md # Personal index with session history + |-- tasks/ # Task files + | |-- *.json # Active tasks + | +-- archive/ # Archived tasks by month + +-- journal-N.md # Journal files (sequential: 1, 2, 3...) +``` + +--- + +## Active Developers + +| Developer | Last Active | Sessions | Active File | +|-----------|-------------|----------|-------------| +| (none yet) | - | - | - | + +--- + +## Getting Started + +### For New Developers + +Run the initialization script: + +```bash +python3 ./.trellis/scripts/init_developer.py <your-name> +``` + +This will: +1. Create your identity file (gitignored) +2. Create your progress directory +3. Create your personal index +4. Create initial journal file + +### For Returning Developers + +1. Get your developer name: + ```bash + python3 ./.trellis/scripts/get_developer.py + ``` + +2. Read your personal index: + ```bash + cat .trellis/workspace/$(python3 ./.trellis/scripts/get_developer.py)/index.md + ``` + +--- + +## Guidelines + +### Journal File Rules + +- **Max 2000 lines** per journal file +- When limit is reached, create `journal-{N+1}.md` +- Update your personal `index.md` when creating new files + +### Session Record Format + +Each session should include: +- Summary: One-line description +- Branch: Which branch the work was done on +- Main Changes: What was modified +- Git Commits: Commit hashes and messages +- Next Steps: What to do next + +--- + +## Session Template + +Use this template when recording sessions: + +```markdown +## Session {N}: {Title} + +**Date**: YYYY-MM-DD +**Task**: {task-name} +**Branch**: `{branch-name}` + +### Summary + +{One-line summary} + +### Main Changes + +- {Change 1} +- {Change 2} + +### Git Commits + +| Hash | Message | +|------|---------| +| `abc1234` | {commit message} | + +### Testing + +- [OK] {Test result} + +### Status + +[OK] **Completed** / # **In Progress** / [P] **Blocked** + +### Next Steps + +- {Next step 1} +- {Next step 2} +``` + +--- + +**Language**: All documentation must be written in **English**. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c9c4c66 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,21 @@ +<!-- TRELLIS:START --> +# Trellis Instructions + +These instructions are for AI assistants working in this project. + +This project is managed by Trellis. The working knowledge you need lives under `.trellis/`: + +- `.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) + +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 + +Managed by Trellis. Edits outside this block are preserved; edits inside may be overwritten by a future `trellis update`. + +<!-- TRELLIS:END -->