merge: 合入 Trellis 项目升级(09-05-trellis-upgrade-rule-cleanup)
这个提交包含在:
@@ -1,40 +1,15 @@
|
|||||||
---
|
---
|
||||||
name: trellis-before-dev
|
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."
|
description: "Load the task and affected-layer guidelines before implementation or when scope changes. Not for pure consultation or repeated reading of unchanged context."
|
||||||
---
|
---
|
||||||
|
|
||||||
Read the relevant development guidelines before starting your task.
|
# Before Development
|
||||||
|
|
||||||
Execute these steps:
|
1. Read the current PRD and applicable design/implementation notes if not already current in context.
|
||||||
|
2. Identify the smallest behavior gap, affected files, exclusions and validation commands. Reuse the approved plan rather than writing another checklist.
|
||||||
|
3. Discover package indexes only when the affected layer is unknown. Read its relevant coding and quality contracts; follow references needed for this change.
|
||||||
|
4. Read cross-layer or reuse guides only when shared contracts, multiple layers, shared constants or new abstractions are involved.
|
||||||
|
5. Once the plan is approved, continue through implementation and relevant validation. Ask again only for material changes to scope, risk or acceptance, or separately protected operations.
|
||||||
|
6. Keep unrelated dirty files and pre-existing failures out of scope. A local refactor requires evidence that behavior stays unchanged.
|
||||||
|
|
||||||
1. **Read current task artifacts**:
|
Do not force full document reads, repeat unchanged context, require unavailable tools, or turn a rule-only task into full business testing. Preserve privacy, production and independent-review boundaries from AGENTS.md.
|
||||||
- `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.
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ description: "Guides collaborative requirements discovery before implementation.
|
|||||||
|
|
||||||
A request to build, implement, fix, refactor, or "go ahead" is not approval to leave planning. Task-creation consent is also not implementation approval.
|
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.
|
For non-trivial work, present the plan and obtain approval before implementation. Approval of the same plan in prior turns remains valid; clerical backfill, unchanged artifacts or repeated planning breadcrumbs do not restart the approval gate. Once decisions and required artifacts are complete, start and execute the approved scope.
|
||||||
|
|
||||||
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`.
|
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`.
|
||||||
|
|
||||||
@@ -34,10 +34,10 @@ Use this skill only after task-creation consent has been given and the user is r
|
|||||||
If no task exists yet, create one:
|
If no task exists yet, create one:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
TASK_DIR=$(python3 ./.trellis/scripts/task.py create "<short task title>" --slug <slug>)
|
TASK_DIR=$(python3 ./.trellis/scripts/task.py create "<short task title>" --description "<one-line summary>" --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.
|
Use a concise title from the user's request. Both the title and `--description` must be non-empty — `create` rejects blanks, and a record with either one empty is refused at archive. 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.
|
`task.py create` creates the default `prd.md`. Update that file with the current understanding before asking follow-up questions.
|
||||||
|
|
||||||
@@ -57,8 +57,8 @@ Use a concise title from the user's request. Use a slug without a date prefix. `
|
|||||||
5. After each user answer, update `prd.md`, recompute the decision inventory, and repeat from step 2.
|
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.
|
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.
|
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.
|
8. If this plan has not yet been approved, present its final summary and wait. If the same scope has already been approved, do not ask again.
|
||||||
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.
|
9. With recorded approval and complete artifacts, run `task.py start` and implement. Repeat review only for a material scope, risk or acceptance change.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -77,9 +77,9 @@ Do not ask process questions such as whether to search, inspect files, or contin
|
|||||||
|
|
||||||
Recommendations are not default selections. Never choose a recommended product decision on the user's behalf merely because the user asked for implementation.
|
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.
|
Do not manufacture clarification questions. Present a new plan for approval when needed; if the same plan is already approved and artifacts are complete, start implementation.
|
||||||
|
|
||||||
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.
|
Final review is required for a new or materially changed plan, not for reformatting an already approved plan. Task-creation consent alone is not implementation approval.
|
||||||
|
|
||||||
## Thinking Framework: First Principles Analysis
|
## Thinking Framework: First Principles Analysis
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ The final planning summary must show Goal, In Scope, Out of Scope, Acceptance Cr
|
|||||||
|
|
||||||
Lightweight tasks may have only `prd.md`. Complex tasks must have `prd.md`, `design.md`, and `implement.md` 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`.
|
`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`; an empty manifest, or one holding only a legacy `_example` placeholder row, does not count. Inline workflows skip this JSONL gate because Phase 2 loads context through `trellis-before-dev`.
|
||||||
|
|
||||||
## PRD Convergence Pass
|
## PRD Convergence Pass
|
||||||
|
|
||||||
@@ -195,6 +195,6 @@ Before declaring planning ready:
|
|||||||
- Complex tasks have `design.md` and `implement.md`.
|
- 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.
|
- 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.
|
- The latest final planning summary has been presented to the user.
|
||||||
- In a subsequent message, the user explicitly approved that summary for implementation.
|
- The user explicitly approved this scope for implementation; unchanged restatements and clerical backfill do not invalidate that approval.
|
||||||
|
|
||||||
Do not start implementation merely because the user originally asked for implementation.
|
Do not start implementation merely because the user originally asked for implementation.
|
||||||
|
|||||||
@@ -1,98 +1,15 @@
|
|||||||
---
|
---
|
||||||
name: trellis-check
|
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."
|
description: "Verify the current task diff against its acceptance criteria and affected-layer contracts. Use after implementation or before an authorized commit, not for unrelated repository cleanup."
|
||||||
---
|
---
|
||||||
|
|
||||||
# Code Quality Check
|
# Task-Scoped Quality Check
|
||||||
|
|
||||||
Comprehensive quality verification for recently written code. Combines spec compliance, cross-layer safety, and pre-commit checks.
|
1. Identify files changed by this task. Git dirty state is evidence, not permission to include other work.
|
||||||
|
2. Read acceptance criteria and applicable quality contracts not already loaded. Check the complete task diff, not only the last chunk.
|
||||||
|
3. Run the minimum meaningful checks: targeted regressions for behavior changes, relevant syntax/type/build checks for affected layers, and governance/reference checks for rules. Expand for shared contracts and cross-layer risks.
|
||||||
|
4. Verify permissions, state transitions, error handling, data flow and tests when affected. Review abstractions and fallback behavior only where the task changes them.
|
||||||
|
5. Fix task-caused failures and rerun affected checks. Report pre-existing failures separately; do not repair unrelated business code. Reuse passing checks whose inputs remain unchanged.
|
||||||
|
6. Report what passed, failed, was not run and why. Distinguish static checks, runtime tests, independent review and public deployment. Never claim a separate reviewer when only the main session checked.
|
||||||
|
|
||||||
---
|
Run one final task-wide acceptance pass before handoff. Do not repeat full-repository tests each iteration or require deployment for pure rules, documentation or local tooling. Capture reusable contracts in the existing spec only when they add value.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ Shows the Phase Index (Plan / Execute / Finish) with routing + skill mapping.
|
|||||||
|
|
||||||
- `status=planning` + no `prd.md` → **1.1** (load `trellis-brainstorm`)
|
- `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` + `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` + complex artifacts complete + sub-agent jsonl not curated (empty, or only a legacy `_example` placeholder 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=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 not started → **2.1**
|
||||||
- `status=in_progress` + implementation done, not yet checked → **2.2**
|
- `status=in_progress` + implementation done, not yet checked → **2.2**
|
||||||
|
|||||||
@@ -1,71 +1,14 @@
|
|||||||
---
|
---
|
||||||
name: trellis-finish-work
|
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."
|
description: "Close out an explicitly requested session or completed task with truthful verification and progress notes. Does not implicitly authorize commits, archival, deployment or other-task cleanup."
|
||||||
---
|
---
|
||||||
|
|
||||||
# Finish Work
|
# 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.
|
1. Compare this task with its acceptance criteria and latest validation evidence. Do not scan unrelated active tasks for cleanup.
|
||||||
|
2. Report implementation, tests, independent review and deployment separately. A completed stage can leave the overall task active; unresolved acceptance must not be checked off.
|
||||||
|
3. Update existing task notes with concise, redacted results and remaining steps. Record a journal only when useful or requested; avoid duplicating stable facts across documents.
|
||||||
|
4. Commit only when explicitly requested, using named task files and a Chinese message containing the task identifier. Never stage unrelated changes or auto-commit journal files; respect session_auto_commit=false.
|
||||||
|
5. Archive only when the task is genuinely complete and archival is explicitly requested. Otherwise leave the task active and report the handoff. Do not infer archive permission from “done” or from reaching the end of a reply.
|
||||||
|
|
||||||
## Step 1: Survey current state
|
Do not deploy, push, delete files, scan prior conversations, or clean other tasks as a side effect. Tool limitations and pending independent review must be stated rather than hidden or used to repeat an already granted authorization.
|
||||||
|
|
||||||
```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`.
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Trellis v0.6 adds three architectural surfaces on top of the pre-v0.6 workflow /
|
|||||||
The default operating scope is local files in the user project:
|
The default operating scope is local files in the user project:
|
||||||
|
|
||||||
- `.trellis/`: workflow, config, tasks, spec, workspace, scripts, bundled runtime agents, and runtime state.
|
- `.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`.
|
- 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, delivers commands plus agent prompts as `.kimi-code/skills/<name>/SKILL.md`, and installs the same agent prompts as custom sub-agents under `.kimi-code/agents/<name>.md`.
|
||||||
- Shared skill layer: `.agents/skills/`.
|
- Shared skill layer: `.agents/skills/`.
|
||||||
- User-owned channel store outside the project tree: `~/.trellis/channels/<project>/<channel>/events.jsonl`.
|
- 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).
|
- Raw platform conversation logs queryable via `trellis mem`: `~/.claude/projects/`, `~/.codex/sessions/`, and `~/.pi/agent/sessions/` (OpenCode adapter degraded for the v0.6 line).
|
||||||
|
|||||||
@@ -31,30 +31,42 @@ The list is discovered at runtime, so adding a new directory under `bundled-skil
|
|||||||
|
|
||||||
## Where Bundled Skills Land Per Platform
|
## 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.
|
A platform's whole file set — commands, workflow skills, agents, hooks, bundled skills — is described exactly once, by `collect<Platform>Templates()` in `packages/cli/src/configurators/<platform>.ts`. For bundled skills that description is two calls: `resolveBundledSkills(ctx)` reads every directory under `templates/common/bundled-skills/`, resolves placeholders, and returns a flat list of `{relativePath, content}` entries; `collectSkillTemplates(<skillsRoot>, <workflowSkills>, <bundledSkills>)` folds them into the platform's `Map<filePath, content>` under `<skillsRoot>/<skill>/<relativePath>`.
|
||||||
|
|
||||||
| Platform | Bundled skill root | Notes |
|
All 21 platforms receive the full bundled-skill set:
|
||||||
| --- | --- | --- |
|
|
||||||
| 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:
|
| Platform | Bundled skill root |
|
||||||
|
| --- | --- |
|
||||||
|
| Claude Code | `.claude/skills/<skill>/` |
|
||||||
|
| Cursor | `.cursor/skills/<skill>/` |
|
||||||
|
| OpenCode | `.opencode/skills/<skill>/` |
|
||||||
|
| Codex | `.agents/skills/<skill>/` |
|
||||||
|
| Gemini CLI | `.agents/skills/<skill>/` |
|
||||||
|
| Pi | `.agents/skills/<skill>/` |
|
||||||
|
| Kimi | `.agents/skills/<skill>/` |
|
||||||
|
| Kilo | `.kilocode/skills/<skill>/` |
|
||||||
|
| Kiro | `.kiro/skills/<skill>/` |
|
||||||
|
| Antigravity | `.agent/skills/<skill>/` |
|
||||||
|
| Devin | `.devin/skills/<skill>/` |
|
||||||
|
| Qoder | `.qoder/skills/<skill>/` |
|
||||||
|
| Codebuddy | `.codebuddy/skills/<skill>/` |
|
||||||
|
| Copilot | `.github/skills/<skill>/` |
|
||||||
|
| Droid | `.factory/skills/<skill>/` |
|
||||||
|
| Reasonix | `.reasonix/skills/<skill>/` |
|
||||||
|
| ZCode | `.zcode/skills/<skill>/` |
|
||||||
|
| Trae | `.trae/skills/<skill>/` |
|
||||||
|
| OMP | `.omp/skills/<skill>/` |
|
||||||
|
| Grok | `.grok/skills/<skill>/` |
|
||||||
|
| Snow | `.snow/skills/<skill>/` |
|
||||||
|
|
||||||
1. `configureX(cwd)` writes files during `trellis init`.
|
Codex, Gemini CLI, Pi and Kimi share the `.agents/skills/` root (the upstream Agent Skills workspace alias). Their collectors are required to emit byte-identical content for every file more than one of them writes there.
|
||||||
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))`.
|
|
||||||
|
One description, two consumers:
|
||||||
|
|
||||||
|
1. `trellis init` → `configurePlatform(platformId, cwd)` → `writeTemplateMap(cwd, collect<Platform>Templates())`. For 18 of the 21 platforms the registry entry in `configurators/index.ts` is literally `fromTemplates(collect<Platform>Templates)`, which *is* that composition. Claude Code, Codex and ZCode spell out a `configure` of their own, each for work a `Map<path, content>` cannot express (an opt-in `--with-statusline` flag, an intentionally empty `.codex/skills/` directory, a one-shot console notice) — none of them restates the file list.
|
||||||
|
2. `trellis update` → `collectPlatformTemplates(platformId)` (in `configurators/index.ts`) → the same map, used to detect drift and to populate `.trellis/.template-hashes.json`.
|
||||||
|
|
||||||
|
Because both consumers read the one description, init and update cannot disagree about which files a bundled skill produces.
|
||||||
|
|
||||||
## Dispatch Wiring (Code Path)
|
## Dispatch Wiring (Code Path)
|
||||||
|
|
||||||
@@ -67,10 +79,10 @@ The mechanism that auto-dispatches bundled skills to platform skill roots lives
|
|||||||
|
|
||||||
2. `packages/cli/src/configurators/shared.ts`
|
2. `packages/cli/src/configurators/shared.ts`
|
||||||
- `resolveBundledSkills(ctx)` flattens that list into `ResolvedSkillFile[]` with `<skill>/<relativePath>` paths and resolved placeholders.
|
- `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 workflow skills and bundled skill files together as a `Map<filePath, content>` rooted at `skillsRoot`.
|
||||||
- `collectSkillTemplates(skillsRoot, workflowSkills, bundledSkills)` returns the same shape as a `Map<filePath, content>` for the update / hash pipeline.
|
- `writeTemplateMap(cwd, files)` is the single writer that puts a collected map on disk.
|
||||||
|
|
||||||
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.
|
Every platform that supports skills reaches those two helpers from its own `collect<Platform>Templates()` — either directly (`claude.ts`, `codex.ts`, `copilot.ts`, `gemini.ts`, `grok.ts`, `kimi.ts`, `kiro.ts`, `omp.ts`, `opencode.ts`, `pi.ts`, `reasonix.ts`, `snow.ts`, `zcode.ts`) or through `collectBothTemplates(ctx, cmdPath, skillRoot)` in `shared.ts`, which makes the same two calls on behalf of platforms that have both a commands directory and a skills root (`antigravity.ts`, `codebuddy.ts`, `cursor.ts`, `devin.ts`, `droid.ts`, `kilo.ts`, `qoder.ts`, `trae.ts`).
|
||||||
|
|
||||||
## Adding a New Bundled Skill
|
## Adding a New Bundled Skill
|
||||||
|
|
||||||
@@ -137,7 +149,7 @@ There is no per-project opt-out flag for bundled skills. Two options:
|
|||||||
|
|
||||||
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.
|
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.
|
A third option — globally disabling all bundled skills — is not supported. The dispatch is unconditional: `collect<Platform>Templates()` takes no arguments, so there is nowhere for a flag to enter. Adding one would mean changing that signature across all 21 platforms plus `collectPlatformTemplates` in `configurators/index.ts`.
|
||||||
|
|
||||||
## Operating Rules
|
## Operating Rules
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ In both modes, JSONL files in the task directory are the manifest for spec/resea
|
|||||||
{"file": ".trellis/spec/backend/index.md", "reason": "Backend rules"}
|
{"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.
|
Readers should skip rows without a `file` field (e.g. legacy `_example` placeholders). 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 And Context Key
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ Use child tasks for deliverables that can move through planning, implementation,
|
|||||||
Create new children with:
|
Create new children with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 ./.trellis/scripts/task.py create "<child title>" --slug <child-slug> --parent <parent-dir>
|
python3 ./.trellis/scripts/task.py create "<child title>" --description "<one-line summary>" --slug <child-slug> --parent <parent-dir>
|
||||||
```
|
```
|
||||||
|
|
||||||
Link or unlink existing tasks with:
|
Link or unlink existing tasks with:
|
||||||
@@ -101,12 +101,12 @@ Rules:
|
|||||||
- Include spec and research files.
|
- Include spec and research files.
|
||||||
- Do not include code files that are about to be modified.
|
- Do not include code files that are about to be modified.
|
||||||
- Do not treat temporary conclusions in chat as the only context.
|
- 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.
|
- Rows without a `file` field are skipped by readers. Legacy `{"_example": ...}` placeholder rows are rejected by `task.py validate` — delete them.
|
||||||
|
|
||||||
## Common Commands
|
## Common Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 ./.trellis/scripts/task.py create "<title>" --slug <slug>
|
python3 ./.trellis/scripts/task.py create "<title>" --description "<one-line summary>" --slug <slug>
|
||||||
python3 ./.trellis/scripts/task.py start <task>
|
python3 ./.trellis/scripts/task.py start <task>
|
||||||
python3 ./.trellis/scripts/task.py current --source
|
python3 ./.trellis/scripts/task.py current --source
|
||||||
python3 ./.trellis/scripts/task.py add-context <task> implement <file> <reason>
|
python3 ./.trellis/scripts/task.py add-context <task> implement <file> <reason>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Agent files should not become generic chat prompts. They should define input sou
|
|||||||
| Pi Agent | `.pi/agents/trellis-*.md` |
|
| Pi Agent | `.pi/agents/trellis-*.md` |
|
||||||
| Reasonix | `.reasonix/skills/trellis-*/SKILL.md` (subagent frontmatter) |
|
| Reasonix | `.reasonix/skills/trellis-*/SKILL.md` (subagent frontmatter) |
|
||||||
| ZCode | `.zcode/agents/trellis-*.md` |
|
| 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) |
|
| Kimi Code | `.kimi-code/agents/trellis-*.md` (custom sub-agents; the same prompts also ship as `.kimi-code/skills/trellis-*/SKILL.md`) |
|
||||||
|
|
||||||
GitHub Copilot agent/prompt support is provided by a combination of directories such as `.github/agents/`, `.github/prompts/`, and `.github/skills/`; inspect the files actually generated in the user project.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ Common files:
|
|||||||
| Pi Agent | `.pi/settings.json`, `.pi/extensions/trellis/` |
|
| Pi Agent | `.pi/settings.json`, `.pi/extensions/trellis/` |
|
||||||
| Trae IDE | `.trae/hooks.json` |
|
| 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.
|
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 and `.kimi-code/agents/` sub-agent definitions with the same prelude.
|
||||||
|
|
||||||
Whether these files exist in a project depends on which `trellis init --<platform>` flags the user ran.
|
Whether these files exist in a project depends on which `trellis init --<platform>` flags the user ran.
|
||||||
|
|
||||||
|
|||||||
@@ -19,12 +19,13 @@ This page lists common Trellis file locations in a user project by platform. Whe
|
|||||||
| CodeBuddy | `--codebuddy` | `.codebuddy/` | `.codebuddy/skills/` | `.codebuddy/agents/` | `.codebuddy/hooks/` + `.codebuddy/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 |
|
| GitHub Copilot | `--copilot` | `.github/` | `.github/skills/` | `.github/agents/` | `.github/copilot/hooks/` + prompts |
|
||||||
| Factory Droid | `--droid` | `.factory/` | `.factory/skills/` | `.factory/droids/` | `.factory/hooks/` + settings |
|
| Factory Droid | `--droid` | `.factory/` | `.factory/skills/` | `.factory/droids/` | `.factory/hooks/` + settings |
|
||||||
|
| DeepSeek Harness (dsh) | `--dsh` | `.dsh/` | `.agents/skills/` (shared) + `.dsh/skills/` (entry skills) | None (workflow skills run implement/check inline) | None (class-2 pull-based; no project hooks/settings) |
|
||||||
| Pi Agent | `--pi` | `.pi/` | `.agents/skills/` | `.pi/agents/` | `.pi/extensions/trellis/` (native `trellis_subagent` tool) + `.pi/settings.json` |
|
| 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` |
|
| 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 |
|
| 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 |
|
| 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`) |
|
| 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) |
|
| Kimi Code | `--kimi` | `.kimi-code/` | `.agents/skills/` (shared) + `.kimi-code/skills/` | `.kimi-code/agents/` (custom sub-agents; same prompts also ship as skills) | 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` |
|
| 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
|
## Capability Groups
|
||||||
@@ -48,7 +49,7 @@ These platforms usually have `trellis-research`, `trellis-implement`, and `trell
|
|||||||
- Reasonix (delivered as skills with `runAs: subagent` under `.reasonix/skills/`, not as a separate `agents/` directory)
|
- Reasonix (delivered as skills with `runAs: subagent` under `.reasonix/skills/`, not as a separate `agents/` directory)
|
||||||
- ZCode
|
- ZCode
|
||||||
- Grok Build (`.grok/agents/`; dispatch via `spawn_subagent` with `subagent_type`)
|
- 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)
|
- Kimi Code (`.kimi-code/agents/`; the same prompts are also delivered as skills under `.kimi-code/skills/`)
|
||||||
- Snow CLI (`.snow/agents/`; auto-discovered project agents + class-1 hooks)
|
- 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.
|
When changing implementation/check/research behavior, look for the corresponding platform agent files first.
|
||||||
@@ -73,7 +74,7 @@ When changing behavior, inspect workflows and skills first. Do not assume Trelli
|
|||||||
|
|
||||||
### Shared `.agents/skills/`
|
### 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/`.
|
Codex, Gemini CLI, Pi Agent, Kimi Code, and DeepSeek Harness (dsh) 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
|
## Decision Rules When Modifying Platform Files
|
||||||
|
|
||||||
|
|||||||
@@ -1,64 +1,14 @@
|
|||||||
---
|
---
|
||||||
name: trellis-start
|
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."
|
description: "Initialize or resume non-trivial work in this Trellis project. Skip pure consultation and small isolated edits unless task tracking is requested."
|
||||||
---
|
---
|
||||||
|
|
||||||
# Start Session
|
# 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.
|
1. Classify the request before loading context. Pure consultation and small isolated edits do not need a task or a task-creation question. Preserve risk boundaries in AGENTS.md.
|
||||||
|
2. For tracked work, obtain current task and status from already supplied context, or run get_context.py when missing or stale. Do not enumerate unrelated journals and historical tasks.
|
||||||
|
3. Read the workflow Phase Index and current step on demand. Read the current PRD, then design and implementation notes if needed. Reuse unchanged material already loaded in this session.
|
||||||
|
4. With no task, obtain task-creation consent for non-trivial work unless already authorized. With a planning task, complete missing decisions; once its plan is approved, start and execute without asking again. An existing PRD alone is not proof of approval.
|
||||||
|
5. For implementation load trellis-before-dev; for verification load trellis-check. Load spec indexes only for affected layers and follow relevant links, not every guideline.
|
||||||
|
|
||||||
---
|
The project workflow and task status are authoritative over legacy user-level Trellis skills. Missing optional tools require a supported fallback or a precise limitation report, not repeated permission requests.
|
||||||
|
|
||||||
## 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`.
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ Trellis Context Loading Protocol:
|
|||||||
- If there is no saved-output notice and the marker is absent, use the active-task fallback below.
|
- 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.
|
- 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.
|
Your job is to review code changes against specs and fix what is safe to fix. Mechanical, local issues (lint, types, imports, dead branches, failing assertions) — fix them in place. Design or judgment calls, public interfaces, module boundaries, or anything outside the current task's scope — report the evidence and your recommendation, and leave the code alone.
|
||||||
|
|
||||||
Review checklist:
|
Review checklist:
|
||||||
- Verify behavior against the actual code paths, not assumptions.
|
- Verify behavior against the actual code paths, not assumptions.
|
||||||
@@ -33,7 +33,7 @@ Review checklist:
|
|||||||
- Run lint and type-check; fix any failures.
|
- Run lint and type-check; fix any failures.
|
||||||
- Prefer concrete findings over speculative warnings.
|
- Prefer concrete findings over speculative warnings.
|
||||||
|
|
||||||
When you find an issue:
|
When you find a mechanical, local issue:
|
||||||
1. Fix it directly using edit/write tools.
|
1. Fix it directly using edit/write tools.
|
||||||
2. Re-run lint and type-check until green.
|
2. Re-run lint and type-check until green.
|
||||||
3. Record what you changed and why.
|
3. Record what you changed and why.
|
||||||
@@ -45,7 +45,7 @@ Output format:
|
|||||||
- Fix: <what you changed>
|
- Fix: <what you changed>
|
||||||
|
|
||||||
## Findings (not fixed)
|
## Findings (not fixed)
|
||||||
Only list issues you could not self-fix (e.g. missing product decision, out-of-scope). Explain why.
|
List every issue you did not fix — the ones you could not (missing product decision) and the ones you chose not to (design or judgment call, public interface, module boundary, outside this task's scope). Explain which and why.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
- Lint: pass/fail
|
- Lint: pass/fail
|
||||||
|
|||||||
@@ -92,9 +92,16 @@ def _detect_platform(input_data: dict) -> str | None:
|
|||||||
return "codex"
|
return "codex"
|
||||||
if isinstance(input_data.get("cursor_version"), str):
|
if isinstance(input_data.get("cursor_version"), str):
|
||||||
return "cursor"
|
return "cursor"
|
||||||
|
# CLAUDE_PROJECT_DIR is a compatibility alias that several hosts set
|
||||||
|
# alongside their own variable — CodeBuddy, ZCode and Trae all do. It must
|
||||||
|
# therefore be checked LAST, or every one of them is detected as claude and
|
||||||
|
# the context key becomes `claude_<their-session-id>`. That key does not
|
||||||
|
# match the session file `task.py start` wrote under the host's real name,
|
||||||
|
# so the sub-agent starts with no task context while the pointer exists on
|
||||||
|
# disk. Same fix as inject-workflow-state.py and session-start.py; this
|
||||||
|
# third copy was missed when those two were corrected.
|
||||||
env_map = {
|
env_map = {
|
||||||
"ZCODE_PROJECT_DIR": "zcode",
|
"ZCODE_PROJECT_DIR": "zcode",
|
||||||
"CLAUDE_PROJECT_DIR": "claude",
|
|
||||||
"CURSOR_PROJECT_DIR": "cursor",
|
"CURSOR_PROJECT_DIR": "cursor",
|
||||||
"CODEBUDDY_PROJECT_DIR": "codebuddy",
|
"CODEBUDDY_PROJECT_DIR": "codebuddy",
|
||||||
"FACTORY_PROJECT_DIR": "droid",
|
"FACTORY_PROJECT_DIR": "droid",
|
||||||
@@ -102,6 +109,9 @@ def _detect_platform(input_data: dict) -> str | None:
|
|||||||
"QODER_PROJECT_DIR": "qoder",
|
"QODER_PROJECT_DIR": "qoder",
|
||||||
"KIRO_PROJECT_DIR": "kiro",
|
"KIRO_PROJECT_DIR": "kiro",
|
||||||
"COPILOT_PROJECT_DIR": "copilot",
|
"COPILOT_PROJECT_DIR": "copilot",
|
||||||
|
"TRAE_PROJECT_DIR": "trae",
|
||||||
|
# Last: the shared alias, only meaningful once no vendor key matched.
|
||||||
|
"CLAUDE_PROJECT_DIR": "claude",
|
||||||
}
|
}
|
||||||
for env_name, platform in env_map.items():
|
for env_name, platform in env_map.items():
|
||||||
if os.environ.get(env_name):
|
if os.environ.get(env_name):
|
||||||
@@ -238,9 +248,33 @@ class _Budget:
|
|||||||
self.used += size
|
self.used += size
|
||||||
|
|
||||||
|
|
||||||
|
def _real_path_contained(base_real: str, target_real: str) -> bool:
|
||||||
|
"""Whether an already-realpath'd target sits under an already-realpath'd base.
|
||||||
|
|
||||||
|
ValueError on Windows when the two sit on different drives; that is
|
||||||
|
outside the base by definition, so it fails closed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return os.path.commonpath([base_real, target_real]) == base_real
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _read_file_bytes(base_path: str, file_path: str) -> bytes | None:
|
def _read_file_bytes(base_path: str, file_path: str) -> bytes | None:
|
||||||
"""Read raw file bytes, return None if file doesn't exist."""
|
"""Read raw file bytes, return None if file doesn't exist."""
|
||||||
full_path = os.path.join(base_path, file_path)
|
full_path = os.path.join(base_path, file_path)
|
||||||
|
try:
|
||||||
|
root_real = os.path.realpath(base_path)
|
||||||
|
# `.trellis` may itself be a symlink into a store outside the repo
|
||||||
|
# (#567); its real location is a second legitimate containment base.
|
||||||
|
workflow_real = os.path.realpath(os.path.join(base_path, ".trellis"))
|
||||||
|
full_real = os.path.realpath(full_path)
|
||||||
|
if not _real_path_contained(root_real, full_real) and not (
|
||||||
|
_real_path_contained(workflow_real, full_real)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
if os.path.exists(full_path) and os.path.isfile(full_path):
|
if os.path.exists(full_path) and os.path.isfile(full_path):
|
||||||
try:
|
try:
|
||||||
with open(full_path, "rb") as f:
|
with open(full_path, "rb") as f:
|
||||||
@@ -365,12 +399,12 @@ def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[dict]:
|
|||||||
Schema:
|
Schema:
|
||||||
{"file": "path/to/file.md", "reason": "..."}
|
{"file": "path/to/file.md", "reason": "..."}
|
||||||
{"file": "path/to/dir/", "type": "directory", "reason": "..."}
|
{"file": "path/to/dir/", "type": "directory", "reason": "..."}
|
||||||
{"_example": "..."} # seed row — skipped (no `file` field)
|
{"_example": "..."} # legacy placeholder — skipped (no `file` field)
|
||||||
|
|
||||||
Rows without a ``file`` field (e.g. the self-describing seed line written
|
Rows without a ``file`` field (e.g. the placeholder line older Trellis
|
||||||
by ``task.py create`` before the agent has curated entries) are skipped
|
versions wrote at ``task.py create`` time) are skipped silently. If the
|
||||||
silently. If the resulting entry list is empty, a stderr warning is
|
resulting entry list is empty, a stderr warning is emitted so the operator
|
||||||
emitted so the operator can debug missing context.
|
can debug missing context.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
[{"file": path, "type": "file" | "directory", "reason": reason}, ...]
|
[{"file": path, "type": "file" | "directory", "reason": reason}, ...]
|
||||||
@@ -459,6 +493,17 @@ def get_agent_context(
|
|||||||
"""
|
"""
|
||||||
agent_jsonl = f"{task_dir}/{agent_type}.jsonl"
|
agent_jsonl = f"{task_dir}/{agent_type}.jsonl"
|
||||||
blocks = _materialize_jsonl_entries(repo_root, agent_jsonl, limits, budget)
|
blocks = _materialize_jsonl_entries(repo_root, agent_jsonl, limits, budget)
|
||||||
|
if not blocks:
|
||||||
|
# Zero curated context reaches the model silently otherwise — the
|
||||||
|
# stderr WARN above never enters any session (#573). Put the fact in
|
||||||
|
# the prompt itself so the sub-agent compensates instead of assuming
|
||||||
|
# the spec context was complete.
|
||||||
|
return (
|
||||||
|
f"[Trellis] {agent_jsonl} has no curated entries, so no spec/research "
|
||||||
|
"context was injected. Before working, read the guidelines relevant "
|
||||||
|
"to the code you will touch under .trellis/spec/, and treat the task "
|
||||||
|
"artifacts below as the only prepared context."
|
||||||
|
)
|
||||||
return "\n\n".join(blocks)
|
return "\n\n".join(blocks)
|
||||||
|
|
||||||
|
|
||||||
@@ -877,8 +922,15 @@ def _handle_codex_subagent_start(input_data: dict) -> None:
|
|||||||
if not subagent_type or not parent_session_id:
|
if not subagent_type or not parent_session_id:
|
||||||
return
|
return
|
||||||
|
|
||||||
cwd = _string_value(input_data.get("cwd")) or os.getcwd()
|
# Payload cwd first, then our own — some hosts (CodeBuddy IDE 4.10.4)
|
||||||
repo_root = find_repo_root(cwd)
|
# report "/" for every hook event. See inject-workflow-state.py.
|
||||||
|
repo_root = None
|
||||||
|
for candidate in (_string_value(input_data.get("cwd")), os.getcwd()):
|
||||||
|
if not candidate:
|
||||||
|
continue
|
||||||
|
repo_root = find_repo_root(candidate)
|
||||||
|
if repo_root:
|
||||||
|
break
|
||||||
if not repo_root:
|
if not repo_root:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -979,6 +1031,8 @@ def _extract_subagent_type(tool_input: dict) -> str:
|
|||||||
"subagentType",
|
"subagentType",
|
||||||
"subagent_type_name",
|
"subagent_type_name",
|
||||||
"subagentTypeName",
|
"subagentTypeName",
|
||||||
|
"subagent_name",
|
||||||
|
"subagentName",
|
||||||
"agent_type",
|
"agent_type",
|
||||||
"agentType",
|
"agentType",
|
||||||
"name",
|
"name",
|
||||||
@@ -994,7 +1048,8 @@ def _parse_hook_input(input_data: dict) -> tuple[str, str, dict]:
|
|||||||
|
|
||||||
Returns (subagent_type, original_prompt, tool_input).
|
Returns (subagent_type, original_prompt, tool_input).
|
||||||
Handles:
|
Handles:
|
||||||
- Claude Code / Qoder / CodeBuddy / Droid: tool_name=Task|Agent, tool_input.subagent_type
|
- Claude Code / Qoder / Droid: tool_name=Task|Agent, tool_input.subagent_type
|
||||||
|
- CodeBuddy: tool_name=task (IDE) or Task (CLI), tool_input.subagent_name
|
||||||
- Cursor: tool_name=Task|Subagent, tool_input.subagent_type
|
- Cursor: tool_name=Task|Subagent, tool_input.subagent_type
|
||||||
- Copilot CLI: toolName=task (camelCase key, lowercase value)
|
- Copilot CLI: toolName=task (camelCase key, lowercase value)
|
||||||
- ZCode: toolName=Agent, toolInput/tool_input.subagent_type
|
- ZCode: toolName=Agent, toolInput/tool_input.subagent_type
|
||||||
@@ -1073,8 +1128,24 @@ def main():
|
|||||||
if subagent_type in AGENTS_REQUIRE_TASK:
|
if subagent_type in AGENTS_REQUIRE_TASK:
|
||||||
if not task_dir:
|
if not task_dir:
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
# Check if task directory exists
|
# Contain the pointer before reading anything through it. `task.py` now
|
||||||
task_dir_full = os.path.join(repo_root, task_dir)
|
# refuses to store a ref that leaves the repo, but a session file
|
||||||
|
# written before that fix can still hold one, and `trellis update`
|
||||||
|
# does not rewrite session files — so a poisoned pointer outlives the
|
||||||
|
# upgrade that closed the writer. This is the last hop before the
|
||||||
|
# task's prd.md/design.md reach the model prompt, so it checks again.
|
||||||
|
try:
|
||||||
|
root_real = os.path.realpath(repo_root)
|
||||||
|
# `.trellis` may itself be a symlink into a store outside the
|
||||||
|
# repo (#567); its real location is a second legitimate base.
|
||||||
|
workflow_real = os.path.realpath(os.path.join(repo_root, ".trellis"))
|
||||||
|
task_dir_full = os.path.realpath(os.path.join(repo_root, task_dir))
|
||||||
|
if not _real_path_contained(root_real, task_dir_full) and not (
|
||||||
|
_real_path_contained(workflow_real, task_dir_full)
|
||||||
|
):
|
||||||
|
sys.exit(0)
|
||||||
|
except OSError:
|
||||||
|
sys.exit(0)
|
||||||
if not os.path.exists(task_dir_full):
|
if not os.path.exists(task_dir_full):
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
|
|||||||
@@ -17,16 +17,22 @@ 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)
|
"Refer to workflow.md for current step." line so users see (and fix)
|
||||||
the broken state instead of the hook silently masking it.
|
the broken state instead of the hook silently masking it.
|
||||||
|
|
||||||
Shared across all hook-capable platforms (Claude, Cursor, Codex, Qoder,
|
Which platforms register this hook is decided by SHARED_HOOKS_BY_PLATFORM
|
||||||
CodeBuddy, Droid, Gemini, Copilot, Kiro). Kiro wires this via the CLI
|
in templates/shared-hooks/index.ts — currently Claude, Codex, Gemini,
|
||||||
|
Qoder, Copilot, CodeBuddy, Droid, Kiro, Trae and ZCode. That table is the
|
||||||
|
source of truth; each listed platform's collect<Platform>Templates() pulls
|
||||||
|
this file into its template map through collectSharedHooks(), and a single
|
||||||
|
writer puts that map on disk at init time. Kiro wires this via the CLI
|
||||||
custom agent's ``hooks.userPromptSubmit`` and the IDE ``.kiro.hook``
|
custom agent's ``hooks.userPromptSubmit`` and the IDE ``.kiro.hook``
|
||||||
``promptSubmit`` event; its output branch emits a plain-text breadcrumb
|
``promptSubmit`` event; its output branch emits a plain-text breadcrumb
|
||||||
(Kiro adds hook stdout directly to the conversation context). Written to
|
(Kiro adds hook stdout directly to the conversation context).
|
||||||
each platform's hooks directory via writeSharedHooks() at init time.
|
|
||||||
|
|
||||||
Silent exit 0 cases (no output):
|
Silent exit 0 case (no output):
|
||||||
- No .trellis/ directory found (not a Trellis project)
|
- No .trellis/ directory found (not a Trellis project)
|
||||||
- task.json malformed or missing status
|
|
||||||
|
When a session points at a task directory whose task.json is missing, malformed,
|
||||||
|
or missing a usable status, the hook emits a task_error breadcrumb instead of
|
||||||
|
misreporting the session as having no active task.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -95,11 +101,16 @@ def find_trellis_root(start: Path) -> Optional[Path]:
|
|||||||
def _detect_platform(input_data: dict) -> str | None:
|
def _detect_platform(input_data: dict) -> str | None:
|
||||||
if isinstance(input_data.get("cursor_version"), str):
|
if isinstance(input_data.get("cursor_version"), str):
|
||||||
return "cursor"
|
return "cursor"
|
||||||
|
# CLAUDE_PROJECT_DIR is a compatibility alias that several hosts set
|
||||||
|
# alongside their own variable — CodeBuddy, ZCode and Trae all do. It must
|
||||||
|
# therefore be checked LAST, or every one of them is detected as claude and
|
||||||
|
# the context key becomes `claude_<their-session-id>`. That key does not
|
||||||
|
# match the session file `task.py start` wrote under the host's real name,
|
||||||
|
# so every turn reports no_task while the pointer exists on disk.
|
||||||
|
# Observed on CodeBuddy IDE 4.10.4: session file `codebuddy_ae54840e….json`
|
||||||
|
# alongside marker `update-check-claude_ae54840e….marker`, same id.
|
||||||
env_map = {
|
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",
|
"ZCODE_PROJECT_DIR": "zcode",
|
||||||
"CLAUDE_PROJECT_DIR": "claude",
|
|
||||||
"CURSOR_PROJECT_DIR": "cursor",
|
"CURSOR_PROJECT_DIR": "cursor",
|
||||||
"CODEBUDDY_PROJECT_DIR": "codebuddy",
|
"CODEBUDDY_PROJECT_DIR": "codebuddy",
|
||||||
"FACTORY_PROJECT_DIR": "droid",
|
"FACTORY_PROJECT_DIR": "droid",
|
||||||
@@ -108,6 +119,8 @@ def _detect_platform(input_data: dict) -> str | None:
|
|||||||
"KIRO_PROJECT_DIR": "kiro",
|
"KIRO_PROJECT_DIR": "kiro",
|
||||||
"COPILOT_PROJECT_DIR": "copilot",
|
"COPILOT_PROJECT_DIR": "copilot",
|
||||||
"TRAE_PROJECT_DIR": "trae",
|
"TRAE_PROJECT_DIR": "trae",
|
||||||
|
# Last: the shared alias, only meaningful once no vendor key matched.
|
||||||
|
"CLAUDE_PROJECT_DIR": "claude",
|
||||||
}
|
}
|
||||||
for env_name, platform in env_map.items():
|
for env_name, platform in env_map.items():
|
||||||
if os.environ.get(env_name):
|
if os.environ.get(env_name):
|
||||||
@@ -145,8 +158,16 @@ def _resolve_active_task(root: Path, input_data: dict):
|
|||||||
return resolve_active_task(root, input_data, platform=_detect_platform(input_data))
|
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]]:
|
def get_active_task(
|
||||||
"""Return (task_id, status, source) from the current active task."""
|
root: Path, input_data: dict
|
||||||
|
) -> tuple[str, str, str] | None:
|
||||||
|
"""Return active task data, a task-record error, or no task pointer.
|
||||||
|
|
||||||
|
``(task_id, "task_error", source)`` is distinct from ``None``: a session
|
||||||
|
pointer can exist even when its task record is missing or unreadable, and
|
||||||
|
that state needs a diagnostic breadcrumb rather than the normal ``no_task``
|
||||||
|
prompt.
|
||||||
|
"""
|
||||||
active = _resolve_active_task(root, input_data)
|
active = _resolve_active_task(root, input_data)
|
||||||
if not active.task_path:
|
if not active.task_path:
|
||||||
return None
|
return None
|
||||||
@@ -159,16 +180,18 @@ def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, st
|
|||||||
|
|
||||||
task_json = task_dir / "task.json"
|
task_json = task_dir / "task.json"
|
||||||
if not task_json.is_file():
|
if not task_json.is_file():
|
||||||
return None
|
return task_dir.name, "task_error", active.source
|
||||||
try:
|
try:
|
||||||
data = json.loads(task_json.read_text(encoding="utf-8"))
|
data = json.loads(task_json.read_text(encoding="utf-8"))
|
||||||
except (json.JSONDecodeError, OSError):
|
except (json.JSONDecodeError, OSError):
|
||||||
return None
|
return task_dir.name, "task_error", active.source
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return task_dir.name, "task_error", active.source
|
||||||
|
|
||||||
task_id = data.get("id") or task_dir.name
|
task_id = data.get("id") or task_dir.name
|
||||||
status = data.get("status", "")
|
status = data.get("status", "")
|
||||||
if not isinstance(status, str) or not status:
|
if not isinstance(status, str) or not status:
|
||||||
return None
|
return task_dir.name, "task_error", active.source
|
||||||
return task_id, status, active.source
|
return task_id, status, active.source
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -128,9 +128,10 @@ def configure_project_encoding(project_dir: Path) -> None:
|
|||||||
def _has_curated_jsonl_entry(jsonl_path: Path) -> bool:
|
def _has_curated_jsonl_entry(jsonl_path: Path) -> bool:
|
||||||
"""Return True iff jsonl has at least one row with a ``file`` field.
|
"""Return True iff jsonl has at least one row with a ``file`` field.
|
||||||
|
|
||||||
A freshly seeded jsonl only contains a ``{"_example": ...}`` row (no
|
A newly created jsonl is empty, and older tasks may still carry a
|
||||||
``file`` key) — that is NOT "ready". Readiness requires at least one
|
``{"_example": ...}`` placeholder row (no ``file`` key) — neither is
|
||||||
curated entry. Matches the contract used by ``inject-subagent-context.py``.
|
"ready". Readiness requires at least one curated entry. Matches the
|
||||||
|
contract used by ``inject-subagent-context.py``.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
for line in jsonl_path.read_text(encoding="utf-8").splitlines():
|
for line in jsonl_path.read_text(encoding="utf-8").splitlines():
|
||||||
|
|||||||
+32
-31
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"__version": 2,
|
"__version": 2,
|
||||||
"hashes": {
|
"hashes": {
|
||||||
".agents/skills/trellis-continue/SKILL.md": "7723ccf49fbf19d8f086cacc7a080bd8be8db6fc70a32908b80f68efa318d7bf",
|
".agents/skills/trellis-continue/SKILL.md": "2b948434883636a1f1e2a4ad5b8a3d7dc2edc93ed05da6bca82882e8c024e22b",
|
||||||
".agents/skills/trellis-finish-work/SKILL.md": "161060fbcd44f787440d3a5c297a9f5223ea7774bb3021a50e376875a9ac5b2d",
|
".agents/skills/trellis-finish-work/SKILL.md": "161060fbcd44f787440d3a5c297a9f5223ea7774bb3021a50e376875a9ac5b2d",
|
||||||
".agents/skills/trellis-before-dev/SKILL.md": "859894f2e8258cbfa142d710363433761c54984c69f7ed7bd44512fb4eb165cd",
|
".agents/skills/trellis-before-dev/SKILL.md": "00c9d1c83bc318e91b27a019bc954c2957cfad907ff20d854b596f16cad2c0f3",
|
||||||
".agents/skills/trellis-brainstorm/SKILL.md": "a0f226ddcb8a3e846acd2a35d121996e9ca55165ce76202095d0b65e2b48a5e8",
|
".agents/skills/trellis-brainstorm/SKILL.md": "17d9bf209730c14f584eb97f38198d0683ad3f8da982d9a6e6d4e84c853f9a94",
|
||||||
".agents/skills/trellis-break-loop/SKILL.md": "f5a93699832f29dee443b53c135a7459519b371f689af191d15c29e8ee5c7bde",
|
".agents/skills/trellis-break-loop/SKILL.md": "f5a93699832f29dee443b53c135a7459519b371f689af191d15c29e8ee5c7bde",
|
||||||
".agents/skills/trellis-check/SKILL.md": "b21ff04b7680ebacb8c5ecbc48a22d627eb13e2b47fceb78c8ced0b43b60b282",
|
".agents/skills/trellis-check/SKILL.md": "dfb0600e95c19c7a83200465b6a92a5514ec9778ba4083efa881bed541cc74a8",
|
||||||
".agents/skills/trellis-update-spec/SKILL.md": "003ce08a3404aeb50998029392c4d4e57b626edf526d3ebd585032bb92dcbb96",
|
".agents/skills/trellis-update-spec/SKILL.md": "003ce08a3404aeb50998029392c4d4e57b626edf526d3ebd585032bb92dcbb96",
|
||||||
".agents/skills/trellis-meta/references/customize-local/add-project-local-conventions.md": "86009ccb5d0373f399582da0bc570c4e5c6053c3c764857424ff93384f0e04e5",
|
".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-agents.md": "4216ac3cc570038fd8ce3319a85932bb537fea9e0a7f4d11fa315cfa645c9c85",
|
||||||
@@ -17,56 +17,56 @@
|
|||||||
".agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "60ff9efb93604b87a461a4af30322d76750402a51e40f31531a7ff88d309996d",
|
".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/change-workflow.md": "43fa780a2ca580de121b10893d49b99f978873deebbf45008c466e5ac6651519",
|
||||||
".agents/skills/trellis-meta/references/customize-local/overview.md": "ce8f09e9f93ce9a48500763fb3a4db2b3908a5fbf4f985ab71dacebb404cf8f4",
|
".agents/skills/trellis-meta/references/customize-local/overview.md": "ce8f09e9f93ce9a48500763fb3a4db2b3908a5fbf4f985ab71dacebb404cf8f4",
|
||||||
".agents/skills/trellis-meta/references/local-architecture/context-injection.md": "8497289bf333b3aa456f317039d1239b7ece79254aa0eb62cfc647714c866084",
|
".agents/skills/trellis-meta/references/local-architecture/context-injection.md": "b1ebaac467bf0195e2a21f2f8492a8b7c91ff3e0f9312f853468cda0255df1c7",
|
||||||
".agents/skills/trellis-meta/references/local-architecture/generated-files.md": "7eb2d452eddb4f4226f7578c2ec6d5ee0434ed172ba4c36107cc8bdff7554dc6",
|
".agents/skills/trellis-meta/references/local-architecture/generated-files.md": "7eb2d452eddb4f4226f7578c2ec6d5ee0434ed172ba4c36107cc8bdff7554dc6",
|
||||||
".agents/skills/trellis-meta/references/local-architecture/overview.md": "50638fd9eaaba2e0edf2f2a84d920578d5b2fb7934031b174e417d3dc510b2a6",
|
".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/spec-system.md": "b8d8a6a0888b44a232c8f50161b9e20e903cf621ad7be4021715ab6fab226f47",
|
||||||
".agents/skills/trellis-meta/references/local-architecture/task-system.md": "2b561d49c390f7d0db5391912946133be4bf73189231e2b8cc9afa1c5ac6165a",
|
".agents/skills/trellis-meta/references/local-architecture/task-system.md": "b154568e74f1c738a51633df9f2503f416b3afa5f309325224422162e0b707e4",
|
||||||
".agents/skills/trellis-meta/references/local-architecture/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6",
|
".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/local-architecture/workspace-memory.md": "e6427b46aba744563c2444b30df4043cd856561b7709ec2dece26095416421fd",
|
||||||
".agents/skills/trellis-meta/references/platform-files/agents.md": "9f41349b78f7ae64698a38490a317882561f3805b26a79bda587a13d03fda245",
|
".agents/skills/trellis-meta/references/platform-files/agents.md": "b806b1f0de6dfc74720014aee15ede767a58925b2b3b964f2e557ed39a65bbc0",
|
||||||
".agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "154af08f7ee8afe7a704968ec0b9fc21e905b7cfb03871c9dbafcdd6cc654319",
|
".agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "91e9855e418673bcd7770411e24e28390738b123532eef000beba0a29cf154e1",
|
||||||
".agents/skills/trellis-meta/references/platform-files/overview.md": "1aec9087ccedd56a213af877b5db474131aa4b4789b6ac0e8da73b058aa43bd4",
|
".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/platform-map.md": "3c0d4546461c06d1aaf006276e34e250e0ca7bbb5b853fb45c58114cebca4042",
|
||||||
".agents/skills/trellis-meta/references/platform-files/skills-and-commands.md": "e39831d860bd27a04e7757f7bd941ab83b3c5b11ad4460cec03975b655f26cc3",
|
".agents/skills/trellis-meta/references/platform-files/skills-and-commands.md": "e39831d860bd27a04e7757f7bd941ab83b3c5b11ad4460cec03975b655f26cc3",
|
||||||
".agents/skills/trellis-meta/SKILL.md": "208e92ace3b8d979d8158b3bd0169185f508f2e1424fcd78fcd03cbcc39516a1",
|
".agents/skills/trellis-meta/SKILL.md": "ee7bcfd0023def62688339c4b63c50f2fbeea12a34eb89e8bb63a992b58cb168",
|
||||||
".agents/skills/trellis-start/SKILL.md": "79a5ba7a2aff3c72e06d7f4cd6942dc4f4f4092dd40f9c8e94f1838024a81e4d",
|
".agents/skills/trellis-start/SKILL.md": "79a5ba7a2aff3c72e06d7f4cd6942dc4f4f4092dd40f9c8e94f1838024a81e4d",
|
||||||
".codex/agents/trellis-check.toml": "79070c63fa404fc53061cca5194bf66db839132b67a57b9c8d6a295037ba7308",
|
".codex/agents/trellis-check.toml": "206fd96a8aa17e95ed344cc435661d597093ece571671f298ca64490422fffe0",
|
||||||
".codex/agents/trellis-implement.toml": "388fb8f39797e0ee6cf4db447c859c79b4ac15f531f7e1e3c68c1d1d71a1c188",
|
".codex/agents/trellis-implement.toml": "388fb8f39797e0ee6cf4db447c859c79b4ac15f531f7e1e3c68c1d1d71a1c188",
|
||||||
".codex/agents/trellis-research.toml": "4435ce73197ba1d29d40359a3279b6423f7e4f559a449f934c016808090066c4",
|
".codex/agents/trellis-research.toml": "4435ce73197ba1d29d40359a3279b6423f7e4f559a449f934c016808090066c4",
|
||||||
".codex/hooks/session-start.py": "14de3be1cf6eb9c9feba348d8998b407f3837d6c0756b74210c9200543440677",
|
".codex/hooks/session-start.py": "efdd015c7c4687227ce34a84ac6249d5554c704f737e00d4844c9284ddcf696f",
|
||||||
".codex/hooks/inject-workflow-state.py": "9ce43910ac39cbb0e4d1783fbde931761eb04536c7f82661a96d95ea72c14bde",
|
".codex/hooks/inject-workflow-state.py": "71ef1aa1ed1051a697cae3c9ebffa518958a3bbd4d147434f382348829915f8c",
|
||||||
".codex/hooks.json": "85a58ba7cdf1e19e7f75ddcc64e5680180c487ca266a74bd5005f31abeee2e02",
|
".codex/hooks.json": "85a58ba7cdf1e19e7f75ddcc64e5680180c487ca266a74bd5005f31abeee2e02",
|
||||||
".codex/config.toml": "9f2d20e28f0bc9c886312eca3ad3bba41533ef4615aaaafe25e98152302267bb",
|
".codex/config.toml": "9f2d20e28f0bc9c886312eca3ad3bba41533ef4615aaaafe25e98152302267bb",
|
||||||
".trellis/config.yaml": "5c9207418cecc390e9d86d589b4183b831f76697d92fb42fefd5221cd8772e51",
|
".trellis/config.yaml": "5c9207418cecc390e9d86d589b4183b831f76697d92fb42fefd5221cd8772e51",
|
||||||
".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c",
|
".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c",
|
||||||
".trellis/scripts/add_session.py": "876dad478edf70db59acccaae9cb4db646a155681f730bd99af48de72ddc9881",
|
".trellis/scripts/add_session.py": "f61a452ec5f7eece8d7e0f0dd56ce5851166cbc83ca4822e30acbb36eeb5896f",
|
||||||
".trellis/scripts/common/__init__.py": "3d5e9347141f0296319a5beb29d69ae714c5a474b9078caeb3edd7c5f6562e22",
|
".trellis/scripts/common/__init__.py": "f258cd873681dc5a4d71db0dd71d10fd78656de55bec96418d6702503c16e87d",
|
||||||
".trellis/scripts/common/active_task.py": "31271e3b69b5a5eca958d8ce25f61fe852eb6e48d32c150471da8e7272fd6119",
|
".trellis/scripts/common/active_task.py": "f52715bbcc0677607055903e799674d7d50d6fcbab413a8f453c4e4efc954726",
|
||||||
".trellis/scripts/common/cli_adapter.py": "5d6bd9d6f5c631e7e792db7dd343351317f9643bc87b73a9a98abd51cefb4307",
|
".trellis/scripts/common/cli_adapter.py": "5d6bd9d6f5c631e7e792db7dd343351317f9643bc87b73a9a98abd51cefb4307",
|
||||||
".trellis/scripts/common/config.py": "8d2e5f8ccfcd5f622cd2af002aa761f3d3ffcc653182fefb2268afd102e77bca",
|
".trellis/scripts/common/config.py": "9a042d6f0e25a33a567510760e87c1e389af14a41faf1daeb1190a00f3df7f5e",
|
||||||
".trellis/scripts/common/developer.py": "f5f833123abe68890171b4da825a324216d24913f6b5ad9245afc556424ffd7b",
|
".trellis/scripts/common/developer.py": "a0f61bb58063e2f4cb357bcb2c22fd5a6f1241286398beaa7130b2a8994a4afa",
|
||||||
".trellis/scripts/common/git.py": "6fc5845d0104dd506ebd8b366a24cb4b1e3d8777e4e6acc12ea15c9d8e2662f2",
|
".trellis/scripts/common/git.py": "5605b71662e524b47632030d9db9245121a5cba85b5521eeb524ed464d840ff5",
|
||||||
".trellis/scripts/common/git_context.py": "fa30ced454f1a91ffc9f8b2abeb32225e3447cbdc90bad783797374eba07265d",
|
".trellis/scripts/common/git_context.py": "fa30ced454f1a91ffc9f8b2abeb32225e3447cbdc90bad783797374eba07265d",
|
||||||
".trellis/scripts/common/io.py": "75648caae03d5b1107d7aeccaa785d133b25762266e54a520d90ca8c76b43bdb",
|
".trellis/scripts/common/io.py": "5dd8b9e297125ff58543a9bd4790c03ae1aca1d37b9e2ca60c4ffb47601c1cbb",
|
||||||
".trellis/scripts/common/log.py": "471df6895cfac80f995edebbf9974f6b7440634b7a688f28b8331c868bc0f3cf",
|
".trellis/scripts/common/log.py": "471df6895cfac80f995edebbf9974f6b7440634b7a688f28b8331c868bc0f3cf",
|
||||||
".trellis/scripts/common/packages_context.py": "efe158d7c99c2268851d0216fbb08de22836e418a8dbeb73575b8cc249eed7b7",
|
".trellis/scripts/common/packages_context.py": "efe158d7c99c2268851d0216fbb08de22836e418a8dbeb73575b8cc249eed7b7",
|
||||||
".trellis/scripts/common/paths.py": "05898ef136cc7c4d861b05fbf2b16d53ddd3e6f311a231d4fcfcb81bde7c45ee",
|
".trellis/scripts/common/paths.py": "d8586d4d3f9bb2dd46b7f11b0f038b5340d283f41b6fa2c76bb8235c13bbb0b7",
|
||||||
".trellis/scripts/common/safe_commit.py": "baa5c82324eb62154374ec63394ecdc8609bb37d93892e3bcb88f452bb7d6446",
|
".trellis/scripts/common/safe_commit.py": "262ad740552279fe6ec6a2a9865b2965ce2448577cf973a4d702dfe2f5f91f04",
|
||||||
".trellis/scripts/common/session_context.py": "4ed3e13b2878ba367e9f2e2cd709b396f806152902df2cd1cd1478317d069017",
|
".trellis/scripts/common/session_context.py": "3379ef1766e4e5ca77cbb7c040dbba3883fcca2548580299e3b38dbf22f4f7d5",
|
||||||
".trellis/scripts/common/task_context.py": "51301e180ce702c6a5b1a2dd1b155a6eed1bf2b05cb2ac1e51081212628b11d4",
|
".trellis/scripts/common/task_context.py": "51301e180ce702c6a5b1a2dd1b155a6eed1bf2b05cb2ac1e51081212628b11d4",
|
||||||
".trellis/scripts/common/task_queue.py": "0be61f713462b1fe4574927c82fc4704e678afe72dcb9813543aedf2f9e9e0c5",
|
".trellis/scripts/common/task_queue.py": "0be61f713462b1fe4574927c82fc4704e678afe72dcb9813543aedf2f9e9e0c5",
|
||||||
".trellis/scripts/common/task_store.py": "e3c2fbf8b79b591e39fc3c9f4e2f3ee0c840c8201c94a16709ec743fa45037f6",
|
".trellis/scripts/common/task_store.py": "5712b0405e1fd0c2b7be5e5fbd420fa6eec2c49c8e4cf2b8fc69b7d2e4aeb016",
|
||||||
".trellis/scripts/common/task_utils.py": "90c0a6d50bad502c3f01cb24c1ccfeb0eece5e2c69efaff8d65eb827fba43871",
|
".trellis/scripts/common/task_utils.py": "35f544317a95e98d05fe089794071482175739e8c1f3b3b0228dae48602ffe90",
|
||||||
".trellis/scripts/common/tasks.py": "4436a8b0b53c270a35989e26d9dbd92669408c6562d88c02083a404562da85fe",
|
".trellis/scripts/common/tasks.py": "87aca6b73ec4524450cfff8e2ae561b1f2e53bd62a185f036a7a2d484d90c8a2",
|
||||||
".trellis/scripts/common/trellis_config.py": "e282e897183e3ec2f4e6e56349431946e5f98c1c31d3eca4de7fc44e1383a7bf",
|
".trellis/scripts/common/trellis_config.py": "f1fc705ec463b0b9c163672dc9e50f10d29da967775a1900429205f7c4fcb51c",
|
||||||
".trellis/scripts/common/types.py": "9962081cc2608fb9d1deb32c6880e336f62cdca6b338e7ae813304701e155ee9",
|
".trellis/scripts/common/types.py": "9962081cc2608fb9d1deb32c6880e336f62cdca6b338e7ae813304701e155ee9",
|
||||||
".trellis/scripts/common/workflow_phase.py": "79ee522de20246acf1e2c222e8ad180ad25aaec7fec98214a93d9e81b350d9a8",
|
".trellis/scripts/common/workflow_phase.py": "146b69c1ec0e628aeb720d3e34d8333f16050225037fe4233da9bbc4d4ac99c1",
|
||||||
".trellis/scripts/get_context.py": "ca5bf9e90bdb1d75d3de182b95f820f9d108ab28793d29097b24fd71315adcf5",
|
".trellis/scripts/get_context.py": "ca5bf9e90bdb1d75d3de182b95f820f9d108ab28793d29097b24fd71315adcf5",
|
||||||
".trellis/scripts/get_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f",
|
".trellis/scripts/get_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f",
|
||||||
".trellis/scripts/hooks/linear_sync.py": "e09cc4ce4699aada908808718698f33f705a3edf55c4dcf8f777ad892f80ca79",
|
".trellis/scripts/hooks/linear_sync.py": "e09cc4ce4699aada908808718698f33f705a3edf55c4dcf8f777ad892f80ca79",
|
||||||
".trellis/scripts/init_developer.py": "f9e6c0d882406e81c8cd6b1c5abb204b0befc0069ff89cf650cd536a80f8c60e",
|
".trellis/scripts/init_developer.py": "f9e6c0d882406e81c8cd6b1c5abb204b0befc0069ff89cf650cd536a80f8c60e",
|
||||||
".trellis/scripts/task.py": "e0ffed9f14994069f0c992141e3ec168524be5af32e3681e6ea30ba0a5da4bc4",
|
".trellis/scripts/task.py": "b415e0a2dbc9e8cba1ada0c7997999aaaaea0eec96d3419db640830e88e84cfe",
|
||||||
".trellis/workflow.md": "f4b8a6f89017f62071986d6d36cc32c4c7f01fe6f023f0fd9311eddc57d8c94f",
|
".trellis/workflow.md": "f4b8a6f89017f62071986d6d36cc32c4c7f01fe6f023f0fd9311eddc57d8c94f",
|
||||||
".trellis/agents/implement.md": "66e25ad046c94869442834bc3cdfbd5a9a7412d3ff54561d64d2886552c27e87",
|
".trellis/agents/implement.md": "66e25ad046c94869442834bc3cdfbd5a9a7412d3ff54561d64d2886552c27e87",
|
||||||
".trellis/agents/check.md": "edb4f57361407249a53bf5998ebf91c40d2b969e826a2c5e1b4e813a08bcb175",
|
".trellis/agents/check.md": "edb4f57361407249a53bf5998ebf91c40d2b969e826a2c5e1b4e813a08bcb175",
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
".agents/skills/trellis-channel/references/workers.md": "d4481b2858bca82050cb8d131916da8a2ccc3857f199cdb9ced8bb41d592e2a7",
|
".agents/skills/trellis-channel/references/workers.md": "d4481b2858bca82050cb8d131916da8a2ccc3857f199cdb9ced8bb41d592e2a7",
|
||||||
".agents/skills/trellis-channel/references/workflows.md": "0782d602efde6e94a7d7d7950fd605eedc9a1f5a523908284e76b628fd618fd4",
|
".agents/skills/trellis-channel/references/workflows.md": "0782d602efde6e94a7d7d7950fd605eedc9a1f5a523908284e76b628fd618fd4",
|
||||||
".agents/skills/trellis-channel/SKILL.md": "ff84801c511a736da018b4b1149348ebc9a16b7fc18d905ec10cfa7acbf269a3",
|
".agents/skills/trellis-channel/SKILL.md": "ff84801c511a736da018b4b1149348ebc9a16b7fc18d905ec10cfa7acbf269a3",
|
||||||
".agents/skills/trellis-meta/references/local-architecture/bundled-skills.md": "7a8d1a5dcc8d1140c4c6bd19d02949364cdd01801bd0825a975a308cf85b8f37",
|
".agents/skills/trellis-meta/references/local-architecture/bundled-skills.md": "aa6a0bf83060205ee4ea621c467fb900a7db06b4476a4ad472cc4e248c887389",
|
||||||
".agents/skills/trellis-meta/references/local-architecture/multi-agent-channel.md": "56e5070474aeca872e2d70c46feea5aaafecd3d3ec052c3f7b1877358dca62e9",
|
".agents/skills/trellis-meta/references/local-architecture/multi-agent-channel.md": "56e5070474aeca872e2d70c46feea5aaafecd3d3ec052c3f7b1877358dca62e9",
|
||||||
".agents/skills/trellis-session-insight/references/cli-quick-reference.md": "c520353fe3fc00b9702ed4f780647c8fbd6d342e66527a03647f533a9fe09779",
|
".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/references/triggering-patterns.md": "121ecd23be83d1567e8ce15c366a81073d7a2b1d3ad616fce235c07ca1f1cc20",
|
||||||
@@ -86,6 +86,7 @@
|
|||||||
".agents/skills/trellis-spec-bootstrap/references/spec-task-planning.md": "ef493d028c3b0807a8a534bb71fb92a68129f273db763ad27ceb464a522e799d",
|
".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/references/spec-writing.md": "e9800fe9ed4a4cd87062ea1829cf2caa8d170ec15e141678a6a30e74c497f47d",
|
||||||
".agents/skills/trellis-spec-bootstrap/SKILL.md": "97bfa68c06cebb558eb4464bc1b81f7d2d56040d75baa8de1ee5ad90cca0196a",
|
".agents/skills/trellis-spec-bootstrap/SKILL.md": "97bfa68c06cebb558eb4464bc1b81f7d2d56040d75baa8de1ee5ad90cca0196a",
|
||||||
".codex/hooks/inject-subagent-context.py": "abffa237eb53f87ae6ffa434063b46b03d58a36a84cdb8fe88bfc5f243aab609"
|
".codex/hooks/inject-subagent-context.py": "9b68be8d08f85e824e39d901a29f14d38346dbeeda15880e4cba03076f2a8caa",
|
||||||
|
"AGENTS.md": "064873e2aba815f74e973de77269e3b2ae072b69d1486af54c355e66e10251c6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.6.12
|
0.6.16
|
||||||
+50
-36
@@ -58,34 +58,23 @@ session_auto_commit: false
|
|||||||
# Trellis auto-detects workspaces during `trellis init`, but you can also
|
# Trellis auto-detects workspaces during `trellis init`, but you can also
|
||||||
# configure them manually here.
|
# configure them manually here.
|
||||||
#
|
#
|
||||||
# hyb-oa uses Trellis single-repo mode in this first migration. Domain specs
|
|
||||||
# live as layers under .trellis/spec/ and point to server/, client/, and docs/.
|
|
||||||
# packages:
|
# packages:
|
||||||
# backend:
|
|
||||||
# path: server
|
|
||||||
# frontend:
|
# frontend:
|
||||||
# path: client
|
# path: packages/frontend
|
||||||
|
# backend:
|
||||||
|
# path: packages/backend
|
||||||
# docs:
|
# docs:
|
||||||
# path: 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 used when --package is not specified.
|
||||||
# default_package: backend
|
# default_package: frontend
|
||||||
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
# Codex (dispatch behavior)
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
# Codex-only knob; other platforms ignore it. Trellis 0.6.12 supports native
|
|
||||||
# sub-agent context injection and recovery of truncated hook output, but hyb-oa
|
|
||||||
# deliberately keeps the main session in charge unless a task explicitly
|
|
||||||
# approves sub-agent work. "sub-agent" remains a backwards-compatible alias
|
|
||||||
# for "auto"; invalid explicit values safely use inline mode.
|
|
||||||
#
|
|
||||||
# In "auto" mode, sub-agents inherit the main session model unless their
|
|
||||||
# generated `.codex/agents/trellis-*.toml` file pins a model. There is no
|
|
||||||
# config.yaml model override.
|
|
||||||
#
|
|
||||||
codex:
|
|
||||||
dispatch_mode: inline # keep Codex main-session edits until a task explicitly asks for sub-agents
|
|
||||||
|
|
||||||
#-------------------------------------------------------------------------------
|
#-------------------------------------------------------------------------------
|
||||||
# Channel worker OOM guard
|
# Channel worker OOM guard
|
||||||
@@ -118,27 +107,52 @@ channel:
|
|||||||
# - /work/user/trellis_workspace
|
# - /work/user/trellis_workspace
|
||||||
# auto_trust_trellis_symlinks: false
|
# 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: inline
|
||||||
|
|
||||||
#-------------------------------------------------------------------------------
|
#-------------------------------------------------------------------------------
|
||||||
# Sub-agent context injection limits
|
# Sub-agent context injection limits
|
||||||
#-------------------------------------------------------------------------------
|
#-------------------------------------------------------------------------------
|
||||||
# Trellis 0.6.12 caps injected task artifacts and referenced context. Oversized
|
# Caps how much task context (implement.jsonl / check.jsonl referenced files,
|
||||||
# files are truncated with notice; total-budget overflow degrades remaining
|
# plus prd.md / design.md / implement.md) gets inlined into a sub-agent's
|
||||||
# files to path/reason/size index lines. Defaults are sufficient for hyb-oa;
|
# first prompt. Oversized files are truncated with a notice; once the total
|
||||||
# keep them documented but implicit until a real task proves they need tuning.
|
# payload cap is reached, remaining files degrade to index lines (path +
|
||||||
# `0` disables the corresponding byte limit.
|
# reason + size) instead of being inlined.
|
||||||
|
#
|
||||||
|
# All values are byte counts. `0` disables the corresponding limit.
|
||||||
#
|
#
|
||||||
# context_injection:
|
# context_injection:
|
||||||
# max_file_bytes: 32768
|
# max_file_bytes: 32768 # per implement.jsonl / check.jsonl referenced file
|
||||||
# max_artifact_bytes: 65536
|
# max_artifact_bytes: 65536 # per task artifact (prd.md / design.md / implement.md)
|
||||||
# max_total_bytes: 131072
|
# max_total_bytes: 131072 # whole injected payload; overflow degrades to index lines
|
||||||
|
|
||||||
#-------------------------------------------------------------------------------
|
#-------------------------------------------------------------------------------
|
||||||
# Per-turn prompt injection
|
# Per-turn prompt injection
|
||||||
#-------------------------------------------------------------------------------
|
#-------------------------------------------------------------------------------
|
||||||
# Optional one-turn escape hatch for the per-turn `<workflow-state>` breadcrumb.
|
# Escape hatch for the per-turn <workflow-state> breadcrumb. When a user
|
||||||
# It uses a case-insensitive standalone-word match and does not affect session
|
# prompt contains the skip keyword as a standalone word (case-insensitive,
|
||||||
# start or sub-agent context injection. The project keeps it disabled so task
|
# word-boundary match — "no-trellisfoo" does NOT count), the breadcrumb is
|
||||||
# state remains deterministic.
|
# skipped for that turn only. Does not affect SessionStart or sub-agent
|
||||||
|
# context injection.
|
||||||
#
|
#
|
||||||
# prompt_injection:
|
# prompt_injection:
|
||||||
# skip_keyword: "no-trellis"
|
# skip_keyword: "no-trellis" # "" disables the escape hatch entirely
|
||||||
|
|||||||
+909
-45
文件差异内容过多而无法显示
加载差异
@@ -66,6 +66,8 @@ from .paths import (
|
|||||||
FILE_CURRENT_TASK,
|
FILE_CURRENT_TASK,
|
||||||
FILE_TASK_JSON,
|
FILE_TASK_JSON,
|
||||||
FILE_JOURNAL_PREFIX,
|
FILE_JOURNAL_PREFIX,
|
||||||
|
ENV_DEVELOPER,
|
||||||
|
DEVELOPER_HINT,
|
||||||
get_repo_root,
|
get_repo_root,
|
||||||
get_developer,
|
get_developer,
|
||||||
check_developer,
|
check_developer,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ session key there is no active task.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
@@ -19,12 +18,21 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from .io import read_json as _io_read_json, write_json as _io_write_json
|
||||||
|
|
||||||
DIR_WORKFLOW = ".trellis"
|
DIR_WORKFLOW = ".trellis"
|
||||||
DIR_TASKS = "tasks"
|
DIR_TASKS = "tasks"
|
||||||
DIR_RUNTIME = ".runtime"
|
DIR_RUNTIME = ".runtime"
|
||||||
DIR_SESSIONS = "sessions"
|
DIR_SESSIONS = "sessions"
|
||||||
DIR_CURSOR_SHELL = "cursor-shell"
|
DIR_SHELL_TICKETS = "shell-tickets"
|
||||||
CURSOR_SHELL_TICKET_TTL_SECONDS = 30
|
# Pre-0.6.13 name, when the bridge was Cursor-only. Still read so a session that
|
||||||
|
# was mid-command across an upgrade does not silently degrade; never written.
|
||||||
|
# Tickets are 30-second ephemera, so the old directory ages out by itself —
|
||||||
|
# there is nothing to migrate, only a glob on a directory that is normally
|
||||||
|
# absent. The alternative (ignore it) would land its one lost command on the
|
||||||
|
# platform that works today.
|
||||||
|
DIR_LEGACY_CURSOR_SHELL_TICKETS = "cursor-shell"
|
||||||
|
SHELL_TICKET_TTL_SECONDS = 30
|
||||||
TASK_SESSION_COMMANDS = {"start", "current", "finish"}
|
TASK_SESSION_COMMANDS = {"start", "current", "finish"}
|
||||||
|
|
||||||
_SESSION_KEYS = ("session_id", "sessionId", "sessionID")
|
_SESSION_KEYS = ("session_id", "sessionId", "sessionID")
|
||||||
@@ -48,37 +56,87 @@ _KNOWN_PLATFORMS = {
|
|||||||
"kimi",
|
"kimi",
|
||||||
"zcode",
|
"zcode",
|
||||||
"snow",
|
"snow",
|
||||||
|
"dsh",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Every name below records how it was checked. Do NOT add a name by analogy
|
||||||
|
# with a neighbour: a 2026-08-05 audit of all 21 platforms found 12 of the 21
|
||||||
|
# declared names had never existed anywhere — they were pattern-guessed from a
|
||||||
|
# `<PLATFORM>_SESSION_ID` shape no vendor agreed to, and the uniformity was the
|
||||||
|
# only "evidence" behind them. A platform with no verified name belongs in no
|
||||||
|
# table; it resolves through TRELLIS_CONTEXT_ID or its hook/plugin bridge.
|
||||||
_ENV_SESSION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
_ENV_SESSION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||||
("claude", ("CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID")),
|
# REAL (reported 2026-08-13 against DSH 0.1.0-rc.6 by @SajoLuo, from a live
|
||||||
("codex", ("CODEX_SESSION_ID", "CODEX_THREAD_ID")),
|
# run: DSH exports DSH_SESSION_ID plus DSH_SHELL=1 into its managed shell).
|
||||||
("cursor", ("CURSOR_SESSION_ID",)),
|
# MUST STAY FIRST. A DSH session can inherit an outer host's identity — a
|
||||||
("opencode", ("OPENCODE_SESSION_ID", "OPENCODE_SESSIONID", "OPENCODE_RUN_ID")),
|
# DSH launched from Codex still carries CODEX_THREAD_ID — and the untargeted
|
||||||
|
# lookup below walks this table in order, so any earlier entry would claim
|
||||||
|
# the session and write a foreign `codex_<thread>` pointer for DSH work.
|
||||||
|
# DSH_SESSION_ID is the only name here no other vendor sets, so first place
|
||||||
|
# is safe: it cannot mis-claim a non-DSH session.
|
||||||
|
("dsh", ("DSH_SESSION_ID",)),
|
||||||
|
# REAL, undocumented (verified 2026-08-05 in a live Claude Code 2.1.221 bash
|
||||||
|
# child; absent from code.claude.com/docs/en/env-vars). CLAUDE_SESSION_ID
|
||||||
|
# was removed here — verified absent from that same live environment.
|
||||||
|
("claude", ("CLAUDE_CODE_SESSION_ID",)),
|
||||||
|
# REAL, undocumented (verified 2026-08-05: injected by codex-cli 0.146.0
|
||||||
|
# into shell children, absent from the parent env; openai/codex#19937).
|
||||||
|
# CODEX_SESSION_ID was removed — absent from a live `codex exec` env.
|
||||||
|
("codex", ("CODEX_THREAD_ID",)),
|
||||||
|
# REAL but HOOK-SCOPE ONLY (verified 2026-08-05): set by Gemini's
|
||||||
|
# hookRunner.ts. Its shell tool builds the child env in
|
||||||
|
# shellExecutionService.ts and adds only GEMINI_CLI/TERM/PAGER/GIT_PAGER, so
|
||||||
|
# this never reaches a bash child — it resolves only inside a hook process.
|
||||||
("gemini", ("GEMINI_SESSION_ID",)),
|
("gemini", ("GEMINI_SESSION_ID",)),
|
||||||
("droid", ("FACTORY_SESSION_ID", "DROID_SESSION_ID")),
|
# REAL but HOOK-SCOPE ONLY (verified 2026-08-05): docs.qoder.com/zh/
|
||||||
|
# extensions/hooks documents it as injected during hook execution by the
|
||||||
|
# Qoder *IDE plugin*. Absent from the Qoder CLI hook docs and from Lingma.
|
||||||
("qoder", ("QODER_SESSION_ID",)),
|
("qoder", ("QODER_SESSION_ID",)),
|
||||||
("codebuddy", ("CODEBUDDY_SESSION_ID",)),
|
# UNVERIFIED (2026-08-05): absent from kiro.dev/docs/hooks/, but Dynatrace
|
||||||
|
# dtctl, oh-my-agent and gastown all key agent detection on it and one notes
|
||||||
|
# it is "set in both interactive and --no-interactive". Kept because that is
|
||||||
|
# absence of evidence, not evidence of absence. To settle: run
|
||||||
|
# `env | grep KIRO` from a Kiro shell-tool call on a machine with Kiro.
|
||||||
("kiro", ("KIRO_SESSION_ID",)),
|
("kiro", ("KIRO_SESSION_ID",)),
|
||||||
|
# UNVERIFIED (2026-08-05): absent from docs.github.com/en/copilot/reference/
|
||||||
|
# hooks-reference and from the CLI programmatic reference. To settle: run
|
||||||
|
# `copilot help environment` (the authoritative list per those docs) — not
|
||||||
|
# runnable here, the CLI is not installed and copilot-cli ships no source.
|
||||||
("copilot", ("COPILOT_SESSION_ID", "COPILOT_SESSIONID")),
|
("copilot", ("COPILOT_SESSION_ID", "COPILOT_SESSIONID")),
|
||||||
("pi", ("PI_SESSION_ID", "PI_SESSIONID")),
|
# REASONED, UNVERIFIED (2026-08-05): ZCode is closed-source and not
|
||||||
("trae", ("TRAE_SESSION_ID",)),
|
# installable here. It mirrors Claude's naming elsewhere (CLAUDE_PLUGIN_ROOT
|
||||||
# ZCode reuses CLAUDE_SESSION_ID (it does not document a ZCODE_SESSION_ID).
|
# / CLAUDE_PLUGIN_DATA compat aliases are in its docs), and the previously
|
||||||
# Platform-scoped lookup (_iter_env_keys filters by platform name), so this
|
# declared CLAUDE_SESSION_ID does not exist on Claude Code either — so the
|
||||||
# only fires when the resolver already detected "zcode" — no collision with
|
# name ZCode would actually reuse is CLAUDE_CODE_SESSION_ID. Try that first,
|
||||||
|
# keep the historical name as a fallback: if neither exists nothing changes.
|
||||||
|
# Platform-scoped lookup (_iter_env_keys filters by platform name), so the
|
||||||
|
# entry only fires once the resolver detected "zcode" — no collision with
|
||||||
# the claude entry above.
|
# the claude entry above.
|
||||||
("zcode", ("CLAUDE_SESSION_ID",)),
|
("zcode", ("CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID")),
|
||||||
# Snow CLI exports SNOW_SESSION_ID into hook/terminal/sub-agent children.
|
# REAL by vendor design (verified 2026-08-05): Snow's sessionIdentityEnv.ts
|
||||||
# TRELLIS_CONTEXT_ID remains the preferred override when present.
|
# exports SNOW_SESSION_ID into hook/terminal/sub-agent children and names
|
||||||
|
# Trellis in its source header. TRELLIS_CONTEXT_ID stays the preferred
|
||||||
|
# override — Snow sets that too.
|
||||||
("snow", ("SNOW_SESSION_ID",)),
|
("snow", ("SNOW_SESSION_ID",)),
|
||||||
)
|
)
|
||||||
_ENV_CONVERSATION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
_ENV_CONVERSATION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||||
|
# REAL in cursor-agent (CLI), undocumented (verified 2026-08-05: the value
|
||||||
|
# matches ~/.cursor/chats/<ws>/<id>). The Cursor *IDE* is unverified — a
|
||||||
|
# 2026-05 forum request for it drew no staff reply. The invented
|
||||||
|
# CURSOR_SESSION_ID was removed from the session table: empty in a live
|
||||||
|
# cursor-agent shell. Cursor's other path is the shell ticket below
|
||||||
|
# (_lookup_shell_ticket_context_key), which is not Cursor-specific.
|
||||||
("cursor", ("CURSOR_CONVERSATION_ID", "CURSOR_CONVERSATIONID")),
|
("cursor", ("CURSOR_CONVERSATION_ID", "CURSOR_CONVERSATIONID")),
|
||||||
)
|
)
|
||||||
_ENV_TRANSCRIPT_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
_ENV_TRANSCRIPT_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||||
("claude", ("CLAUDE_TRANSCRIPT_PATH",)),
|
# REAL but HOOK-SCOPE ONLY (verified 2026-08-05): documented for Cursor hook
|
||||||
("codex", ("CODEX_TRANSCRIPT_PATH",)),
|
# scripts; empty in the agent's own shell env.
|
||||||
("cursor", ("CURSOR_TRANSCRIPT_PATH",)),
|
("cursor", ("CURSOR_TRANSCRIPT_PATH",)),
|
||||||
|
# UNVERIFIED — never researched. The 2026-08-05 audit covered the session
|
||||||
|
# table only, so do not infer these are real *or* fake from that work
|
||||||
|
# (CLAUDE_/CODEX_TRANSCRIPT_PATH were removed because those two *were*
|
||||||
|
# checked: absent from docs and from live envs). To settle each: run
|
||||||
|
# `env | grep _TRANSCRIPT_PATH` inside a hook and inside a shell-tool call.
|
||||||
("gemini", ("GEMINI_TRANSCRIPT_PATH",)),
|
("gemini", ("GEMINI_TRANSCRIPT_PATH",)),
|
||||||
("droid", ("FACTORY_TRANSCRIPT_PATH", "DROID_TRANSCRIPT_PATH")),
|
("droid", ("FACTORY_TRANSCRIPT_PATH", "DROID_TRANSCRIPT_PATH")),
|
||||||
("qoder", ("QODER_TRANSCRIPT_PATH",)),
|
("qoder", ("QODER_TRANSCRIPT_PATH",)),
|
||||||
@@ -90,11 +148,15 @@ _ENV_PLATFORM_ALIASES = {
|
|||||||
"factory-ai": "droid",
|
"factory-ai": "droid",
|
||||||
"github-copilot": "copilot",
|
"github-copilot": "copilot",
|
||||||
}
|
}
|
||||||
# ZCode intentionally reuses CLAUDE_SESSION_ID. Hooks know the host is ZCode,
|
# ZCode intentionally reuses Claude's session env var name. Hooks know the host
|
||||||
# while later shell commands see only the shared env name and resolve it through
|
# is ZCode, while later shell commands see only the shared env name and resolve
|
||||||
# the Claude entry. Canonicalize both paths to one runtime filename.
|
# it through the claude entry. Canonicalize both paths to one runtime filename.
|
||||||
_CONTEXT_KEY_PLATFORM_ALIASES = {
|
_CONTEXT_KEY_PLATFORM_ALIASES = {
|
||||||
"zcode": "claude",
|
"zcode": "claude",
|
||||||
|
# Factory Droid's config directory is `.factory/`, so a hook that names its
|
||||||
|
# platform after the directory it was installed in reports "factory". Its
|
||||||
|
# sibling hooks report "droid". One runtime filename either way.
|
||||||
|
"factory": "droid",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -138,19 +200,54 @@ def normalize_task_ref(task_ref: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def resolve_task_ref(task_ref: str, repo_root: Path) -> Path | None:
|
def resolve_task_ref(task_ref: str, repo_root: Path) -> Path | None:
|
||||||
"""Resolve a task ref to an absolute task directory."""
|
"""Resolve a task ref to an absolute task directory inside the repo.
|
||||||
|
|
||||||
|
Mirrors `paths.resolve_task_ref` (same containment check). Duplicated
|
||||||
|
rather than imported because this module is loaded standalone — hooks add
|
||||||
|
it to `sys.path` directly — so it stays zero-relative-import on purpose.
|
||||||
|
"""
|
||||||
normalized = normalize_task_ref(task_ref)
|
normalized = normalize_task_ref(task_ref)
|
||||||
if not normalized:
|
if not normalized:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
root = repo_root.resolve()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
path_obj = Path(normalized)
|
path_obj = Path(normalized)
|
||||||
if path_obj.is_absolute():
|
if path_obj.is_absolute():
|
||||||
return path_obj
|
candidate = path_obj
|
||||||
|
elif normalized.startswith(f"{DIR_WORKFLOW}/"):
|
||||||
|
candidate = root / path_obj
|
||||||
|
else:
|
||||||
|
candidate = root / DIR_WORKFLOW / DIR_TASKS / path_obj
|
||||||
|
|
||||||
if normalized.startswith(f"{DIR_WORKFLOW}/"):
|
# Both sides are resolved because repo_root itself may sit behind a
|
||||||
return repo_root / path_obj
|
# symlink (/tmp on macOS does), and resolve() is what collapses `..`
|
||||||
|
# instead of leaving it for a lexical relative_to() to wave through.
|
||||||
|
try:
|
||||||
|
resolved = candidate.resolve()
|
||||||
|
workflow_real = (root / DIR_WORKFLOW).resolve()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj
|
try:
|
||||||
|
resolved.relative_to(root)
|
||||||
|
return resolved
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# `.trellis` may itself be a symlink into a store outside the repo (#567).
|
||||||
|
# The workflow dir's own real location is then a second legitimate
|
||||||
|
# containment base; a ref that escapes BOTH bases is still refused. Map
|
||||||
|
# back to the in-repo (lexical) form so callers store a repo-relative ref.
|
||||||
|
try:
|
||||||
|
rel = resolved.relative_to(workflow_real)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return root / DIR_WORKFLOW / rel
|
||||||
|
|
||||||
|
|
||||||
def _runtime_sessions_dir(repo_root: Path) -> Path:
|
def _runtime_sessions_dir(repo_root: Path) -> Path:
|
||||||
@@ -222,6 +319,12 @@ def _iter_env_keys(
|
|||||||
env_keys: tuple[tuple[str, tuple[str, ...]], ...],
|
env_keys: tuple[tuple[str, tuple[str, ...]], ...],
|
||||||
platform_name: str | None,
|
platform_name: str | None,
|
||||||
) -> tuple[tuple[str, tuple[str, ...]], ...]:
|
) -> tuple[tuple[str, tuple[str, ...]], ...]:
|
||||||
|
"""Narrow an env-key table to one platform, or return all of it.
|
||||||
|
|
||||||
|
A platform with no entry yields an empty tuple, and the caller's `for` loop
|
||||||
|
simply does not run. That is the normal case, not an error: platforms with
|
||||||
|
no verified env var name are deliberately absent from these tables.
|
||||||
|
"""
|
||||||
if not platform_name:
|
if not platform_name:
|
||||||
return env_keys
|
return env_keys
|
||||||
matched = tuple((name, keys) for name, keys in env_keys if name == platform_name)
|
matched = tuple((name, keys) for name, keys in env_keys if name == platform_name)
|
||||||
@@ -275,8 +378,12 @@ def _find_repo_root_from_cwd() -> Path | None:
|
|||||||
current = current.parent
|
current = current.parent
|
||||||
|
|
||||||
|
|
||||||
def _cursor_shell_ticket_dir(repo_root: Path) -> Path:
|
def _shell_ticket_dirs(repo_root: Path) -> tuple[Path, ...]:
|
||||||
return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_CURSOR_SHELL
|
runtime_dir = repo_root / DIR_WORKFLOW / DIR_RUNTIME
|
||||||
|
return (
|
||||||
|
runtime_dir / DIR_SHELL_TICKETS,
|
||||||
|
runtime_dir / DIR_LEGACY_CURSOR_SHELL_TICKETS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _remove_file(path: Path) -> bool:
|
def _remove_file(path: Path) -> bool:
|
||||||
@@ -334,7 +441,7 @@ def _ticket_is_fresh(ticket: dict[str, Any], ticket_path: Path, now: float) -> b
|
|||||||
|
|
||||||
created_at = ticket.get("created_at_epoch")
|
created_at = ticket.get("created_at_epoch")
|
||||||
if isinstance(created_at, (int, float)):
|
if isinstance(created_at, (int, float)):
|
||||||
if now - created_at <= CURSOR_SHELL_TICKET_TTL_SECONDS:
|
if now - created_at <= SHELL_TICKET_TTL_SECONDS:
|
||||||
return True
|
return True
|
||||||
_remove_file(ticket_path)
|
_remove_file(ticket_path)
|
||||||
return False
|
return False
|
||||||
@@ -352,13 +459,18 @@ def _ticket_cwd_matches_repo(ticket: dict[str, Any], repo_root: Path) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _matching_cursor_ticket_context_key(
|
def _matching_ticket_context_key(
|
||||||
ticket_path: Path,
|
ticket_path: Path,
|
||||||
repo_root: Path,
|
repo_root: Path,
|
||||||
now: float,
|
now: float,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
|
"""Accept a ticket on its merits, never on which platform wrote it.
|
||||||
|
|
||||||
|
The `platform` field a ticket carries is debugging metadata; gating on it
|
||||||
|
was what kept this bridge invisible to every platform but Cursor.
|
||||||
|
"""
|
||||||
ticket = _read_json(ticket_path)
|
ticket = _read_json(ticket_path)
|
||||||
if ticket is None or ticket.get("platform") != "cursor":
|
if ticket is None:
|
||||||
return None
|
return None
|
||||||
if not _ticket_is_fresh(ticket, ticket_path, now):
|
if not _ticket_is_fresh(ticket, ticket_path, now):
|
||||||
return None
|
return None
|
||||||
@@ -369,27 +481,28 @@ def _matching_cursor_ticket_context_key(
|
|||||||
return _string_value(ticket.get("context_key"))
|
return _string_value(ticket.get("context_key"))
|
||||||
|
|
||||||
|
|
||||||
def _lookup_cursor_shell_ticket_context_key() -> str | None:
|
def _lookup_shell_ticket_context_key() -> str | None:
|
||||||
"""Resolve Cursor conversation identity from a short-lived shell ticket.
|
"""Resolve session identity from a short-lived shell ticket.
|
||||||
|
|
||||||
Cursor exposes `conversation_id` to `beforeShellExecution`, but does not
|
No researched platform exports its session id into a shell child, but every
|
||||||
export it into the shell command environment. The Cursor hook writes a
|
hook-capable one hands that id to a hook. So the hook that runs just before
|
||||||
short-lived ticket just before `task.py` runs. We accept a ticket only when
|
a shell command writes a ticket, and this reads it back. A ticket counts
|
||||||
the current `task.py` subcommand matches and exactly one fresh context key
|
only when it is fresh, was written for this repo, and matches the `task.py`
|
||||||
matches, which avoids cross-window pointer contamination.
|
subcommand now running — and only when exactly one fresh context key
|
||||||
|
matches. Two concurrent windows therefore both degrade rather than one
|
||||||
|
inheriting the other's pointer.
|
||||||
"""
|
"""
|
||||||
repo_root = _find_repo_root_from_cwd()
|
repo_root = _find_repo_root_from_cwd()
|
||||||
if repo_root is None:
|
if repo_root is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
ticket_dir = _cursor_shell_ticket_dir(repo_root)
|
|
||||||
if not ticket_dir.is_dir():
|
|
||||||
return None
|
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
candidates: set[str] = set()
|
candidates: set[str] = set()
|
||||||
|
for ticket_dir in _shell_ticket_dirs(repo_root):
|
||||||
|
if not ticket_dir.is_dir():
|
||||||
|
continue
|
||||||
for ticket_path in ticket_dir.glob("*.json"):
|
for ticket_path in ticket_dir.glob("*.json"):
|
||||||
context_key = _matching_cursor_ticket_context_key(ticket_path, repo_root, now)
|
context_key = _matching_ticket_context_key(ticket_path, repo_root, now)
|
||||||
if context_key:
|
if context_key:
|
||||||
candidates.add(context_key)
|
candidates.add(context_key)
|
||||||
|
|
||||||
@@ -435,29 +548,32 @@ def resolve_context_key(
|
|||||||
if env_context_key:
|
if env_context_key:
|
||||||
return env_context_key
|
return env_context_key
|
||||||
|
|
||||||
if allow_environment_context and platform_name in (None, "session", "cursor"):
|
# Last in the chain on purpose: a platform that genuinely exports identity
|
||||||
return _lookup_cursor_shell_ticket_context_key()
|
# into the shell outranks a ticket, and no platform name gates the lookup.
|
||||||
|
if allow_environment_context:
|
||||||
|
return _lookup_shell_ticket_context_key()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _read_json(path: Path) -> dict[str, Any] | None:
|
def _read_json(path: Path) -> dict[str, Any] | None:
|
||||||
try:
|
"""Tolerant read of a session runtime file, non-objects included."""
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
data = _io_read_json(path)
|
||||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
||||||
return None
|
|
||||||
return data if isinstance(data, dict) else None
|
return data if isinstance(data, dict) else None
|
||||||
|
|
||||||
|
|
||||||
def _write_json(path: Path, data: dict[str, Any]) -> bool:
|
def _write_json(path: Path, data: dict[str, Any]) -> bool:
|
||||||
|
"""Write a session runtime file atomically, creating the runtime dir.
|
||||||
|
|
||||||
|
Routes through io.write_json so session pointers get the same
|
||||||
|
temp-file-then-rename treatment as task.json (#429). A plain write_text
|
||||||
|
truncates the target first, so a crash mid-write would leave a session
|
||||||
|
file that reads back as no active task.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
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:
|
except OSError:
|
||||||
return False
|
return False
|
||||||
|
return _io_write_json(path, data)
|
||||||
|
|
||||||
|
|
||||||
def _canonical_task_ref(task_path: str, repo_root: Path) -> str | None:
|
def _canonical_task_ref(task_path: str, repo_root: Path) -> str | None:
|
||||||
@@ -468,9 +584,46 @@ def _canonical_task_ref(task_path: str, repo_root: Path) -> str | None:
|
|||||||
if full_path is None or not full_path.is_dir():
|
if full_path is None or not full_path.is_dir():
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return full_path.relative_to(repo_root).as_posix()
|
return full_path.relative_to(repo_root.resolve()).as_posix()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return str(full_path)
|
# resolve_task_ref already refused everything outside the repo, so this
|
||||||
|
# is unreachable. Refuse rather than fall back to an absolute path —
|
||||||
|
# that fallback is how an out-of-repo ref used to reach the session
|
||||||
|
# pointer and get replayed on every later turn.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _relative_task_ref(task_path: str, repo_root: Path) -> str:
|
||||||
|
"""Repo-relative posix ref for a task path that need not exist.
|
||||||
|
|
||||||
|
`_canonical_task_ref` resolves through the filesystem and so refuses a task
|
||||||
|
directory that has been moved away. Rename needs to name both sides of the
|
||||||
|
move, one of which is always absent.
|
||||||
|
"""
|
||||||
|
normalized = normalize_task_ref(task_path)
|
||||||
|
if not normalized:
|
||||||
|
return ""
|
||||||
|
candidate = Path(normalized)
|
||||||
|
if not candidate.is_absolute():
|
||||||
|
return normalized
|
||||||
|
try:
|
||||||
|
resolved = candidate.resolve()
|
||||||
|
root = repo_root.resolve()
|
||||||
|
workflow_real = (root / DIR_WORKFLOW).resolve()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return resolved.relative_to(root).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
# Same dual-base containment as resolve_task_ref: a path through a
|
||||||
|
# symlinked `.trellis` (#567) maps back to its in-repo form; anything
|
||||||
|
# outside both bases is refused rather than stored as an absolute pointer.
|
||||||
|
try:
|
||||||
|
rel = resolved.relative_to(workflow_real)
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
return (Path(DIR_WORKFLOW) / rel).as_posix()
|
||||||
|
|
||||||
|
|
||||||
def _active_from_ref(
|
def _active_from_ref(
|
||||||
@@ -652,6 +805,48 @@ def clear_task_from_sessions(task_path: str, repo_root: Path) -> int:
|
|||||||
return cleared
|
return cleared
|
||||||
|
|
||||||
|
|
||||||
|
def repoint_task_in_sessions(old_path: str, new_path: str, repo_root: Path) -> int:
|
||||||
|
"""Move every session pointer from `old_path` to `new_path`.
|
||||||
|
|
||||||
|
Rename is the one lifecycle step where the task survives under a different
|
||||||
|
name, so clearing the pointers (what archive does) would be wrong: the user
|
||||||
|
would silently lose their active task and have to run `task.py start`
|
||||||
|
again to get context injection back. Repointing keeps the session valid
|
||||||
|
across the rename.
|
||||||
|
"""
|
||||||
|
# Not `_canonical_task_ref`: the caller repoints *after* moving the
|
||||||
|
# directory, so `old_path` no longer exists and canonicalization — which
|
||||||
|
# requires an existing directory — would return None for exactly the ref we
|
||||||
|
# need to match.
|
||||||
|
target = _relative_task_ref(old_path, repo_root)
|
||||||
|
replacement = _relative_task_ref(new_path, repo_root)
|
||||||
|
if not target or not replacement:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
moved = 0
|
||||||
|
sessions_dir = _runtime_sessions_dir(repo_root)
|
||||||
|
if not sessions_dir.is_dir():
|
||||||
|
return moved
|
||||||
|
|
||||||
|
for session_path in sorted(sessions_dir.glob("*.json")):
|
||||||
|
context = _read_json(session_path)
|
||||||
|
if not context:
|
||||||
|
continue
|
||||||
|
current = _string_value(context.get("current_task"))
|
||||||
|
if not current:
|
||||||
|
continue
|
||||||
|
current_ref = _canonical_task_ref(current, repo_root) or _relative_task_ref(
|
||||||
|
current, repo_root
|
||||||
|
)
|
||||||
|
if current_ref != target:
|
||||||
|
continue
|
||||||
|
context["current_task"] = replacement
|
||||||
|
if _write_json(session_path, context):
|
||||||
|
moved += 1
|
||||||
|
|
||||||
|
return moved
|
||||||
|
|
||||||
|
|
||||||
def get_current_task_source(
|
def get_current_task_source(
|
||||||
repo_root: Path,
|
repo_root: Path,
|
||||||
platform_input: dict[str, Any] | None = None,
|
platform_input: dict[str, Any] | None = None,
|
||||||
|
|||||||
+81
-168
@@ -11,157 +11,11 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .paths import DIR_WORKFLOW, get_repo_root
|
from .paths import DIR_WORKFLOW, get_repo_root
|
||||||
|
from .trellis_config import parse_simple_yaml
|
||||||
|
|
||||||
|
# The YAML subset parser lives in trellis_config.py — it imports nothing from
|
||||||
# =============================================================================
|
# this package, so hooks can load it as a single standalone file. Two byte-
|
||||||
# YAML Simple Parser (no dependencies)
|
# equivalent copies is a drift hazard, not a feature.
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
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
|
# Defaults
|
||||||
@@ -173,13 +27,46 @@ DEFAULT_CODEX_DISPATCH_MODE = "auto"
|
|||||||
CONFIG_FILE = "config.yaml"
|
CONFIG_FILE = "config.yaml"
|
||||||
|
|
||||||
|
|
||||||
def _is_true_config_value(value: object) -> bool:
|
TRUE_CONFIG_VALUES = ("true", "yes", "1", "on")
|
||||||
"""Return True when a config value represents an enabled flag."""
|
FALSE_CONFIG_VALUES = ("false", "no", "0", "off")
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_config_bool(
|
||||||
|
value: object,
|
||||||
|
default: bool,
|
||||||
|
label: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Coerce a config value to a bool, warning on anything unrecognized.
|
||||||
|
|
||||||
|
The parser stores every value as a string, so ``git: yes`` arrives as
|
||||||
|
``"yes"``. Every boolean config key goes through this one helper: an
|
||||||
|
accepted-here/rejected-there split means a user writing a perfectly
|
||||||
|
reasonable YAML boolean silently gets the opposite branch.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Raw value from the parsed config.
|
||||||
|
default: Returned when the value is unrecognized.
|
||||||
|
label: Config key name, used in the warning.
|
||||||
|
"""
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
return value
|
return value
|
||||||
if isinstance(value, str):
|
s = str(value).strip().lower()
|
||||||
return value.strip().lower() == "true"
|
if s in TRUE_CONFIG_VALUES:
|
||||||
|
return True
|
||||||
|
if s in FALSE_CONFIG_VALUES:
|
||||||
return False
|
return False
|
||||||
|
print(
|
||||||
|
f"[WARN] invalid {label} value: {value!r}; using {str(default).lower()} (default)",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _is_true_config_value(value: object, label: str = "config flag") -> bool:
|
||||||
|
"""Return True when a config value represents an enabled flag."""
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
return coerce_config_bool(value, False, label)
|
||||||
|
|
||||||
|
|
||||||
def _get_config_path(repo_root: Path | None = None) -> Path:
|
def _get_config_path(repo_root: Path | None = None) -> Path:
|
||||||
@@ -189,13 +76,27 @@ def _get_config_path(repo_root: Path | None = None) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def _load_config(repo_root: Path | None = None) -> dict:
|
def _load_config(repo_root: Path | None = None) -> dict:
|
||||||
"""Load and parse config.yaml. Returns empty dict on any error."""
|
"""Load and parse config.yaml. Returns empty dict on any error.
|
||||||
|
|
||||||
|
Fail-open, matching ``trellis_config.read_trellis_config``: a malformed
|
||||||
|
config must not take down ``task.py create``. A parse failure is reported
|
||||||
|
once on stderr so it is not invisible.
|
||||||
|
"""
|
||||||
config_file = _get_config_path(repo_root)
|
config_file = _get_config_path(repo_root)
|
||||||
try:
|
try:
|
||||||
content = config_file.read_text(encoding="utf-8")
|
content = config_file.read_text(encoding="utf-8")
|
||||||
return parse_simple_yaml(content)
|
|
||||||
except (OSError, IOError):
|
except (OSError, IOError):
|
||||||
return {}
|
return {}
|
||||||
|
try:
|
||||||
|
parsed = parse_simple_yaml(content, source=str(config_file))
|
||||||
|
except Exception as e:
|
||||||
|
print(
|
||||||
|
f"[WARN] could not parse {config_file}: {type(e).__name__}: {e}; "
|
||||||
|
"using defaults",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
return parsed if isinstance(parsed, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
def get_session_commit_message(repo_root: Path | None = None) -> str:
|
def get_session_commit_message(repo_root: Path | None = None) -> str:
|
||||||
@@ -231,18 +132,9 @@ def get_session_auto_commit(repo_root: Path | None = None) -> bool:
|
|||||||
"""
|
"""
|
||||||
config = _load_config(repo_root)
|
config = _load_config(repo_root)
|
||||||
raw = config.get("session_auto_commit", DEFAULT_SESSION_AUTO_COMMIT)
|
raw = config.get("session_auto_commit", DEFAULT_SESSION_AUTO_COMMIT)
|
||||||
if isinstance(raw, bool):
|
return coerce_config_bool(
|
||||||
return raw
|
raw, DEFAULT_SESSION_AUTO_COMMIT, "session_auto_commit"
|
||||||
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:
|
def get_codex_dispatch_mode(repo_root: Path | None = None) -> str:
|
||||||
@@ -374,16 +266,37 @@ def get_hooks(event: str, repo_root: Path | None = None) -> list[str]:
|
|||||||
event: Event name (e.g. "after_create", "after_archive").
|
event: Event name (e.g. "after_create", "after_archive").
|
||||||
repo_root: Repository root path.
|
repo_root: Repository root path.
|
||||||
|
|
||||||
|
A hook the user believes is installed and which silently never runs is the
|
||||||
|
worst outcome for this feature, so a declared-but-unusable shape warns
|
||||||
|
instead of returning an empty list quietly.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of shell commands to execute, empty if none configured.
|
List of shell commands to execute, empty if none configured.
|
||||||
"""
|
"""
|
||||||
config = _load_config(repo_root)
|
config = _load_config(repo_root)
|
||||||
hooks = config.get("hooks")
|
hooks = config.get("hooks")
|
||||||
|
if hooks is None:
|
||||||
|
return []
|
||||||
if not isinstance(hooks, dict):
|
if not isinstance(hooks, dict):
|
||||||
|
print(
|
||||||
|
f"[WARN] ignoring `hooks` in config.yaml: expected a mapping of "
|
||||||
|
f"event -> list of commands, got {hooks!r}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
return []
|
return []
|
||||||
commands = hooks.get(event)
|
commands = hooks.get(event)
|
||||||
|
if commands is None:
|
||||||
|
return []
|
||||||
if isinstance(commands, list):
|
if isinstance(commands, list):
|
||||||
return [str(c) for c in commands]
|
return [str(c) for c in commands]
|
||||||
|
# `after_create: echo hi` instead of a `- ` list — parses fine, registers
|
||||||
|
# nothing.
|
||||||
|
print(
|
||||||
|
f"[WARN] ignoring hook `{event}` in config.yaml: expected a list of "
|
||||||
|
f"commands, got {commands!r}. Write it as:\n"
|
||||||
|
f" hooks:\n {event}:\n - {commands}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@@ -471,7 +384,7 @@ def get_git_packages(repo_root: Path | None = None) -> dict[str, str]:
|
|||||||
return {
|
return {
|
||||||
name: cfg.get("path", name)
|
name: cfg.get("path", name)
|
||||||
for name, cfg in packages.items()
|
for name, cfg in packages.items()
|
||||||
if _is_true_config_value(cfg.get("git"))
|
if _is_true_config_value(cfg.get("git"), f"packages.{name}.git")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .paths import (
|
from .paths import (
|
||||||
|
DEVELOPER_HINT,
|
||||||
DIR_WORKFLOW,
|
DIR_WORKFLOW,
|
||||||
DIR_WORKSPACE,
|
DIR_WORKSPACE,
|
||||||
DIR_TASKS,
|
DIR_TASKS,
|
||||||
@@ -160,6 +161,7 @@ def ensure_developer(repo_root: Path | None = None) -> None:
|
|||||||
if not check_developer(repo_root):
|
if not check_developer(repo_root):
|
||||||
print("Error: Developer not initialized.", file=sys.stderr)
|
print("Error: Developer not initialized.", file=sys.stderr)
|
||||||
print(f"Run: python3 ./{DIR_WORKFLOW}/scripts/init_developer.py <your-name>", file=sys.stderr)
|
print(f"Run: python3 ./{DIR_WORKFLOW}/scripts/init_developer.py <your-name>", file=sys.stderr)
|
||||||
|
print(DEVELOPER_HINT, file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,25 @@ Single source of truth for running git commands across all Trellis scripts.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# Bounded retry for transient `.git/index.lock` contention. Another process
|
||||||
|
# (IDE git integration, status daemon, a concurrent Trellis session) can hold
|
||||||
|
# the lock for a fraction of a second; three attempts spread over ~1.5s ride
|
||||||
|
# that out without making a genuinely stuck lock hang the command. One sleep
|
||||||
|
# per retry, so the attempt count follows the backoff tuple.
|
||||||
|
INDEX_LOCK_RETRY_BACKOFF = (0.5, 1.0)
|
||||||
|
INDEX_LOCK_RETRY_ATTEMPTS = len(INDEX_LOCK_RETRY_BACKOFF) + 1
|
||||||
|
|
||||||
|
# Whether a checkout is a linked worktree cannot change while a script runs, and
|
||||||
|
# the answer costs two subprocesses. Developer-identity resolution asks several
|
||||||
|
# times per command whenever the local `.developer` file is absent, so memoize.
|
||||||
|
_CACHE_MISS = object()
|
||||||
|
_MAIN_WORKTREE_CACHE: dict[Path, Path | None] = {}
|
||||||
|
|
||||||
|
|
||||||
def run_git(
|
def run_git(
|
||||||
args: list[str],
|
args: list[str],
|
||||||
cwd: Path | None = None,
|
cwd: Path | None = None,
|
||||||
@@ -38,6 +54,49 @@ def run_git(
|
|||||||
return 1, "", str(e)
|
return 1, "", str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def stderr_indicates_index_lock(stderr: str) -> bool:
|
||||||
|
"""git failed because another process holds `.git/index.lock`."""
|
||||||
|
if not stderr:
|
||||||
|
return False
|
||||||
|
return "index.lock" in stderr.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def run_git_retry_index_lock(
|
||||||
|
args: list[str],
|
||||||
|
cwd: Path | None = None,
|
||||||
|
timeout: float | None = None,
|
||||||
|
) -> tuple[int, str, str]:
|
||||||
|
"""Run a git command, retrying only while `.git/index.lock` is held.
|
||||||
|
|
||||||
|
Any other non-zero exit returns immediately — a retry loop around real
|
||||||
|
failures (bad path, nothing to commit, hook rejection) just delays the
|
||||||
|
error. Returns the last (returncode, stdout, stderr).
|
||||||
|
"""
|
||||||
|
rc, out, err = run_git(args, cwd=cwd, timeout=timeout)
|
||||||
|
attempt = 1
|
||||||
|
while (
|
||||||
|
rc != 0
|
||||||
|
and attempt < INDEX_LOCK_RETRY_ATTEMPTS
|
||||||
|
and stderr_indicates_index_lock(err)
|
||||||
|
):
|
||||||
|
time.sleep(INDEX_LOCK_RETRY_BACKOFF[attempt - 1])
|
||||||
|
attempt += 1
|
||||||
|
rc, out, err = run_git(args, cwd=cwd, timeout=timeout)
|
||||||
|
return rc, out, err
|
||||||
|
|
||||||
|
|
||||||
|
def index_lock_path(repo_root: Path) -> str:
|
||||||
|
"""Path of the lock file git is contending on, for diagnostics.
|
||||||
|
|
||||||
|
Asks git so worktrees and `GIT_DIR` setups name the real file rather
|
||||||
|
than a `.git/` guess that does not exist there.
|
||||||
|
"""
|
||||||
|
rc, out, _ = run_git(["rev-parse", "--git-path", "index.lock"], cwd=repo_root)
|
||||||
|
if rc == 0 and out.strip():
|
||||||
|
return out.strip()
|
||||||
|
return ".git/index.lock"
|
||||||
|
|
||||||
|
|
||||||
def resolve_default_branch(repo_root: Path) -> str | None:
|
def resolve_default_branch(repo_root: Path) -> str | None:
|
||||||
"""Resolve the repository's default branch (origin/HEAD target).
|
"""Resolve the repository's default branch (origin/HEAD target).
|
||||||
|
|
||||||
@@ -63,6 +122,76 @@ def resolve_default_branch(repo_root: Path) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def current_branch_name(repo_root: Path) -> str | None:
|
||||||
|
"""Return the checked-out branch name, or None when there isn't one.
|
||||||
|
|
||||||
|
Empty output covers detached HEAD and "not a git repository" alike, and
|
||||||
|
callers treat both the same way: there is no branch worth recording.
|
||||||
|
"""
|
||||||
|
rc, out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
|
||||||
|
if rc != 0:
|
||||||
|
return None
|
||||||
|
return out.strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
def has_git_remote(repo_root: Path) -> bool:
|
||||||
|
"""Whether the repository has at least one configured remote."""
|
||||||
|
rc, out, _ = run_git(["remote"], cwd=repo_root)
|
||||||
|
return rc == 0 and bool(out.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def main_worktree_root(repo_root: Path) -> Path | None:
|
||||||
|
"""Root of the main working tree when `repo_root` is a linked worktree.
|
||||||
|
|
||||||
|
Returns None in the main working tree itself, outside a git repository, and
|
||||||
|
for a linked worktree of a bare repository (no main checkout to point at).
|
||||||
|
|
||||||
|
`git worktree list --porcelain` reports the main working tree as its first
|
||||||
|
record, so git identifies it rather than this code deriving it from the
|
||||||
|
`.git` layout. Deriving it — taking the parent of `--git-common-dir` — is
|
||||||
|
wrong for a bare repository that happens to sit inside an unrelated
|
||||||
|
checkout (`~/repos/project.git` under a `~/repos` that is itself a repo):
|
||||||
|
the parent is a real checkout with a real `.developer`, so the guess is
|
||||||
|
indistinguishable from a hit and identity leaks across repositories.
|
||||||
|
"""
|
||||||
|
cached = _MAIN_WORKTREE_CACHE.get(repo_root, _CACHE_MISS)
|
||||||
|
if cached is not _CACHE_MISS:
|
||||||
|
return cached # type: ignore[return-value]
|
||||||
|
|
||||||
|
result = _probe_main_worktree_root(repo_root)
|
||||||
|
_MAIN_WORKTREE_CACHE[repo_root] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_main_worktree_root(repo_root: Path) -> Path | None:
|
||||||
|
rc_list, listing, _ = run_git(["worktree", "list", "--porcelain"], cwd=repo_root)
|
||||||
|
rc_top, toplevel, _ = run_git(["rev-parse", "--show-toplevel"], cwd=repo_root)
|
||||||
|
if rc_list != 0 or rc_top != 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
lines = listing.splitlines()
|
||||||
|
if not lines or not lines[0].startswith("worktree "):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Records are blank-line separated; a `bare` attribute on the first one
|
||||||
|
# means the "main working tree" is a bare repo with nothing to inherit.
|
||||||
|
for line in lines[1:]:
|
||||||
|
if not line.strip():
|
||||||
|
break
|
||||||
|
if line.strip() == "bare":
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
main_root = Path(lines[0][len("worktree ") :].strip()).resolve()
|
||||||
|
current_root = Path(toplevel.strip()).resolve()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if main_root == current_root:
|
||||||
|
return None
|
||||||
|
return main_root
|
||||||
|
|
||||||
|
|
||||||
def branch_exists_locally(branch: str, repo_root: Path) -> bool:
|
def branch_exists_locally(branch: str, repo_root: Path) -> bool:
|
||||||
"""Check whether a local branch ref exists in the repository."""
|
"""Check whether a local branch ref exists in the repository."""
|
||||||
if not branch:
|
if not branch:
|
||||||
|
|||||||
+105
-6
@@ -1,8 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
JSON file I/O utilities.
|
File I/O utilities.
|
||||||
|
|
||||||
Provides read_json and write_json as the single source of truth
|
Provides read_json / write_json as the single source of truth for JSON file
|
||||||
for JSON file operations across all Trellis scripts.
|
operations, plus write_text_atomic for the Markdown state files (journal,
|
||||||
|
index.md) that carry durable session state.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -13,17 +14,96 @@ import tempfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
JSON_READ_MISSING = "missing"
|
||||||
|
JSON_READ_INVALID = "invalid"
|
||||||
|
JSON_READ_UNREADABLE = "unreadable"
|
||||||
|
JSON_READ_NOT_OBJECT = "not-object"
|
||||||
|
JSON_READ_EMPTY = "empty"
|
||||||
|
JSON_READ_UNDECODABLE = "undecodable"
|
||||||
|
|
||||||
|
|
||||||
def read_json(path: Path) -> dict | None:
|
def read_json(path: Path) -> dict | None:
|
||||||
"""Read and parse a JSON file.
|
"""Read and parse a JSON file.
|
||||||
|
|
||||||
Returns None if the file doesn't exist, is invalid JSON, or can't be read.
|
Returns None if the file doesn't exist, is invalid JSON, or can't be read.
|
||||||
|
Use this for optional reads only — a caller that is about to overwrite the
|
||||||
|
file, or that must tell a parse error from a permissions error, wants
|
||||||
|
read_json_checked instead.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return json.loads(path.read_text(encoding="utf-8"))
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
except (FileNotFoundError, json.JSONDecodeError, OSError, UnicodeDecodeError):
|
||||||
|
# UnicodeDecodeError is not an OSError. Without it here a non-UTF-8
|
||||||
|
# session file raises out of a tolerant read, so the hook path fails
|
||||||
|
# instead of degrading to "no active task".
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_json_checked(path: Path) -> tuple[dict | None, str | None]:
|
||||||
|
"""Read a JSON object, keeping the ways it can fail distinguishable.
|
||||||
|
|
||||||
|
Returns ``(data, None)`` on success, or ``(None, reason)`` where reason is
|
||||||
|
one of the ``JSON_READ_*`` constants. An empty object counts as a failure:
|
||||||
|
a state file that parses to ``{}`` carries none of the fields callers read,
|
||||||
|
and treating it as success would silently rebuild it from defaults.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None, JSON_READ_MISSING
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
# Not an OSError, so it escaped both handlers and surfaced as a
|
||||||
|
# traceback. The point of this reader is that every failure mode stays
|
||||||
|
# nameable, and "not valid UTF-8" is a different repair from
|
||||||
|
# "not valid JSON".
|
||||||
|
return None, JSON_READ_UNDECODABLE
|
||||||
|
except OSError:
|
||||||
|
return None, JSON_READ_UNREADABLE
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None, JSON_READ_INVALID
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None, JSON_READ_NOT_OBJECT
|
||||||
|
if not data:
|
||||||
|
return None, JSON_READ_EMPTY
|
||||||
|
return data, None
|
||||||
|
|
||||||
|
|
||||||
|
def describe_json_read_failure(path: Path, reason: str | None) -> tuple[str, str]:
|
||||||
|
"""Return ``(what happened, what to do)`` for a read_json_checked reason."""
|
||||||
|
if reason == JSON_READ_MISSING:
|
||||||
|
return (f"{path}: file not found", "Pass an existing task directory, or create the task first.")
|
||||||
|
if reason == JSON_READ_UNREADABLE:
|
||||||
|
return (
|
||||||
|
f"{path}: could not be read (permission denied or I/O error)",
|
||||||
|
"Check the file and directory permissions, then retry.",
|
||||||
|
)
|
||||||
|
if reason == JSON_READ_INVALID:
|
||||||
|
return (
|
||||||
|
f"{path}: not valid JSON",
|
||||||
|
f"Fix the syntax (e.g. `python3 -m json.tool {path}`), then retry.",
|
||||||
|
)
|
||||||
|
if reason == JSON_READ_NOT_OBJECT:
|
||||||
|
return (
|
||||||
|
f"{path}: top level is not a JSON object",
|
||||||
|
"Restore the file to a JSON object ({ ... }), then retry.",
|
||||||
|
)
|
||||||
|
if reason == JSON_READ_EMPTY:
|
||||||
|
return (
|
||||||
|
f"{path}: contains an empty JSON object",
|
||||||
|
"Restore the task fields (or recreate the task), then retry.",
|
||||||
|
)
|
||||||
|
if reason == JSON_READ_UNDECODABLE:
|
||||||
|
return (
|
||||||
|
f"{path}: not valid UTF-8 text",
|
||||||
|
"Re-save the file as UTF-8 (or restore it from git), then retry.",
|
||||||
|
)
|
||||||
|
return (f"{path}: could not be loaded", "Inspect the file, then retry.")
|
||||||
|
|
||||||
|
|
||||||
def write_json(path: Path, data: dict) -> bool:
|
def write_json(path: Path, data: dict) -> bool:
|
||||||
"""Write dict to JSON file with pretty formatting.
|
"""Write dict to JSON file with pretty formatting.
|
||||||
|
|
||||||
@@ -34,7 +114,19 @@ def write_json(path: Path, data: dict) -> bool:
|
|||||||
|
|
||||||
Returns True on success, False on error.
|
Returns True on success, False on error.
|
||||||
"""
|
"""
|
||||||
payload = json.dumps(data, indent=2, ensure_ascii=False)
|
return write_text_atomic(path, json.dumps(data, indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
def write_text_atomic(path: Path, text: str) -> bool:
|
||||||
|
"""Write text to a file atomically (temp in same dir, then replace).
|
||||||
|
|
||||||
|
The same never-truncate-in-place guarantee as :func:`write_json`, for the
|
||||||
|
Markdown state files that hold durable session state (journal files,
|
||||||
|
index.md). A crash or Ctrl-C mid-write leaves the previous content intact
|
||||||
|
instead of a half-written record that no retry can classify.
|
||||||
|
|
||||||
|
Returns True on success, False on error.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
fd, tmp = tempfile.mkstemp(
|
fd, tmp = tempfile.mkstemp(
|
||||||
dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp"
|
dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp"
|
||||||
@@ -50,7 +142,7 @@ def write_json(path: Path, data: dict) -> bool:
|
|||||||
os.close(fd)
|
os.close(fd)
|
||||||
raise
|
raise
|
||||||
with f:
|
with f:
|
||||||
f.write(payload)
|
f.write(text)
|
||||||
os.replace(tmp, path)
|
os.replace(tmp, path)
|
||||||
return True
|
return True
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -59,3 +151,10 @@ def write_json(path: Path, data: dict) -> bool:
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
return False
|
return False
|
||||||
|
except BaseException:
|
||||||
|
# Ctrl-C mid-write: drop the temp file, then let the interrupt through.
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|||||||
+113
-17
@@ -12,10 +12,13 @@ Provides:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .git import main_worktree_root
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Path Constants (change here to rename directories)
|
# Path Constants (change here to rename directories)
|
||||||
@@ -35,6 +38,17 @@ FILE_CURRENT_TASK = ".current-task"
|
|||||||
FILE_TASK_JSON = "task.json"
|
FILE_TASK_JSON = "task.json"
|
||||||
FILE_JOURNAL_PREFIX = "journal-"
|
FILE_JOURNAL_PREFIX = "journal-"
|
||||||
|
|
||||||
|
# Environment override for the developer identity, ahead of the .developer file.
|
||||||
|
ENV_DEVELOPER = "TRELLIS_DEVELOPER"
|
||||||
|
|
||||||
|
# Appended to every "no developer set" error so the two non-obvious sources are
|
||||||
|
# discoverable from the failure itself.
|
||||||
|
DEVELOPER_HINT = (
|
||||||
|
f" Or set {ENV_DEVELOPER}=<your-name> in the environment.\n"
|
||||||
|
f" A linked git worktree inherits {DIR_WORKFLOW}/{FILE_DEVELOPER} from its "
|
||||||
|
f"main checkout — run init_developer.py there to cover every worktree."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Repository Root
|
# Repository Root
|
||||||
@@ -66,8 +80,40 @@ def get_repo_root(start_path: Path | None = None) -> Path:
|
|||||||
# Developer
|
# Developer
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
def _read_developer_file(dev_file: Path) -> str | None:
|
||||||
|
"""Read the `name=` field out of a .developer file, or None."""
|
||||||
|
if not dev_file.is_file():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = dev_file.read_text(encoding="utf-8")
|
||||||
|
except (OSError, IOError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
for line in content.splitlines():
|
||||||
|
if line.startswith("name="):
|
||||||
|
return line.split("=", 1)[1].strip() or None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_developer(repo_root: Path | None = None) -> str | None:
|
def get_developer(repo_root: Path | None = None) -> str | None:
|
||||||
"""Get developer name from .developer file.
|
"""Get the developer name for this checkout.
|
||||||
|
|
||||||
|
Resolution order, first hit wins (a CLI `--assignee` flag overrides all of
|
||||||
|
it, before this function is ever called):
|
||||||
|
|
||||||
|
1. The ``TRELLIS_DEVELOPER`` environment variable.
|
||||||
|
2. ``.trellis/.developer`` in this checkout.
|
||||||
|
3. ``.trellis/.developer`` in the main checkout, when this checkout is a
|
||||||
|
linked git worktree.
|
||||||
|
|
||||||
|
Step 3 exists because `.developer` is gitignored on purpose — it carries a
|
||||||
|
personal identity and no tracked file should. A fresh `git worktree add`
|
||||||
|
therefore starts with no identity file of its own, which used to make every
|
||||||
|
task.py command fail until init_developer.py was re-run per worktree. The
|
||||||
|
main checkout's file is read, never copied: a copy would go stale and shadow
|
||||||
|
later changes made in the main checkout.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
repo_root: Repository root path. Defaults to auto-detected.
|
repo_root: Repository root path. Defaults to auto-detected.
|
||||||
@@ -75,23 +121,22 @@ def get_developer(repo_root: Path | None = None) -> str | None:
|
|||||||
Returns:
|
Returns:
|
||||||
Developer name or None if not initialized.
|
Developer name or None if not initialized.
|
||||||
"""
|
"""
|
||||||
|
env_name = os.environ.get(ENV_DEVELOPER, "").strip()
|
||||||
|
if env_name:
|
||||||
|
return env_name
|
||||||
|
|
||||||
if repo_root is None:
|
if repo_root is None:
|
||||||
repo_root = get_repo_root()
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
|
local = _read_developer_file(repo_root / DIR_WORKFLOW / FILE_DEVELOPER)
|
||||||
|
if local:
|
||||||
|
return local
|
||||||
|
|
||||||
if not dev_file.is_file():
|
main_root = main_worktree_root(repo_root)
|
||||||
|
if main_root is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
return _read_developer_file(main_root / DIR_WORKFLOW / FILE_DEVELOPER)
|
||||||
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:
|
def check_developer(repo_root: Path | None = None) -> bool:
|
||||||
@@ -233,7 +278,25 @@ def normalize_task_ref(task_ref: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def resolve_task_ref(task_ref: str, repo_root: Path | None = None) -> Path | None:
|
def resolve_task_ref(task_ref: str, repo_root: Path | None = None) -> Path | None:
|
||||||
"""Resolve a task ref to an absolute task directory path."""
|
"""Resolve a task ref to an absolute task directory path inside the repo.
|
||||||
|
|
||||||
|
Returns None when the ref resolves outside `repo_root`. Every reader of the
|
||||||
|
active task — `task.py`, the shared hooks, the platform extensions — comes
|
||||||
|
through here, so containment is enforced at this one point rather than at
|
||||||
|
each call site.
|
||||||
|
|
||||||
|
It matters because a ref is not always something the user typed. It round
|
||||||
|
trips through the session pointer under `.trellis/.runtime/sessions/`, and
|
||||||
|
`..` segments used to survive that trip intact: `_canonical_task_ref`
|
||||||
|
compares lexically, and a lexical `relative_to` accepts
|
||||||
|
`<root>/.trellis/tasks/../../../elsewhere` because the string does start
|
||||||
|
with the root. The ref was then stored verbatim and replayed on every later
|
||||||
|
turn, so `task.py start .trellis/tasks/../../../elsewhere` both rewrote that
|
||||||
|
directory's `task.json` and fed its files to the model.
|
||||||
|
|
||||||
|
Resolving here also normalises the path, so callers get a ref without `..`
|
||||||
|
to store.
|
||||||
|
"""
|
||||||
if repo_root is None:
|
if repo_root is None:
|
||||||
repo_root = get_repo_root()
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
@@ -241,14 +304,47 @@ def resolve_task_ref(task_ref: str, repo_root: Path | None = None) -> Path | Non
|
|||||||
if not normalized:
|
if not normalized:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
root = repo_root.resolve()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
path_obj = Path(normalized)
|
path_obj = Path(normalized)
|
||||||
if path_obj.is_absolute():
|
if path_obj.is_absolute():
|
||||||
return path_obj
|
candidate = path_obj
|
||||||
|
elif normalized.startswith(f"{DIR_WORKFLOW}/"):
|
||||||
|
candidate = root / path_obj
|
||||||
|
else:
|
||||||
|
candidate = root / DIR_WORKFLOW / DIR_TASKS / path_obj
|
||||||
|
|
||||||
if normalized.startswith(f"{DIR_WORKFLOW}/"):
|
# resolve() collapses `..` and follows symlinks, so a task directory that
|
||||||
return repo_root / path_obj
|
# links outside the repo is refused too. Both sides are resolved because
|
||||||
|
# repo_root itself may sit behind a symlink (/tmp on macOS does).
|
||||||
|
try:
|
||||||
|
resolved = candidate.resolve()
|
||||||
|
workflow_real = (root / DIR_WORKFLOW).resolve()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj
|
try:
|
||||||
|
resolved.relative_to(root)
|
||||||
|
return resolved
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# `.trellis` may itself be a symlink into a store outside the repo (#567).
|
||||||
|
# The workflow dir's own real location is then a second legitimate
|
||||||
|
# containment base: a ref through that link never left the workflow tree.
|
||||||
|
# A ref that escapes BOTH bases (traversal, absolute path elsewhere, a
|
||||||
|
# task dir symlinked out of the tree) is still refused.
|
||||||
|
try:
|
||||||
|
rel = resolved.relative_to(workflow_real)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Map back to the in-repo (lexical) form so callers store the same
|
||||||
|
# repo-relative ref as in the non-symlinked layout.
|
||||||
|
return root / DIR_WORKFLOW / rel
|
||||||
|
|
||||||
|
|
||||||
def get_current_task(
|
def get_current_task(
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ from __future__ import annotations
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .git import run_git
|
from .git import run_git, run_git_retry_index_lock
|
||||||
from .paths import (
|
from .paths import (
|
||||||
DIR_ARCHIVE,
|
DIR_ARCHIVE,
|
||||||
DIR_TASKS,
|
DIR_TASKS,
|
||||||
@@ -208,7 +208,7 @@ def _stderr_indicates_ignored(stderr: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def safe_git_add(
|
def safe_git_add(
|
||||||
paths: list[str], repo_root: Path
|
paths: list[str], repo_root: Path, retry_on_index_lock: bool = False
|
||||||
) -> tuple[bool, bool, str]:
|
) -> tuple[bool, bool, str]:
|
||||||
"""Run `git add` on specific paths; never retry with -f.
|
"""Run `git add` on specific paths; never retry with -f.
|
||||||
|
|
||||||
@@ -222,11 +222,18 @@ def safe_git_add(
|
|||||||
- Plain fails (any reason — ignored or otherwise) → return failure with
|
- Plain fails (any reason — ignored or otherwise) → return failure with
|
||||||
the stderr. Callers should inspect the stderr (see
|
the stderr. Callers should inspect the stderr (see
|
||||||
:func:`print_gitignore_warning`) and skip the auto-commit.
|
:func:`print_gitignore_warning`) and skip the auto-commit.
|
||||||
|
|
||||||
|
``retry_on_index_lock`` opts into the bounded backoff-retry for a held
|
||||||
|
``.git/index.lock`` (see :func:`~.git.run_git_retry_index_lock`). It is
|
||||||
|
off by default: only the archive path, which has already moved the task
|
||||||
|
directory on disk by the time it stages, needs to wait out a transient
|
||||||
|
lock rather than fail.
|
||||||
"""
|
"""
|
||||||
if not paths:
|
if not paths:
|
||||||
return True, False, ""
|
return True, False, ""
|
||||||
|
|
||||||
rc, _, err = run_git(["add", "--", *paths], cwd=repo_root)
|
runner = run_git_retry_index_lock if retry_on_index_lock else run_git
|
||||||
|
rc, _, err = runner(["add", "--", *paths], cwd=repo_root)
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
return True, False, ""
|
return True, False, ""
|
||||||
return False, False, err
|
return False, False, err
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ Provides:
|
|||||||
get_context_text_record - Text for record mode
|
get_context_text_record - Text for record mode
|
||||||
output_json - Print JSON
|
output_json - Print JSON
|
||||||
output_text - Print text
|
output_text - Print text
|
||||||
|
get_update_hint - Once-per-session "update available" line
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -417,7 +418,15 @@ def _compare_versions(left: str, right: str) -> int | None:
|
|||||||
return _compare_prerelease(left_prerelease, right_prerelease)
|
return _compare_prerelease(left_prerelease, right_prerelease)
|
||||||
|
|
||||||
|
|
||||||
def _update_marker_path(repo_root: Path) -> Path:
|
def _update_marker_path(repo_root: Path, context_key: str | None = None) -> Path:
|
||||||
|
"""Path of the once-per-session marker that throttles the update check.
|
||||||
|
|
||||||
|
`context_key` lets a caller that already resolved session identity pass it
|
||||||
|
in — the SessionStart hook reads the session id from hook stdin, which is
|
||||||
|
more reliable than this function's environment-only fallback chain. Shell
|
||||||
|
entry points leave it None and keep the previous behavior.
|
||||||
|
"""
|
||||||
|
if not context_key:
|
||||||
context_key = resolve_context_key()
|
context_key = resolve_context_key()
|
||||||
if not context_key:
|
if not context_key:
|
||||||
terminal_key = os.environ.get("TERM_SESSION_ID", "").strip()
|
terminal_key = os.environ.get("TERM_SESSION_ID", "").strip()
|
||||||
@@ -433,8 +442,11 @@ def _update_marker_path(repo_root: Path) -> Path:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _mark_update_check_attempted(repo_root: Path) -> bool:
|
def _mark_update_check_attempted(
|
||||||
marker_path = _update_marker_path(repo_root)
|
repo_root: Path,
|
||||||
|
context_key: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
marker_path = _update_marker_path(repo_root, context_key)
|
||||||
if marker_path.exists():
|
if marker_path.exists():
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
@@ -445,8 +457,14 @@ def _mark_update_check_attempted(repo_root: Path) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _get_update_hint(repo_root: Path) -> str | None:
|
def get_update_hint(repo_root: Path, context_key: str | None = None) -> str | None:
|
||||||
marker_path = _update_marker_path(repo_root)
|
"""Return the "update available" line for this session, at most once.
|
||||||
|
|
||||||
|
Public because the SessionStart hook imports it: the text-mode CLI path
|
||||||
|
(`get_context.py`) used to be the only caller, so hook-driven platforms —
|
||||||
|
Claude Code included — never saw the reminder at all.
|
||||||
|
"""
|
||||||
|
marker_path = _update_marker_path(repo_root, context_key)
|
||||||
if marker_path.exists():
|
if marker_path.exists():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -458,7 +476,7 @@ def _get_update_hint(repo_root: Path) -> str | None:
|
|||||||
if not latest_version:
|
if not latest_version:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
_mark_update_check_attempted(repo_root)
|
_mark_update_check_attempted(repo_root, context_key)
|
||||||
comparison = _compare_versions(current_version, latest_version)
|
comparison = _compare_versions(current_version, latest_version)
|
||||||
if comparison is None or comparison >= 0:
|
if comparison is None or comparison >= 0:
|
||||||
return None
|
return None
|
||||||
@@ -867,7 +885,7 @@ def output_text(repo_root: Path | None = None) -> None:
|
|||||||
"""
|
"""
|
||||||
if repo_root is None:
|
if repo_root is None:
|
||||||
repo_root = get_repo_root()
|
repo_root = get_repo_root()
|
||||||
update_hint = _get_update_hint(repo_root)
|
update_hint = get_update_hint(repo_root)
|
||||||
if update_hint:
|
if update_hint:
|
||||||
print(update_hint)
|
print(update_hint)
|
||||||
print("")
|
print("")
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ Provides:
|
|||||||
|
|
||||||
Note:
|
Note:
|
||||||
``cmd_init_context`` was removed in v0.5.0-beta.12. JSONL context files
|
``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
|
are created empty at ``task.py create`` time; the AI agent curates real
|
||||||
``_example`` line; the AI agent curates real entries during planning when
|
entries during planning when the task needs sub-agent/spec context. See
|
||||||
the task needs sub-agent/spec context. See ``.trellis/workflow.md`` for the
|
``.trellis/workflow.md`` for the current planning artifact contract.
|
||||||
current planning artifact contract.
|
|
||||||
|
Older Trellis versions seeded those files with a ``{"_example": ...}``
|
||||||
|
placeholder row. Sub-agent validation rejects that row; explicit inline
|
||||||
|
mode accepts historical seed-only rows without counting them as context.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -21,11 +24,11 @@ import argparse
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .config import get_context_injection_limits
|
from .config import get_codex_dispatch_mode, get_context_injection_limits
|
||||||
from .git import branch_exists_locally
|
from .git import branch_exists_locally
|
||||||
from .io import read_json
|
from .io import read_json
|
||||||
from .log import Colors, colored
|
from .log import Colors, colored
|
||||||
from .paths import FILE_TASK_JSON, get_repo_root
|
from .paths import DIR_ARCHIVE, DIR_TASKS, DIR_WORKFLOW, FILE_TASK_JSON, get_repo_root
|
||||||
from .task_utils import resolve_task_dir
|
from .task_utils import resolve_task_dir
|
||||||
|
|
||||||
# Extensions that look like code rather than spec/research docs. Entries with
|
# Extensions that look like code rather than spec/research docs. Entries with
|
||||||
@@ -60,15 +63,26 @@ def cmd_add_context(args: argparse.Namespace) -> int:
|
|||||||
"""Add entry to JSONL context file."""
|
"""Add entry to JSONL context file."""
|
||||||
repo_root = get_repo_root()
|
repo_root = get_repo_root()
|
||||||
target_dir = resolve_task_dir(args.dir, repo_root)
|
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||||
|
if target_dir is None:
|
||||||
|
return 1
|
||||||
|
|
||||||
jsonl_name = args.file
|
jsonl_name = args.file
|
||||||
path = args.path
|
path = args.path
|
||||||
reason = args.reason or "Added manually"
|
reason = args.reason or "Added manually"
|
||||||
|
|
||||||
if not target_dir.is_dir():
|
if not target_dir or not target_dir.is_dir():
|
||||||
print(colored(f"Error: Directory not found: {target_dir}", Colors.RED))
|
print(colored(f"Error: Directory not found: {target_dir}", Colors.RED))
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
# The JSONL name is user input joined onto the task dir — keep it a plain
|
||||||
|
# filename so it cannot create files elsewhere.
|
||||||
|
if "/" in jsonl_name or "\\" in jsonl_name or jsonl_name in (".", ".."):
|
||||||
|
print(colored(
|
||||||
|
f"Error: context file must be a plain name (e.g. implement, check): {jsonl_name}",
|
||||||
|
Colors.RED,
|
||||||
|
))
|
||||||
|
return 1
|
||||||
|
|
||||||
# Support shorthand
|
# Support shorthand
|
||||||
if not jsonl_name.endswith(".jsonl"):
|
if not jsonl_name.endswith(".jsonl"):
|
||||||
jsonl_name = f"{jsonl_name}.jsonl"
|
jsonl_name = f"{jsonl_name}.jsonl"
|
||||||
@@ -110,12 +124,41 @@ def cmd_add_context(args: argparse.Namespace) -> int:
|
|||||||
# Command: validate
|
# Command: validate
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
def curated_entry_count(jsonl_file: Path) -> int | None:
|
||||||
|
"""Count curated entries in a jsonl context manifest.
|
||||||
|
|
||||||
|
Returns None when the file does not exist — `task.py create` seeds the
|
||||||
|
manifests only on sub-agent-capable platforms, so an absent file means no
|
||||||
|
sub-agent will ever read it and callers should not gate on it. A curated
|
||||||
|
entry is a JSON object row carrying a truthy ``file`` (or legacy ``path``)
|
||||||
|
value: the same rows the sub-agent injection hook materializes.
|
||||||
|
"""
|
||||||
|
if not jsonl_file.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
lines = jsonl_file.read_text(encoding="utf-8").splitlines()
|
||||||
|
except OSError:
|
||||||
|
return 0
|
||||||
|
count = 0
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if isinstance(data, dict) and (data.get("file") or data.get("path")):
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
def cmd_validate(args: argparse.Namespace) -> int:
|
def cmd_validate(args: argparse.Namespace) -> int:
|
||||||
"""Validate JSONL context files."""
|
"""Validate JSONL context files."""
|
||||||
repo_root = get_repo_root()
|
repo_root = get_repo_root()
|
||||||
target_dir = resolve_task_dir(args.dir, repo_root)
|
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||||
|
|
||||||
if not target_dir.is_dir():
|
if target_dir is None or not target_dir.is_dir():
|
||||||
print(colored("Error: task directory required", Colors.RED))
|
print(colored("Error: task directory required", Colors.RED))
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
@@ -170,11 +213,69 @@ def _is_exempt_from_code_file_warning(file_path: str, task_rel: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_context_entry_path(
|
||||||
|
file_path: str, repo_root: Path, task_dir: Path | None
|
||||||
|
) -> Path | None:
|
||||||
|
"""Resolve a JSONL entry, binding archived self-references to the archive copy.
|
||||||
|
|
||||||
|
Exact historical self-references are remapped only for archived tasks.
|
||||||
|
``None`` means the remapped path traversed or resolved outside that archive.
|
||||||
|
"""
|
||||||
|
repo_path = repo_root / file_path
|
||||||
|
try:
|
||||||
|
repo_path.resolve().relative_to(repo_root.resolve())
|
||||||
|
except (OSError, RuntimeError, ValueError):
|
||||||
|
return None
|
||||||
|
if task_dir is None:
|
||||||
|
return repo_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
task_parts = task_dir.resolve().relative_to(repo_root.resolve()).parts
|
||||||
|
except ValueError:
|
||||||
|
return repo_path
|
||||||
|
|
||||||
|
archive_prefix = (DIR_WORKFLOW, DIR_TASKS, DIR_ARCHIVE)
|
||||||
|
if len(task_parts) != 5 or task_parts[:3] != archive_prefix:
|
||||||
|
return repo_path
|
||||||
|
|
||||||
|
year_month = task_parts[3]
|
||||||
|
if (
|
||||||
|
len(year_month) != 7
|
||||||
|
or year_month[4] != "-"
|
||||||
|
or not year_month[:4].isdigit()
|
||||||
|
or not year_month[5:].isdigit()
|
||||||
|
):
|
||||||
|
return repo_path
|
||||||
|
|
||||||
|
historical_root = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_dir.name}"
|
||||||
|
posix_path = file_path.replace("\\", "/")
|
||||||
|
if posix_path == historical_root:
|
||||||
|
relative_parts: tuple[str, ...] = ()
|
||||||
|
elif posix_path.startswith(f"{historical_root}/"):
|
||||||
|
relative_path = posix_path[len(historical_root) + 1 :]
|
||||||
|
if relative_path.endswith("/"):
|
||||||
|
relative_path = relative_path[:-1]
|
||||||
|
relative_parts = tuple(relative_path.split("/")) if relative_path else ()
|
||||||
|
if any(part in ("", ".", "..") for part in relative_parts):
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return repo_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
archive_root = task_dir.resolve()
|
||||||
|
resolved_path = task_dir.joinpath(*relative_parts).resolve()
|
||||||
|
resolved_path.relative_to(archive_root)
|
||||||
|
except (OSError, RuntimeError, ValueError):
|
||||||
|
return None
|
||||||
|
return resolved_path
|
||||||
|
|
||||||
|
|
||||||
def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = None) -> int:
|
def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = None) -> int:
|
||||||
"""Validate a single JSONL file.
|
"""Validate a single JSONL file.
|
||||||
|
|
||||||
Seed rows (no ``file`` field — typically ``{"_example": "..."}``) are
|
Historical seed-only rows are accepted in explicit inline mode. Sub-agent
|
||||||
skipped silently; they are self-describing comments, not real entries.
|
mode rejects scaffolding and empty manifests. Real entries are validated
|
||||||
|
in both modes.
|
||||||
|
|
||||||
Beyond hard errors (missing file/dir, invalid JSON), this also prints
|
Beyond hard errors (missing file/dir, invalid JSON), this also prints
|
||||||
non-blocking hygiene warnings (never counted in ``errors``, never change
|
non-blocking hygiene warnings (never counted in ``errors``, never change
|
||||||
@@ -184,6 +285,7 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N
|
|||||||
"""
|
"""
|
||||||
file_name = jsonl_file.name
|
file_name = jsonl_file.name
|
||||||
errors = 0
|
errors = 0
|
||||||
|
inline = get_codex_dispatch_mode(repo_root) == "inline"
|
||||||
|
|
||||||
if not jsonl_file.is_file():
|
if not jsonl_file.is_file():
|
||||||
print(f" {colored(f'{file_name}: not found (skipped)', Colors.YELLOW)}")
|
print(f" {colored(f'{file_name}: not found (skipped)', Colors.YELLOW)}")
|
||||||
@@ -212,22 +314,59 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N
|
|||||||
errors += 1
|
errors += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
print(
|
||||||
|
f" {colored(f'{file_name}:{line_num}: Expected a JSON object', Colors.RED)}"
|
||||||
|
)
|
||||||
|
errors += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if inline and set(data) == {"_example"}:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "_example" in data:
|
||||||
|
error_message = (
|
||||||
|
f"{file_name}:{line_num}: Placeholder `_example` row left by an older "
|
||||||
|
"task.py create — delete this line, or replace it with "
|
||||||
|
'{"file": "<path>", "reason": "<why>"}'
|
||||||
|
)
|
||||||
|
print(f" {colored(error_message, Colors.RED)}")
|
||||||
|
errors += 1
|
||||||
|
continue
|
||||||
|
|
||||||
file_path = data.get("file")
|
file_path = data.get("file")
|
||||||
entry_type = data.get("type", "file")
|
entry_type = data.get("type", "file")
|
||||||
|
|
||||||
if not file_path:
|
if not file_path:
|
||||||
# Seed / comment row — skip silently
|
if "file" in data or "path" in data or "reason" in data:
|
||||||
|
print(colored(f"{file_name}:{line_num}: file must be a non-empty string path", Colors.RED))
|
||||||
|
errors += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(file_path, str):
|
||||||
|
# A truthy non-string (e.g. {"file": 1}) reached path joining and
|
||||||
|
# raised TypeError, so validation crashed on the row it exists to
|
||||||
|
# report.
|
||||||
|
print(
|
||||||
|
f" {colored(f'{file_name}:{line_num}: `file` must be a string path', Colors.RED)}"
|
||||||
|
)
|
||||||
|
errors += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(data.get("reason"), str) or not data["reason"].strip():
|
||||||
|
print(colored(f"{file_name}:{line_num}: reason must be a non-empty string", Colors.RED))
|
||||||
|
errors += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
real_entries += 1
|
real_entries += 1
|
||||||
full_path = repo_root / file_path
|
full_path = _resolve_context_entry_path(file_path, repo_root, task_dir)
|
||||||
if entry_type == "directory":
|
if entry_type == "directory":
|
||||||
if not full_path.is_dir():
|
if full_path is None or not full_path.is_dir():
|
||||||
print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}")
|
print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}")
|
||||||
errors += 1
|
errors += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not full_path.is_file():
|
if full_path is None or not full_path.is_file():
|
||||||
print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}")
|
print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}")
|
||||||
errors += 1
|
errors += 1
|
||||||
continue
|
continue
|
||||||
@@ -244,8 +383,14 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N
|
|||||||
print(f" {colored(warning_message, Colors.YELLOW)}")
|
print(f" {colored(warning_message, Colors.YELLOW)}")
|
||||||
|
|
||||||
if max_file_bytes:
|
if max_file_bytes:
|
||||||
size = full_path.stat().st_size
|
# Advisory hygiene warning, so it must never be what fails
|
||||||
if size > max_file_bytes:
|
# `validate`. `stat()` can still raise after the `is_file()` check
|
||||||
|
# above — a permission change, or the file removed in between.
|
||||||
|
try:
|
||||||
|
size: int | None = full_path.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
size = None
|
||||||
|
if size is not None and size > max_file_bytes:
|
||||||
warning_message = (
|
warning_message = (
|
||||||
f"{file_name}:{line_num}: Warning: {file_path} is {size} bytes, "
|
f"{file_name}:{line_num}: Warning: {file_path} is {size} bytes, "
|
||||||
f"exceeds context_injection.max_file_bytes ({max_file_bytes}); "
|
f"exceeds context_injection.max_file_bytes ({max_file_bytes}); "
|
||||||
@@ -253,6 +398,22 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N
|
|||||||
)
|
)
|
||||||
print(f" {colored(warning_message, Colors.YELLOW)}")
|
print(f" {colored(warning_message, Colors.YELLOW)}")
|
||||||
|
|
||||||
|
if errors == 0 and real_entries == 0 and not inline:
|
||||||
|
# Seed-only / empty manifest: sub-agents dispatched for this task
|
||||||
|
# would run with zero spec context (#573). Silent-green here is how
|
||||||
|
# more than half the tasks in the report ended up uncurated.
|
||||||
|
action = file_name.split(".", 1)[0]
|
||||||
|
print(
|
||||||
|
f" {colored(f'{file_name}: ✗ (0 curated entries — sub-agents would get zero spec context)', Colors.RED)}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" Curate it: python3 .trellis/scripts/task.py add-context <task> {action} <path> \"<why>\""
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
" Intentionally empty? Bypass at start: task.py start <task> --allow-empty-context"
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
if errors == 0:
|
if errors == 0:
|
||||||
print(f" {colored(f'{file_name}: ✓ ({real_entries} entries)', Colors.GREEN)}")
|
print(f" {colored(f'{file_name}: ✓ ({real_entries} entries)', Colors.GREEN)}")
|
||||||
else:
|
else:
|
||||||
@@ -270,7 +431,7 @@ def cmd_list_context(args: argparse.Namespace) -> int:
|
|||||||
repo_root = get_repo_root()
|
repo_root = get_repo_root()
|
||||||
target_dir = resolve_task_dir(args.dir, repo_root)
|
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||||
|
|
||||||
if not target_dir.is_dir():
|
if target_dir is None or not target_dir.is_dir():
|
||||||
print(colored("Error: task directory required", Colors.RED))
|
print(colored("Error: task directory required", Colors.RED))
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
@@ -285,7 +446,7 @@ def cmd_list_context(args: argparse.Namespace) -> int:
|
|||||||
print(colored(f"[{jsonl_name}]", Colors.CYAN))
|
print(colored(f"[{jsonl_name}]", Colors.CYAN))
|
||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
seed_only = True
|
curated = False
|
||||||
for line in jsonl_file.read_text(encoding="utf-8").splitlines():
|
for line in jsonl_file.read_text(encoding="utf-8").splitlines():
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
@@ -295,11 +456,14 @@ def cmd_list_context(args: argparse.Namespace) -> int:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
file_path = data.get("file")
|
file_path = data.get("file")
|
||||||
if not file_path:
|
if not file_path:
|
||||||
# Seed / comment row — don't count as a real entry
|
# Placeholder / comment row — don't count as a real entry
|
||||||
continue
|
continue
|
||||||
seed_only = False
|
curated = True
|
||||||
|
|
||||||
count += 1
|
count += 1
|
||||||
entry_type = data.get("type", "file")
|
entry_type = data.get("type", "file")
|
||||||
@@ -311,8 +475,8 @@ def cmd_list_context(args: argparse.Namespace) -> int:
|
|||||||
print(f" {colored(f'{count}.', Colors.GREEN)} {file_path}")
|
print(f" {colored(f'{count}.', Colors.GREEN)} {file_path}")
|
||||||
print(f" {colored('→', Colors.YELLOW)} {reason}")
|
print(f" {colored('→', Colors.YELLOW)} {reason}")
|
||||||
|
|
||||||
if seed_only:
|
if not curated:
|
||||||
print(f" {colored('(no curated entries yet — only seed row)', Colors.YELLOW)}")
|
print(f" {colored('(no curated entries yet)', Colors.YELLOW)}")
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
|||||||
+1034
-105
文件差异内容过多而无法显示
加载差异
@@ -3,9 +3,10 @@
|
|||||||
Task utility functions.
|
Task utility functions.
|
||||||
|
|
||||||
Provides:
|
Provides:
|
||||||
is_safe_task_path - Validate task path is safe to operate on
|
is_within_tasks_dir - Check a resolved path is a task directly under tasks/
|
||||||
find_task_by_name - Find task directory by name
|
find_task_by_name - Find task directory by name
|
||||||
resolve_task_dir - Resolve task directory from name, relative, or absolute path
|
resolve_task_dir - Resolve task directory from name, relative, or absolute path
|
||||||
|
archive_destination_for - Path a task would be archived to
|
||||||
archive_task_dir - Archive task to monthly directory
|
archive_task_dir - Archive task to monthly directory
|
||||||
run_task_hooks - Run lifecycle hooks for task events
|
run_task_hooks - Run lifecycle hooks for task events
|
||||||
"""
|
"""
|
||||||
@@ -16,70 +17,28 @@ import shutil
|
|||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from .paths import get_repo_root, get_tasks_dir
|
from .paths import get_repo_root, get_tasks_dir
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Path Safety
|
# 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:
|
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.
|
"""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
|
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.
|
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
|
Narrows ``resolve_task_dir``'s containment for archive: that chokepoint
|
||||||
an unknown name, so a mistyped ``task.py archive src`` resolves to the real
|
accepts anything under the tasks dir, including a task already in
|
||||||
``src/`` source directory. Without this check archive would ``shutil.move``
|
``archive/<YYYY-MM>/``. This rejects those, plus the tasks dir itself, so
|
||||||
it out of the repo. Also rejects the tasks dir itself and anything nested
|
``shutil.move`` never re-archives an archived task into a nested copy.
|
||||||
under ``archive/`` (already-archived tasks).
|
|
||||||
"""
|
"""
|
||||||
if repo_root is None:
|
if repo_root is None:
|
||||||
repo_root = get_repo_root()
|
repo_root = get_repo_root()
|
||||||
@@ -100,25 +59,46 @@ def is_within_tasks_dir(task_dir_abs: Path, repo_root: Path | None = None) -> bo
|
|||||||
def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None:
|
def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None:
|
||||||
"""Find task directory by name (exact or suffix match).
|
"""Find task directory by name (exact or suffix match).
|
||||||
|
|
||||||
|
A task name is a single directory name under ``tasks_dir``, never a path:
|
||||||
|
names carrying a separator or a dot segment are rejected before the join,
|
||||||
|
so ``".."`` cannot hand back the tasks dir's own parent.
|
||||||
|
|
||||||
|
An ambiguous suffix (two tasks created on different days with the same
|
||||||
|
slug) is a failure, not a coin flip — ``iterdir()`` order is filesystem
|
||||||
|
order, so silently picking the first match picks a different task on a
|
||||||
|
different machine.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
task_name: Task name to find.
|
task_name: Task name to find.
|
||||||
tasks_dir: Tasks directory path.
|
tasks_dir: Tasks directory path.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Absolute path to task directory, or None if not found.
|
Absolute path to task directory, or None if not found or ambiguous.
|
||||||
"""
|
"""
|
||||||
if not task_name or not tasks_dir or not tasks_dir.is_dir():
|
if not task_name or not tasks_dir or not tasks_dir.is_dir():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if "/" in task_name or "\\" in task_name or task_name in (".", ".."):
|
||||||
|
print(f"Error: invalid task name: {task_name}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
# Try exact match first
|
# Try exact match first
|
||||||
exact_match = tasks_dir / task_name
|
exact_match = tasks_dir / task_name
|
||||||
if exact_match.is_dir():
|
if exact_match.is_dir():
|
||||||
return exact_match
|
return exact_match
|
||||||
|
|
||||||
# Try suffix match (e.g., "my-task" matches "01-21-my-task")
|
# Try suffix match (e.g., "my-task" matches "01-21-my-task")
|
||||||
for d in tasks_dir.iterdir():
|
matches = sorted(
|
||||||
if d.is_dir() and d.name.endswith(f"-{task_name}"):
|
d for d in tasks_dir.iterdir()
|
||||||
return d
|
if d.is_dir() and d.name.endswith(f"-{task_name}")
|
||||||
|
)
|
||||||
|
if len(matches) == 1:
|
||||||
|
return matches[0]
|
||||||
|
if matches:
|
||||||
|
print(f"Error: ambiguous task name '{task_name}' matches:", file=sys.stderr)
|
||||||
|
for match in matches:
|
||||||
|
print(f" - {match.name}", file=sys.stderr)
|
||||||
|
print("Pass the full task directory name.", file=sys.stderr)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -127,6 +107,13 @@ def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None:
|
|||||||
# Archive Operations
|
# Archive Operations
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
def archive_destination_for(task_dir_abs: Path) -> Path:
|
||||||
|
"""Path a task would be archived to: <tasks>/archive/<YYYY-MM>/<name>."""
|
||||||
|
tasks_dir = task_dir_abs.parent
|
||||||
|
year_month = datetime.now().strftime("%Y-%m")
|
||||||
|
return tasks_dir / "archive" / year_month / task_dir_abs.name
|
||||||
|
|
||||||
|
|
||||||
def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path | None:
|
def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path | None:
|
||||||
"""Archive a task directory to archive/{YYYY-MM}/.
|
"""Archive a task directory to archive/{YYYY-MM}/.
|
||||||
|
|
||||||
@@ -141,11 +128,8 @@ def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path
|
|||||||
print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr)
|
print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Get tasks directory (parent of the task)
|
dest = archive_destination_for(task_dir_abs)
|
||||||
tasks_dir = task_dir_abs.parent
|
month_dir = dest.parent
|
||||||
archive_dir = tasks_dir / "archive"
|
|
||||||
year_month = datetime.now().strftime("%Y-%m")
|
|
||||||
month_dir = archive_dir / year_month
|
|
||||||
|
|
||||||
# Create archive directory
|
# Create archive directory
|
||||||
try:
|
try:
|
||||||
@@ -154,9 +138,22 @@ def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path
|
|||||||
print(f"Error: Failed to create archive directory: {e}", file=sys.stderr)
|
print(f"Error: Failed to create archive directory: {e}", file=sys.stderr)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Move task to archive
|
# shutil.move into an existing directory moves the source *inside* it,
|
||||||
task_name = task_dir_abs.name
|
# producing archive/<month>/<task>/<task>/ and returning a path that is not
|
||||||
dest = month_dir / task_name
|
# where the task actually landed. That wrong path then flows into the
|
||||||
|
# printed result, the after_archive hook's TASK_JSON_PATH, and auto-commit
|
||||||
|
# staging, so refuse before the move instead.
|
||||||
|
if dest.exists():
|
||||||
|
print(
|
||||||
|
f"Error: refusing to archive {task_dir_abs}: "
|
||||||
|
f"archive destination already exists: {dest}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"Move or rename the existing archived task, then retry.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
shutil.move(str(task_dir_abs), str(dest))
|
shutil.move(str(task_dir_abs), str(dest))
|
||||||
@@ -195,53 +192,210 @@ def archive_task_complete(
|
|||||||
# Task Directory Resolution
|
# Task Directory Resolution
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
def resolve_task_dir(target_dir: str, repo_root: Path) -> Path:
|
def resolve_task_dir(target_dir: str, repo_root: Path) -> Path | None:
|
||||||
"""Resolve task directory to absolute path.
|
"""Resolve task directory to an absolute path inside the tasks directory.
|
||||||
|
|
||||||
Supports:
|
Supports:
|
||||||
- Absolute path: /path/to/task
|
- Absolute path: /path/to/task
|
||||||
- Relative path: .trellis/tasks/01-31-my-task
|
- Relative path: .trellis/tasks/01-31-my-task
|
||||||
- Task name: my-task (uses find_task_by_name for lookup)
|
- Task name: my-task (uses find_task_by_name for lookup)
|
||||||
|
|
||||||
|
This is the containment chokepoint for every command that accepts a task
|
||||||
|
directory argument. The candidate is resolved (following symlinks) and must
|
||||||
|
land strictly under ``.trellis/tasks/``; archived tasks under
|
||||||
|
``archive/<YYYY-MM>/`` qualify. Traversal (``../victim``), absolute paths
|
||||||
|
outside the repo, a task dir symlinked out of the tasks tree, and the
|
||||||
|
tasks directory itself are all rejected here so no caller has to re-check.
|
||||||
|
The containment base is the tasks dir's own real location, so a
|
||||||
|
``.trellis`` that is itself a symlink into a shared store (#567) works.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
target_dir: Task directory specification.
|
target_dir: Task directory specification.
|
||||||
repo_root: Repository root path.
|
repo_root: Repository root path.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Resolved absolute path.
|
Absolute path spelled through the repo's own tasks dir (in-repo
|
||||||
|
lexical form), or None when it is not a location inside the tasks
|
||||||
|
directory (an error naming the path is printed to stderr).
|
||||||
"""
|
"""
|
||||||
if not target_dir:
|
if not target_dir:
|
||||||
return Path()
|
print("Error: task directory is required", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
normalized = target_dir.replace("\\", "/")
|
normalized = target_dir.replace("\\", "/")
|
||||||
while normalized.startswith("./"):
|
while normalized.startswith("./"):
|
||||||
normalized = normalized[2:]
|
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)
|
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
|
if Path(target_dir).is_absolute():
|
||||||
return repo_root / Path(normalized)
|
candidate = Path(target_dir)
|
||||||
|
elif "/" in normalized or normalized.startswith(".trellis"):
|
||||||
|
# Relative path (contains path separator or starts with .trellis)
|
||||||
|
candidate = repo_root / Path(normalized)
|
||||||
|
else:
|
||||||
|
# Task name - must resolve inside the tasks directory. The historical
|
||||||
|
# fallback to repo_root/<name> only ever produced a path the check
|
||||||
|
# below rejects, so a miss ends here instead.
|
||||||
|
candidate = find_task_by_name(target_dir, tasks_dir)
|
||||||
|
if candidate is None:
|
||||||
|
# find_task_by_name reports invalid names and ambiguity itself.
|
||||||
|
print(
|
||||||
|
f"Error: could not resolve task '{target_dir}' under {tasks_dir}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved = candidate.resolve()
|
||||||
|
tasks_lexical = get_tasks_dir(repo_root.resolve())
|
||||||
|
tasks_resolved = tasks_lexical.resolve()
|
||||||
|
except (OSError, RuntimeError) as e:
|
||||||
|
print(f"Error: could not resolve task directory '{target_dir}': {e}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if resolved == tasks_resolved:
|
||||||
|
print(
|
||||||
|
f"Error: refusing to use '{target_dir}': {tasks_resolved} is the tasks "
|
||||||
|
"directory itself, not a task",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if tasks_resolved not in resolved.parents:
|
||||||
|
print(
|
||||||
|
f"Error: refusing to use '{target_dir}': {resolved} is outside {tasks_resolved}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Hand back the path spelled through the repo's own tasks dir rather than
|
||||||
|
# `resolved`: `.trellis` may be a symlink into a store outside the repo
|
||||||
|
# (#567), in which case `resolved` sits outside the repo even though the
|
||||||
|
# task is legitimate, and callers converting to a repo-relative ref for
|
||||||
|
# storage would refuse it.
|
||||||
|
return tasks_lexical / resolved.relative_to(tasks_resolved)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Lifecycle Hooks
|
# Lifecycle Hooks
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
HOOK_TIMEOUT_SECONDS = 60
|
||||||
|
HOOK_KILL_GRACE_SECONDS = 5
|
||||||
|
HOOK_OUTPUT_LIMIT = 2000
|
||||||
|
|
||||||
|
|
||||||
|
def _kill_hook_tree(proc: subprocess.Popen[str]) -> None:
|
||||||
|
"""Kill a timed-out hook's whole process tree, not just the shell.
|
||||||
|
|
||||||
|
With ``shell=True`` the direct child is the shell; the actual work runs in
|
||||||
|
its children. Killing only the shell leaves grandchildren alive holding the
|
||||||
|
inherited stdout/stderr pipes, and collecting output afterwards then blocks
|
||||||
|
until those orphans exit — the exact "command that never returns" the
|
||||||
|
timeout exists to prevent.
|
||||||
|
|
||||||
|
POSIX: the hook is started with ``start_new_session=True``, so the shell and
|
||||||
|
every descendant share one fresh process group; SIGKILL the group.
|
||||||
|
Windows: ``taskkill /F /T`` walks the tree (best effort).
|
||||||
|
Either way ``proc.kill()`` is the fallback.
|
||||||
|
|
||||||
|
Limitation: a hook that calls ``setsid`` itself leaves the group and
|
||||||
|
survives this. Accepted and out of scope — a hook is arbitrary code, and the
|
||||||
|
timeout is a liveness guarantee, not a containment boundary.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
if os.name == "posix":
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||||
|
return
|
||||||
|
except (ProcessLookupError, PermissionError, OSError):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _release_hook_process(proc: subprocess.Popen[str]) -> None:
|
||||||
|
"""Close our pipe ends and reap a finished hook under a bound.
|
||||||
|
|
||||||
|
``Popen`` used as a context manager calls ``wait()`` with no timeout when
|
||||||
|
the block exits. That is the one place the liveness guarantee could still
|
||||||
|
be lost: if ``_kill_hook_tree`` failed outright — both the process-group
|
||||||
|
kill and the direct kill raising — the lifecycle command would block there
|
||||||
|
forever, which is the exact hang the timeout exists to prevent.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
for stream in (proc.stdout, proc.stderr, proc.stdin):
|
||||||
|
if stream is not None:
|
||||||
|
try:
|
||||||
|
stream.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=HOOK_KILL_GRACE_SECONDS)
|
||||||
|
except (subprocess.TimeoutExpired, OSError, ValueError):
|
||||||
|
# Leaves a zombie until this process exits. Fail-open and bounded beats
|
||||||
|
# a task command that never returns.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_hook_output(raw: object) -> str:
|
||||||
|
"""TimeoutExpired carries bytes or str depending on the platform."""
|
||||||
|
if raw is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(raw, bytes):
|
||||||
|
return raw.decode("utf-8", errors="replace")
|
||||||
|
return str(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_hook_stream(name: str, text: str) -> None:
|
||||||
|
"""Print a captured hook stream to stderr, truncated."""
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
if len(text) > HOOK_OUTPUT_LIMIT:
|
||||||
|
text = (
|
||||||
|
text[:HOOK_OUTPUT_LIMIT]
|
||||||
|
+ f"\n… ({len(text) - HOOK_OUTPUT_LIMIT} more characters truncated)"
|
||||||
|
)
|
||||||
|
print(f" {name}:", file=sys.stderr)
|
||||||
|
for line in text.splitlines():
|
||||||
|
print(f" {line}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
def run_task_hooks(event: str, task_json_path: Path, repo_root: Path) -> None:
|
def run_task_hooks(event: str, task_json_path: Path, repo_root: Path) -> None:
|
||||||
"""Run lifecycle hooks for a task event.
|
"""Run lifecycle hooks for a task event.
|
||||||
|
|
||||||
|
Hooks are shell commands read from ``.trellis/config.yaml`` and executed
|
||||||
|
with ``shell=True`` from the repo root — see the trust boundary note in
|
||||||
|
``.trellis/spec/cli/backend/script-conventions.md``.
|
||||||
|
|
||||||
|
Fail-open by design: a broken hook warns and the lifecycle command
|
||||||
|
continues. The warning names the event, command, exit status, and both
|
||||||
|
captured streams, because a hook whose only symptom is "nothing happened"
|
||||||
|
is undebuggable. A hook that hangs is bounded by
|
||||||
|
``HOOK_TIMEOUT_SECONDS``; output is captured, so without the timeout the
|
||||||
|
user sees a task command that never returns and prints nothing. On timeout
|
||||||
|
the hook's entire process tree is killed (see ``_kill_hook_tree``) and
|
||||||
|
output is collected under a bounded grace, so a surviving grandchild
|
||||||
|
holding the pipes cannot re-create that same hang. Cleanup is bounded for
|
||||||
|
the same reason — see ``_release_hook_process``.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
event: Event name (e.g. "after_create").
|
event: Event name (e.g. "after_create").
|
||||||
task_json_path: Absolute path to the task's task.json.
|
task_json_path: Absolute path to the task's task.json.
|
||||||
@@ -259,28 +413,74 @@ def run_task_hooks(event: str, task_json_path: Path, repo_root: Path) -> None:
|
|||||||
|
|
||||||
env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)}
|
env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)}
|
||||||
|
|
||||||
|
# POSIX only: a fresh session puts the shell and every descendant in one
|
||||||
|
# process group, which is what makes the timeout kill the whole tree.
|
||||||
|
popen_kwargs: dict = {}
|
||||||
|
if os.name == "posix":
|
||||||
|
popen_kwargs["start_new_session"] = True
|
||||||
|
|
||||||
for cmd in commands:
|
for cmd in commands:
|
||||||
|
proc = None
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
proc = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
shell=True,
|
shell=True,
|
||||||
cwd=repo_root,
|
cwd=repo_root,
|
||||||
env=env,
|
env=env,
|
||||||
capture_output=True,
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
errors="replace",
|
errors="replace",
|
||||||
|
**popen_kwargs,
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
try:
|
||||||
|
stdout, stderr = proc.communicate(timeout=HOOK_TIMEOUT_SECONDS)
|
||||||
|
except subprocess.TimeoutExpired as e:
|
||||||
|
_kill_hook_tree(proc)
|
||||||
|
stdout = _decode_hook_output(e.stdout)
|
||||||
|
stderr = _decode_hook_output(e.stderr)
|
||||||
|
try:
|
||||||
|
# Bounded: an orphan that escaped the kill still holds
|
||||||
|
# the pipes, and waiting on it forever here would be
|
||||||
|
# the very hang the timeout prevents.
|
||||||
|
rest_out, rest_err = proc.communicate(
|
||||||
|
timeout=HOOK_KILL_GRACE_SECONDS
|
||||||
|
)
|
||||||
|
stdout = rest_out or stdout
|
||||||
|
stderr = rest_err or stderr
|
||||||
|
except (subprocess.TimeoutExpired, OSError, ValueError):
|
||||||
|
pass
|
||||||
print(
|
print(
|
||||||
colored(f"[WARN] Hook failed ({event}): {cmd}", Colors.YELLOW),
|
colored(
|
||||||
|
f"[WARN] Hook timed out ({event}) after "
|
||||||
|
f"{HOOK_TIMEOUT_SECONDS}s: {cmd}",
|
||||||
|
Colors.YELLOW,
|
||||||
|
),
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
if result.stderr.strip():
|
print(f" cwd: {repo_root}", file=sys.stderr)
|
||||||
print(f" {result.stderr.strip()}", file=sys.stderr)
|
_print_hook_stream("stdout", stdout)
|
||||||
|
_print_hook_stream("stderr", stderr)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
f"[WARN] Hook failed ({event}): exit {proc.returncode}: {cmd}",
|
||||||
|
Colors.YELLOW,
|
||||||
|
),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(f" cwd: {repo_root}", file=sys.stderr)
|
||||||
|
_print_hook_stream("stdout", stdout or "")
|
||||||
|
_print_hook_stream("stderr", stderr or "")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(
|
print(
|
||||||
colored(f"[WARN] Hook error ({event}): {cmd} — {e}", Colors.YELLOW),
|
colored(
|
||||||
|
f"[WARN] Hook error ({event}): {cmd} — {type(e).__name__}: {e}",
|
||||||
|
Colors.YELLOW,
|
||||||
|
),
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -294,5 +494,5 @@ if __name__ == "__main__":
|
|||||||
tasks = get_tasks_dir(repo)
|
tasks = get_tasks_dir(repo)
|
||||||
|
|
||||||
print(f"Tasks dir: {tasks}")
|
print(f"Tasks dir: {tasks}")
|
||||||
print(f"is_safe_task_path('.trellis/tasks/test'): {is_safe_task_path('.trellis/tasks/test', repo)}")
|
print(f"resolve_task_dir('.trellis/tasks/test'): {resolve_task_dir('.trellis/tasks/test', repo)}")
|
||||||
print(f"is_safe_task_path('../test'): {is_safe_task_path('../test', repo)}")
|
print(f"resolve_task_dir('../test'): {resolve_task_dir('../test', repo)}")
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ Provides:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .io import read_json
|
from .io import describe_json_read_failure, read_json_checked
|
||||||
from .paths import FILE_TASK_JSON
|
from .paths import FILE_TASK_JSON
|
||||||
from .types import TaskInfo
|
from .types import TaskInfo
|
||||||
|
|
||||||
@@ -28,13 +29,22 @@ def load_task(task_dir: Path) -> TaskInfo | None:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
TaskInfo if task.json exists and is valid, None otherwise.
|
TaskInfo if task.json exists and is valid, None otherwise.
|
||||||
|
|
||||||
|
A directory without task.json is not a task, so it is skipped silently.
|
||||||
|
A task.json that exists but cannot be loaded is different: the task
|
||||||
|
disappears from `task.py list` and from every context the iterator feeds.
|
||||||
|
Callers stay tolerant, but the skip is announced on stderr so a task
|
||||||
|
cannot vanish from the workflow with no diagnostic anywhere.
|
||||||
"""
|
"""
|
||||||
task_json = task_dir / FILE_TASK_JSON
|
task_json = task_dir / FILE_TASK_JSON
|
||||||
if not task_json.is_file():
|
if not task_json.is_file():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
data = read_json(task_json)
|
data, reason = read_json_checked(task_json)
|
||||||
if not data:
|
if data is None:
|
||||||
|
problem, hint = describe_json_read_failure(task_json, reason)
|
||||||
|
print(f"[WARN] Skipping task '{task_dir.name}': {problem}", file=sys.stderr)
|
||||||
|
print(f" {hint}", file=sys.stderr)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return TaskInfo(
|
return TaskInfo(
|
||||||
|
|||||||
@@ -2,13 +2,23 @@
|
|||||||
"""
|
"""
|
||||||
Standalone reader for .trellis/config.yaml.
|
Standalone reader for .trellis/config.yaml.
|
||||||
|
|
||||||
Mirrors a minimal subset of common.config so callers (hooks, workflow_phase)
|
Owns the minimal YAML parser used across Trellis. ``common.config`` imports
|
||||||
can read configuration without importing the full task/repo helpers. Returns
|
``parse_simple_yaml`` from here rather than keeping its own copy: this module
|
||||||
an empty dict on missing/malformed files so callers stay simple.
|
imports nothing from the package, so hooks can load it as a single file, and
|
||||||
|
one parser cannot drift from another. Returns an empty dict on
|
||||||
|
missing/malformed files so callers stay simple.
|
||||||
|
|
||||||
|
Supported subset: ``key: value`` scalars (everything is a string), nested
|
||||||
|
mappings by indentation, ``- `` lists of scalars, ``#`` comments (whole-line
|
||||||
|
and inline outside quotes), and one layer of matching surrounding quotes.
|
||||||
|
Constructs outside that subset — block scalars, anchors, aliases, merge keys,
|
||||||
|
flow collections, and mappings nested inside a list — are reported on stderr
|
||||||
|
and skipped rather than parsed into a plausible-looking wrong value.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -52,11 +62,62 @@ def _next_content_line(lines: list[str], start: int) -> tuple[int, str]:
|
|||||||
return i, ""
|
return i, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_unsupported(source: str, lineno: int, line: str, reason: str) -> None:
|
||||||
|
"""Report a YAML construct this parser cannot represent, and move on."""
|
||||||
|
print(
|
||||||
|
f"[WARN] {source}:{lineno}: {reason}; ignoring: {line.strip()}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_block_scalar(value: str) -> bool:
|
||||||
|
"""True for ``|``, ``>`` and their chomping/indent indicators (``|-``, ``>2``)."""
|
||||||
|
if not value or value[0] not in ("|", ">"):
|
||||||
|
return False
|
||||||
|
return all(ch in "+-0123456789" for ch in value[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def _unsupported_value(key: str, value: str) -> str | None:
|
||||||
|
"""Name the unsupported construct in an unquoted scalar value, else None.
|
||||||
|
|
||||||
|
Only unquoted values are inspected: ``cmd: "[a] | b"`` is a string the user
|
||||||
|
wrote deliberately, while a bare ``notes: |`` or ``base: *anchor`` would
|
||||||
|
otherwise be stored as the literal marker with the real content dropped.
|
||||||
|
"""
|
||||||
|
if key == "<<":
|
||||||
|
return "YAML merge keys are not supported"
|
||||||
|
if _is_block_scalar(value):
|
||||||
|
return "block scalars are not supported"
|
||||||
|
if value.startswith("&"):
|
||||||
|
return "YAML anchors are not supported"
|
||||||
|
if value.startswith("*"):
|
||||||
|
return "YAML aliases are not supported"
|
||||||
|
if value.startswith("["):
|
||||||
|
return "flow sequences are not supported (use `- ` list items)"
|
||||||
|
if value.startswith("{"):
|
||||||
|
return "flow mappings are not supported (use an indented mapping)"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _skip_indented_body(lines: list[str], start: int, indent: int) -> int:
|
||||||
|
"""Skip the continuation lines of a rejected key (block scalar body etc.)."""
|
||||||
|
i = start
|
||||||
|
while i < len(lines):
|
||||||
|
stripped = lines[i].strip()
|
||||||
|
if stripped and len(lines[i]) - len(lines[i].lstrip()) <= indent:
|
||||||
|
break
|
||||||
|
i += 1
|
||||||
|
return i
|
||||||
|
|
||||||
|
|
||||||
def _parse_yaml_block(
|
def _parse_yaml_block(
|
||||||
lines: list[str], start: int, min_indent: int, target: dict
|
lines: list[str], start: int, min_indent: int, target: dict, source: str
|
||||||
) -> int:
|
) -> int:
|
||||||
i = start
|
i = start
|
||||||
current_list: list | None = None
|
current_list: list | None = None
|
||||||
|
# Indent of the key that opened current_list, so a deeper `key: value`
|
||||||
|
# can be recognized as a mapping inside that list.
|
||||||
|
list_owner_indent = 0
|
||||||
|
|
||||||
while i < len(lines):
|
while i < len(lines):
|
||||||
line = lines[i]
|
line = lines[i]
|
||||||
@@ -75,10 +136,33 @@ def _parse_yaml_block(
|
|||||||
current_list.append(_unquote(stripped[2:].strip()))
|
current_list.append(_unquote(stripped[2:].strip()))
|
||||||
i += 1
|
i += 1
|
||||||
elif ":" in stripped:
|
elif ":" in stripped:
|
||||||
|
if current_list is not None and indent > list_owner_indent:
|
||||||
|
# `- name: cli` / ` path: x`: the second key belongs to a
|
||||||
|
# mapping inside the list. Storing it would hoist it into the
|
||||||
|
# parent dict as a sibling of the list — a nested key silently
|
||||||
|
# becoming a root key.
|
||||||
|
_warn_unsupported(
|
||||||
|
source,
|
||||||
|
i + 1,
|
||||||
|
line,
|
||||||
|
"mappings inside a list are not supported",
|
||||||
|
)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
key, _, value = stripped.partition(":")
|
key, _, value = stripped.partition(":")
|
||||||
key = key.strip()
|
key = key.strip()
|
||||||
value = _strip_inline_comment(value).strip()
|
value = _strip_inline_comment(value).strip()
|
||||||
was_quoted = len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'")
|
was_quoted = len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'")
|
||||||
|
|
||||||
|
if not was_quoted:
|
||||||
|
reason = _unsupported_value(key, value)
|
||||||
|
if reason is not None:
|
||||||
|
_warn_unsupported(source, i + 1, line, reason)
|
||||||
|
current_list = None
|
||||||
|
i = _skip_indented_body(lines, i + 1, indent)
|
||||||
|
continue
|
||||||
|
|
||||||
value = _unquote(value)
|
value = _unquote(value)
|
||||||
current_list = None
|
current_list = None
|
||||||
|
|
||||||
@@ -92,6 +176,7 @@ def _parse_yaml_block(
|
|||||||
i = next_i
|
i = next_i
|
||||||
elif next_line.strip().startswith("- "):
|
elif next_line.strip().startswith("- "):
|
||||||
current_list = []
|
current_list = []
|
||||||
|
list_owner_indent = indent
|
||||||
target[key] = current_list
|
target[key] = current_list
|
||||||
i += 1
|
i += 1
|
||||||
else:
|
else:
|
||||||
@@ -99,7 +184,7 @@ def _parse_yaml_block(
|
|||||||
if next_indent > indent:
|
if next_indent > indent:
|
||||||
nested: dict = {}
|
nested: dict = {}
|
||||||
target[key] = nested
|
target[key] = nested
|
||||||
i = _parse_yaml_block(lines, i + 1, next_indent, nested)
|
i = _parse_yaml_block(lines, i + 1, next_indent, nested, source)
|
||||||
else:
|
else:
|
||||||
target[key] = {}
|
target[key] = {}
|
||||||
i += 1
|
i += 1
|
||||||
@@ -109,11 +194,33 @@ def _parse_yaml_block(
|
|||||||
return i
|
return i
|
||||||
|
|
||||||
|
|
||||||
def parse_simple_yaml(content: str) -> dict:
|
def parse_simple_yaml(content: str, source: str = "config.yaml") -> dict:
|
||||||
"""Parse a small subset of YAML. See common.config for full doc."""
|
"""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). Every value
|
||||||
|
is a string; consumers coerce. Unsupported constructs are reported on
|
||||||
|
stderr against ``source`` and skipped — see the module docstring.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: YAML content string.
|
||||||
|
source: Label used in warnings, normally the config file path.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed dict (values can be str, list[str], or dict).
|
||||||
|
"""
|
||||||
lines = content.splitlines()
|
lines = content.splitlines()
|
||||||
result: dict = {}
|
result: dict = {}
|
||||||
_parse_yaml_block(lines, 0, 0, result)
|
_parse_yaml_block(lines, 0, 0, result, source)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -126,7 +233,7 @@ def read_trellis_config(repo_root: Optional[Path] = None) -> dict:
|
|||||||
except (FileNotFoundError, OSError):
|
except (FileNotFoundError, OSError):
|
||||||
return {}
|
return {}
|
||||||
try:
|
try:
|
||||||
parsed = parse_simple_yaml(content)
|
parsed = parse_simple_yaml(content, source=str(config_file))
|
||||||
except Exception:
|
except Exception:
|
||||||
return {}
|
return {}
|
||||||
return parsed if isinstance(parsed, dict) else {}
|
return parsed if isinstance(parsed, dict) else {}
|
||||||
|
|||||||
@@ -141,6 +141,25 @@ def _platform_matches(platform: str, block_names: list[str]) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
_PLATFORM_MARKER_LABELS: dict[str, str] = {
|
||||||
|
# workflow.md marker blocks label platforms with their product names, but
|
||||||
|
# every caller passes the stable id instead (`--platform {{CLI_FLAG}}` in
|
||||||
|
# the start / continue commands). `_platform_matches` only strips
|
||||||
|
# punctuation, so an id that is not its label-minus-spaces never matches and
|
||||||
|
# `filter_platform` drops the block WITHOUT error — the section just comes
|
||||||
|
# back empty. Four platforms shipped that way before this table existed.
|
||||||
|
#
|
||||||
|
# Add an entry whenever a platform's id is not its marker label with the
|
||||||
|
# separators removed. `test/registry-invariants.test.ts` asserts every
|
||||||
|
# registry id keeps a non-empty routing section, so a missing entry fails
|
||||||
|
# there rather than silently blanking that platform's routing.
|
||||||
|
"claude": "Claude Code",
|
||||||
|
"kimi": "Kimi Code",
|
||||||
|
"omp": "Oh My Pi",
|
||||||
|
"dsh": "DeepSeek Harness",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def resolve_effective_platform(platform: str, config: dict) -> str:
|
def resolve_effective_platform(platform: str, config: dict) -> str:
|
||||||
"""Map ``codex`` to a dispatch-mode-namespaced virtual platform name.
|
"""Map ``codex`` to a dispatch-mode-namespaced virtual platform name.
|
||||||
|
|
||||||
@@ -155,8 +174,12 @@ def resolve_effective_platform(platform: str, config: dict) -> str:
|
|||||||
explicit values fall back to ``inline`` safely; this renderer deliberately
|
explicit values fall back to ``inline`` safely; this renderer deliberately
|
||||||
does not warn because it can run in normal CLI output flows.
|
does not warn because it can run in normal CLI output flows.
|
||||||
|
|
||||||
Other platforms are returned unchanged.
|
Platforms whose marker label differs from their id resolve through
|
||||||
|
``_PLATFORM_MARKER_LABELS``. Everything else is returned unchanged.
|
||||||
"""
|
"""
|
||||||
|
label = _PLATFORM_MARKER_LABELS.get(platform.strip().lower())
|
||||||
|
if label:
|
||||||
|
return label
|
||||||
if platform == "codex":
|
if platform == "codex":
|
||||||
mode = "auto"
|
mode = "auto"
|
||||||
codex_cfg = config.get("codex") if isinstance(config, dict) else None
|
codex_cfg = config.get("codex") if isinstance(config, dict) else None
|
||||||
|
|||||||
+232
-32
@@ -4,18 +4,19 @@
|
|||||||
Task Management Script.
|
Task Management Script.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python3 task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] [--package <pkg>] [--no-start]
|
python3 task.py create "<title>" --description "<desc>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] [--package <pkg>] [--no-start] [--force]
|
||||||
python3 task.py add-context <dir> <file> <path> [reason] # Add jsonl entry
|
python3 task.py add-context <dir> <file> <path> [reason] # Add jsonl entry
|
||||||
python3 task.py validate <dir> # Validate jsonl files
|
python3 task.py validate <dir> # Validate jsonl files
|
||||||
python3 task.py list-context <dir> # List jsonl entries
|
python3 task.py list-context <dir> # List jsonl entries
|
||||||
python3 task.py start <dir> # Set active task
|
python3 task.py start <dir> # Set active task, record current branch
|
||||||
python3 task.py current [--source] [--json] # Show active task
|
python3 task.py current [--source] [--json] # Show active task
|
||||||
python3 task.py finish # Clear active task
|
python3 task.py finish # Clear active task
|
||||||
python3 task.py set-branch <dir> <branch> # Set git branch
|
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-base-branch <dir> <branch> # Set PR target branch
|
||||||
python3 task.py set-scope <dir> <scope> # Set scope for PR title
|
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 set-meta <dir> <key> <value> # Set a task metadata key
|
||||||
python3 task.py archive <task-dir> # Archive completed task
|
python3 task.py rename <dir> <new-slug> [--dry-run] # Rename task + references
|
||||||
|
python3 task.py archive <task-dir> [--skip-branch-validation] # Archive completed task
|
||||||
python3 task.py list # List active tasks
|
python3 task.py list # List active tasks
|
||||||
python3 task.py list-archive [month] # List archived 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 add-subtask <parent-dir> <child-dir> # Link child to parent
|
||||||
@@ -27,9 +28,12 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from common.log import Colors, colored
|
from common.log import Colors, colored
|
||||||
|
from common.config import get_codex_dispatch_mode
|
||||||
from common.paths import (
|
from common.paths import (
|
||||||
|
DEVELOPER_HINT,
|
||||||
DIR_WORKFLOW,
|
DIR_WORKFLOW,
|
||||||
DIR_TASKS,
|
DIR_TASKS,
|
||||||
FILE_TASK_JSON,
|
FILE_TASK_JSON,
|
||||||
@@ -44,13 +48,19 @@ from common.active_task import (
|
|||||||
resolve_context_key,
|
resolve_context_key,
|
||||||
set_active_task,
|
set_active_task,
|
||||||
)
|
)
|
||||||
from common.io import read_json, write_json
|
from common.git import current_branch_name
|
||||||
|
from common.io import (
|
||||||
|
describe_json_read_failure,
|
||||||
|
read_json_checked,
|
||||||
|
write_json,
|
||||||
|
)
|
||||||
from common.task_utils import resolve_task_dir, run_task_hooks
|
from common.task_utils import resolve_task_dir, run_task_hooks
|
||||||
from common.tasks import iter_active_tasks, children_progress
|
from common.tasks import iter_active_tasks, children_progress
|
||||||
|
|
||||||
# Import command handlers from split modules (also re-exports for plan.py compatibility)
|
# Import command handlers from split modules (also re-exports for plan.py compatibility)
|
||||||
from common.task_store import (
|
from common.task_store import (
|
||||||
cmd_create,
|
cmd_create,
|
||||||
|
cmd_rename,
|
||||||
cmd_archive,
|
cmd_archive,
|
||||||
cmd_set_branch,
|
cmd_set_branch,
|
||||||
cmd_set_base_branch,
|
cmd_set_base_branch,
|
||||||
@@ -63,6 +73,7 @@ from common.task_context import (
|
|||||||
cmd_add_context,
|
cmd_add_context,
|
||||||
cmd_validate,
|
cmd_validate,
|
||||||
cmd_list_context,
|
cmd_list_context,
|
||||||
|
curated_entry_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -70,6 +81,94 @@ from common.task_context import (
|
|||||||
# Command: start / finish
|
# Command: start / finish
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
def _record_start_state(
|
||||||
|
task_json_path: Path,
|
||||||
|
repo_root: Path,
|
||||||
|
label: str = "",
|
||||||
|
) -> None:
|
||||||
|
"""Move a freshly started task to in_progress and record its branch.
|
||||||
|
|
||||||
|
Both updates share one read/write: the status flip from planning, and the
|
||||||
|
checked-out branch when `branch` is still empty. Recording at start is what
|
||||||
|
keeps `branch` trustworthy at archive time — a task whose branch is only
|
||||||
|
ever set by hand tends to reach archive with `branch: null`.
|
||||||
|
|
||||||
|
Tolerant on purpose — a broken task.json does not fail `start`, because the
|
||||||
|
session pointer is the point of the command. But the read overwrites the
|
||||||
|
file it just read, so no failure may be silent: without a message the
|
||||||
|
absent status line looks like the task simply was not in planning.
|
||||||
|
"""
|
||||||
|
data, reason = read_json_checked(task_json_path)
|
||||||
|
if data is None:
|
||||||
|
problem, hint = describe_json_read_failure(task_json_path, reason)
|
||||||
|
print(
|
||||||
|
colored(f"Warning: {problem}; task.json not updated.", Colors.YELLOW),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(hint, file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
applied: list[str] = []
|
||||||
|
|
||||||
|
if data.get("status") == "planning":
|
||||||
|
data["status"] = "in_progress"
|
||||||
|
applied.append(f"✓ Status: planning → in_progress{label}")
|
||||||
|
|
||||||
|
# Only fill an empty field: an explicit `set-branch` must survive a later
|
||||||
|
# `start` (re-starting a task after a checkout is a normal thing to do).
|
||||||
|
base_branch_conflict: str | None = None
|
||||||
|
if not data.get("branch"):
|
||||||
|
branch = current_branch_name(repo_root)
|
||||||
|
if branch:
|
||||||
|
data["branch"] = branch
|
||||||
|
applied.append(f"✓ Branch recorded: {branch}{label}")
|
||||||
|
if branch == data.get("base_branch"):
|
||||||
|
base_branch_conflict = branch
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
"Note: no checked-out branch (detached HEAD, or not a git "
|
||||||
|
"repository); task branch not recorded.",
|
||||||
|
Colors.YELLOW,
|
||||||
|
),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not applied:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not write_json(task_json_path, data):
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
f"Warning: Failed to write {task_json_path}; "
|
||||||
|
"status and branch are unchanged.",
|
||||||
|
Colors.YELLOW,
|
||||||
|
),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
for line in applied:
|
||||||
|
print(colored(line, Colors.GREEN))
|
||||||
|
|
||||||
|
if base_branch_conflict:
|
||||||
|
# Recorded anyway — the value is true, it just cannot describe a PR.
|
||||||
|
# Archive refuses this shape, so say so now rather than at the gate.
|
||||||
|
print(
|
||||||
|
colored(
|
||||||
|
f"Warning: '{base_branch_conflict}' is also this task's base_branch; "
|
||||||
|
"a PR cannot target its own branch, and archive will refuse it.",
|
||||||
|
Colors.YELLOW,
|
||||||
|
),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"Once you branch off, run: python3 {DIR_WORKFLOW}/scripts/task.py "
|
||||||
|
"set-branch <task> <feature-branch>",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def cmd_start(args: argparse.Namespace) -> int:
|
def cmd_start(args: argparse.Namespace) -> int:
|
||||||
"""Set active task."""
|
"""Set active task."""
|
||||||
repo_root = get_repo_root()
|
repo_root = get_repo_root()
|
||||||
@@ -82,16 +181,55 @@ def cmd_start(args: argparse.Namespace) -> int:
|
|||||||
# Resolve task directory (supports task name, relative path, or absolute path)
|
# Resolve task directory (supports task name, relative path, or absolute path)
|
||||||
full_path = resolve_task_dir(task_input, repo_root)
|
full_path = resolve_task_dir(task_input, repo_root)
|
||||||
|
|
||||||
|
if full_path is None:
|
||||||
|
# resolve_task_dir already named the exact reason on stderr. A second,
|
||||||
|
# generic line on stdout would split one diagnosis across two streams
|
||||||
|
# and bury the specific message.
|
||||||
|
return 1
|
||||||
|
|
||||||
if not full_path.is_dir():
|
if not full_path.is_dir():
|
||||||
print(colored(f"Error: Task not found: {task_input}", Colors.RED))
|
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')")
|
print("Hint: Use task name (e.g., 'my-task') or full path (e.g., '.trellis/tasks/01-31-my-task')")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# Convert to relative path for storage
|
# Context-manifest gate (#573): a seeded-but-uncurated implement/check
|
||||||
|
# manifest means every sub-agent dispatched for this task runs with zero
|
||||||
|
# spec context, and nothing downstream surfaces that to the main session.
|
||||||
|
# An absent manifest is not gated — create seeds the files only on
|
||||||
|
# sub-agent-capable platforms, so absence means no sub-agent reads them.
|
||||||
|
if get_codex_dispatch_mode(repo_root) != "inline" and not getattr(args, "allow_empty_context", False):
|
||||||
|
empty_manifests = [
|
||||||
|
name
|
||||||
|
for name in ("implement.jsonl", "check.jsonl")
|
||||||
|
if curated_entry_count(full_path / name) == 0
|
||||||
|
]
|
||||||
|
if empty_manifests:
|
||||||
|
print(colored(
|
||||||
|
f"Error: {' and '.join(empty_manifests)} "
|
||||||
|
f"{'has' if len(empty_manifests) == 1 else 'have'} no curated entries",
|
||||||
|
Colors.RED,
|
||||||
|
))
|
||||||
|
print("Sub-agents (implement/check) would run with zero spec context.")
|
||||||
|
print(f" Curate: python3 .trellis/scripts/task.py add-context {task_input} implement <path> \"<why>\"")
|
||||||
|
print(f" Verify: python3 .trellis/scripts/task.py validate {task_input}")
|
||||||
|
print(" Intentionally empty? Re-run start with --allow-empty-context")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Convert to relative path for storage. repo_root is resolved because
|
||||||
|
# full_path already is (resolve_task_dir only returns paths inside the
|
||||||
|
# resolved root), so an unresolved repo_root would mismatch under a
|
||||||
|
# symlink (e.g. /tmp on macOS) and reject a perfectly normal task.
|
||||||
try:
|
try:
|
||||||
task_dir = full_path.relative_to(repo_root).as_posix()
|
task_dir = full_path.relative_to(repo_root.resolve()).as_posix()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
task_dir = str(full_path)
|
# resolve_task_dir already refused everything outside the repo, so
|
||||||
|
# this is unreachable in practice. Refuse rather than fall back to
|
||||||
|
# str(full_path) — that fallback (a lexical relative_to() paired with
|
||||||
|
# an absolute-path fallback) is exactly the pattern that let a `..`
|
||||||
|
# ref escape into storage before this fix.
|
||||||
|
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
|
||||||
|
|
||||||
task_json_path = full_path / FILE_TASK_JSON
|
task_json_path = full_path / FILE_TASK_JSON
|
||||||
|
|
||||||
@@ -113,11 +251,7 @@ def cmd_start(args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
# Still flip task.json status: planning → in_progress so downstream phases proceed.
|
# Still flip task.json status: planning → in_progress so downstream phases proceed.
|
||||||
if task_json_path.is_file():
|
if task_json_path.is_file():
|
||||||
data = read_json(task_json_path)
|
_record_start_state(task_json_path, repo_root, " (degraded)")
|
||||||
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)
|
run_task_hooks("after_start", task_json_path, repo_root)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -127,11 +261,7 @@ def cmd_start(args: argparse.Namespace) -> int:
|
|||||||
print(f"Source: {active.source}")
|
print(f"Source: {active.source}")
|
||||||
|
|
||||||
if task_json_path.is_file():
|
if task_json_path.is_file():
|
||||||
data = read_json(task_json_path)
|
_record_start_state(task_json_path, repo_root)
|
||||||
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()
|
||||||
print(colored("The hook will now inject context from this task's jsonl files.", Colors.BLUE))
|
print(colored("The hook will now inject context from this task's jsonl files.", Colors.BLUE))
|
||||||
@@ -171,8 +301,20 @@ def cmd_current(args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
if getattr(args, "json", False):
|
if getattr(args, "json", False):
|
||||||
task_obj = None
|
task_obj = None
|
||||||
|
read_error = None
|
||||||
if active.task_path:
|
if active.task_path:
|
||||||
data = read_json(repo_root / active.task_path / FILE_TASK_JSON) or {}
|
task_json_path = repo_root / active.task_path / FILE_TASK_JSON
|
||||||
|
data, reason = read_json_checked(task_json_path)
|
||||||
|
if data is None:
|
||||||
|
# Without this, a corrupt task.json emits null for every field
|
||||||
|
# — indistinguishable from a task whose fields really are null.
|
||||||
|
problem, hint = describe_json_read_failure(task_json_path, reason)
|
||||||
|
read_error = {
|
||||||
|
"file": str(task_json_path),
|
||||||
|
"reason": reason,
|
||||||
|
"message": f"{problem}. {hint}",
|
||||||
|
}
|
||||||
|
data = {}
|
||||||
task_obj = {
|
task_obj = {
|
||||||
"dir": active.task_path,
|
"dir": active.task_path,
|
||||||
"id": data.get("id") or data.get("name"),
|
"id": data.get("id") or data.get("name"),
|
||||||
@@ -183,11 +325,15 @@ def cmd_current(args: argparse.Namespace) -> int:
|
|||||||
"branch": data.get("branch"),
|
"branch": data.get("branch"),
|
||||||
"base_branch": data.get("base_branch"),
|
"base_branch": data.get("base_branch"),
|
||||||
}
|
}
|
||||||
print(json.dumps({
|
payload = {
|
||||||
"current_task": task_obj,
|
"current_task": task_obj,
|
||||||
"source": active.source,
|
"source": active.source,
|
||||||
"stale": active.stale,
|
"stale": active.stale,
|
||||||
}, ensure_ascii=False))
|
}
|
||||||
|
# Only present when the read failed, so the healthy shape is unchanged.
|
||||||
|
if read_error:
|
||||||
|
payload["error"] = read_error
|
||||||
|
print(json.dumps(payload, ensure_ascii=False))
|
||||||
return 0 if active.task_path else 1
|
return 0 if active.task_path else 1
|
||||||
|
|
||||||
if args.source:
|
if args.source:
|
||||||
@@ -242,7 +388,10 @@ def cmd_list(args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
if as_json:
|
if as_json:
|
||||||
if filter_mine and not developer:
|
if filter_mine and not developer:
|
||||||
print(json.dumps({"error": "No developer set"}), file=sys.stderr)
|
print(
|
||||||
|
json.dumps({"error": "No developer set", "hint": DEVELOPER_HINT}),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
@@ -270,6 +419,7 @@ def cmd_list(args: argparse.Namespace) -> int:
|
|||||||
if filter_mine:
|
if filter_mine:
|
||||||
if not developer:
|
if not developer:
|
||||||
print(colored("Error: No developer set. Run init_developer.py first", Colors.RED), file=sys.stderr)
|
print(colored("Error: No developer set. Run init_developer.py first", Colors.RED), file=sys.stderr)
|
||||||
|
print(DEVELOPER_HINT, file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
print(colored(f"My tasks (assignee: {developer}):", Colors.BLUE))
|
print(colored(f"My tasks (assignee: {developer}):", Colors.BLUE))
|
||||||
else:
|
else:
|
||||||
@@ -378,20 +528,21 @@ def show_usage() -> None:
|
|||||||
print("""Task Management Script
|
print("""Task Management Script
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python3 task.py create <title> Create new task directory
|
python3 task.py create <title> --description <desc> Create new task directory (both required, non-empty)
|
||||||
python3 task.py create <title> --package <pkg> Create task for a specific package
|
python3 task.py create <title> --description <desc> --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> --description <desc> --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 create <title> --description <desc> --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 add-context <dir> <jsonl> <path> [reason] Add entry to jsonl
|
||||||
python3 task.py validate <dir> Validate jsonl files
|
python3 task.py validate <dir> Validate jsonl files
|
||||||
python3 task.py list-context <dir> List jsonl entries
|
python3 task.py list-context <dir> List jsonl entries
|
||||||
python3 task.py start <dir> Set active task
|
python3 task.py start <dir> Set active task; records the checked-out branch when unset
|
||||||
python3 task.py current [--source] Show active task
|
python3 task.py current [--source] Show active task
|
||||||
python3 task.py finish Clear active task
|
python3 task.py finish Clear active task
|
||||||
python3 task.py set-branch <dir> <branch> Set git branch
|
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-base-branch <dir> <branch> Set PR target branch
|
||||||
python3 task.py set-scope <dir> <scope> Set scope for PR title
|
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 set-meta <dir> <key> <value> Set/overwrite a task metadata key
|
||||||
|
python3 task.py rename <dir> <new-slug> Rename task, identity fields and references
|
||||||
python3 task.py archive <task-dir> Archive completed task
|
python3 task.py archive <task-dir> Archive completed task
|
||||||
python3 task.py add-subtask <parent> <child> Link child task to parent
|
python3 task.py add-subtask <parent> <child> Link child task to parent
|
||||||
python3 task.py remove-subtask <parent> <child> Unlink child from parent
|
python3 task.py remove-subtask <parent> <child> Unlink child from parent
|
||||||
@@ -401,22 +552,38 @@ Usage:
|
|||||||
Monorepo options:
|
Monorepo options:
|
||||||
--package <pkg> Package name (validated against config.yaml packages)
|
--package <pkg> Package name (validated against config.yaml packages)
|
||||||
|
|
||||||
|
Rename options:
|
||||||
|
--dry-run Print the change set without writing anything
|
||||||
|
|
||||||
|
Archive options:
|
||||||
|
--no-commit Skip the auto git commit after archiving
|
||||||
|
--skip-branch-validation Archive despite missing or self-referential branch metadata.
|
||||||
|
Archive normally refuses a task with no `branch` when it has a
|
||||||
|
`base_branch` and the repo has a remote, or with
|
||||||
|
`branch == base_branch`; repair those with `set-branch` /
|
||||||
|
`set-base-branch` instead. Use this flag only for tasks that
|
||||||
|
were never PR-backed. A recorded branch that was merged and
|
||||||
|
deleted is only a warning and needs no flag.
|
||||||
|
|
||||||
List options:
|
List options:
|
||||||
--mine, -m Show only tasks assigned to current developer
|
--mine, -m Show only tasks assigned to current developer
|
||||||
--status, -s <s> Filter by status (planning, in_progress, review, completed)
|
--status, -s <s> Filter by status (planning, in_progress, review, completed)
|
||||||
--json Output machine-readable JSON (also available on `current`)
|
--json Output machine-readable JSON (also available on `current`)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
python3 task.py create "Add login feature" --slug add-login
|
python3 task.py create "Add login feature" --description "Email + password sign-in" --slug add-login
|
||||||
python3 task.py create "Add login feature" --slug add-login --package cli
|
python3 task.py create "Add login feature" --description "Email + password sign-in" --slug add-login --package cli
|
||||||
python3 task.py create "Add login feature" --meta linear=ENG-123 --meta epic=auth
|
python3 task.py create "Add login feature" --description "Email + password sign-in" --meta linear=ENG-123 --meta epic=auth
|
||||||
python3 task.py create "Child task" --slug child --parent .trellis/tasks/01-21-parent
|
python3 task.py create "Child task" --description "Session cookie handling" --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 add-context <dir> implement .trellis/spec/cli/backend/auth.md "Auth guidelines"
|
||||||
python3 task.py set-branch <dir> task/add-login
|
python3 task.py set-branch <dir> task/add-login
|
||||||
python3 task.py start .trellis/tasks/01-21-add-login
|
python3 task.py start .trellis/tasks/01-21-add-login
|
||||||
python3 task.py current --source
|
python3 task.py current --source
|
||||||
python3 task.py finish
|
python3 task.py finish
|
||||||
|
python3 task.py rename add-login add-sso --dry-run # Preview the change set
|
||||||
|
python3 task.py rename add-login add-sso
|
||||||
python3 task.py archive add-login
|
python3 task.py archive add-login
|
||||||
|
python3 task.py archive add-login --skip-branch-validation # Task never had a branch of its own
|
||||||
python3 task.py add-subtask parent-task child-task # Link existing tasks
|
python3 task.py add-subtask parent-task child-task # Link existing tasks
|
||||||
python3 task.py remove-subtask parent-task child-task
|
python3 task.py remove-subtask parent-task child-task
|
||||||
python3 task.py list # List all active tasks
|
python3 task.py list # List all active tasks
|
||||||
@@ -469,11 +636,15 @@ def main() -> int:
|
|||||||
|
|
||||||
# create
|
# create
|
||||||
p_create = subparsers.add_parser("create", help="Create new task")
|
p_create = subparsers.add_parser("create", help="Create new task")
|
||||||
p_create.add_argument("title", help="Task title")
|
p_create.add_argument("title", help="Task title (required, non-empty)")
|
||||||
p_create.add_argument("--slug", "-s", help="Task slug without the MM-DD date prefix")
|
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("--assignee", "-a", help="Assignee developer")
|
||||||
p_create.add_argument("--priority", "-p", default="P2", help="Priority (P0-P3)")
|
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(
|
||||||
|
"--description",
|
||||||
|
"-d",
|
||||||
|
help="Task description (required, non-empty — an empty one is refused at archive)",
|
||||||
|
)
|
||||||
p_create.add_argument("--parent", help="Parent task directory (establishes subtask link)")
|
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("--package", help="Package name for monorepo projects")
|
||||||
p_create.add_argument(
|
p_create.add_argument(
|
||||||
@@ -490,6 +661,11 @@ def main() -> int:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Create the task without making it active in this session",
|
help="Create the task without making it active in this session",
|
||||||
)
|
)
|
||||||
|
p_create.add_argument(
|
||||||
|
"--force",
|
||||||
|
action="store_true",
|
||||||
|
help="Overwrite task.json when the task directory already exists",
|
||||||
|
)
|
||||||
|
|
||||||
# add-context
|
# add-context
|
||||||
p_add = subparsers.add_parser("add-context", help="Add context entry")
|
p_add = subparsers.add_parser("add-context", help="Add context entry")
|
||||||
@@ -509,6 +685,11 @@ def main() -> int:
|
|||||||
# start
|
# start
|
||||||
p_start = subparsers.add_parser("start", help="Set active task")
|
p_start = subparsers.add_parser("start", help="Set active task")
|
||||||
p_start.add_argument("dir", help="Task directory")
|
p_start.add_argument("dir", help="Task directory")
|
||||||
|
p_start.add_argument(
|
||||||
|
"--allow-empty-context",
|
||||||
|
action="store_true",
|
||||||
|
help="Start even when implement.jsonl / check.jsonl have no curated entries",
|
||||||
|
)
|
||||||
|
|
||||||
# current
|
# current
|
||||||
p_current = subparsers.add_parser("current", help="Show active task")
|
p_current = subparsers.add_parser("current", help="Show active task")
|
||||||
@@ -541,10 +722,28 @@ def main() -> int:
|
|||||||
p_setmeta.add_argument("key", help="Metadata key")
|
p_setmeta.add_argument("key", help="Metadata key")
|
||||||
p_setmeta.add_argument("value", help="Metadata value")
|
p_setmeta.add_argument("value", help="Metadata value")
|
||||||
|
|
||||||
|
# rename
|
||||||
|
p_rename = subparsers.add_parser("rename", help="Rename task and its references")
|
||||||
|
p_rename.add_argument("name", help="Task directory or name")
|
||||||
|
p_rename.add_argument("new_slug", help="New slug without the MM-DD date prefix")
|
||||||
|
p_rename.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Print the change set without writing anything",
|
||||||
|
)
|
||||||
|
|
||||||
# archive
|
# archive
|
||||||
p_archive = subparsers.add_parser("archive", help="Archive task")
|
p_archive = subparsers.add_parser("archive", help="Archive task")
|
||||||
p_archive.add_argument("name", help="Task directory or name")
|
p_archive.add_argument("name", help="Task directory or name")
|
||||||
p_archive.add_argument("--no-commit", action="store_true", help="Skip auto git commit after archive")
|
p_archive.add_argument("--no-commit", action="store_true", help="Skip auto git commit after archive")
|
||||||
|
p_archive.add_argument(
|
||||||
|
"--skip-branch-validation",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Archive even when branch metadata is missing or self-referential "
|
||||||
|
"(for tasks that were never PR-backed)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# list
|
# list
|
||||||
p_list = subparsers.add_parser("list", help="List tasks")
|
p_list = subparsers.add_parser("list", help="List tasks")
|
||||||
@@ -584,6 +783,7 @@ def main() -> int:
|
|||||||
"set-base-branch": cmd_set_base_branch,
|
"set-base-branch": cmd_set_base_branch,
|
||||||
"set-scope": cmd_set_scope,
|
"set-scope": cmd_set_scope,
|
||||||
"set-meta": cmd_set_meta,
|
"set-meta": cmd_set_meta,
|
||||||
|
"rename": cmd_rename,
|
||||||
"archive": cmd_archive,
|
"archive": cmd_archive,
|
||||||
"add-subtask": cmd_add_subtask,
|
"add-subtask": cmd_add_subtask,
|
||||||
"remove-subtask": cmd_remove_subtask,
|
"remove-subtask": cmd_remove_subtask,
|
||||||
|
|||||||
@@ -42,23 +42,16 @@ These guides help you **ask the right questions before coding**.
|
|||||||
- [ ] You're writing similar code to something that exists
|
- [ ] You're writing similar code to something that exists
|
||||||
- [ ] You see the same pattern repeated 3+ times
|
- [ ] You see the same pattern repeated 3+ times
|
||||||
- [ ] You're adding a new field to multiple places
|
- [ ] You're adding a new field to multiple places
|
||||||
- [ ] **You're modifying any constant or config**
|
- [ ] You are changing a shared constant or configuration contract
|
||||||
- [ ] **You're creating a new utility/helper function** ← Search first!
|
- [ ] **You're creating a new utility/helper function** ← Search first!
|
||||||
|
|
||||||
→ Read [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md)
|
→ Read [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Pre-Modification Rule (CRITICAL)
|
## Reference Checks By Risk
|
||||||
|
|
||||||
> **Before changing ANY value, ALWAYS search first!**
|
Before changing shared contracts, cross-layer constants or configuration, inspect their consumers. A local literal or wording correction does not require a repository-wide search. Read these guides only when the task matches their triggers; do not repeat an unchanged checklist every turn.
|
||||||
|
|
||||||
```bash
|
|
||||||
# Search for the value you're about to change
|
|
||||||
grep -r "value_to_change" .
|
|
||||||
```
|
|
||||||
|
|
||||||
This single habit prevents most "forgot to update X" bugs.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,32 @@ tasks after TRELLIS2.
|
|||||||
- In sub-agent dispatch mode, curate `implement.jsonl` and `check.jsonl` with
|
- In sub-agent dispatch mode, curate `implement.jsonl` and `check.jsonl` with
|
||||||
stable spec or research context only. In Codex inline mode they may contain
|
stable spec or research context only. In Codex inline mode they may contain
|
||||||
no real entries because the main session reads specs directly.
|
no real entries because the main session reads specs directly.
|
||||||
- Give a concrete implementation plan before coding and wait for approval.
|
- Give a concrete plan before non-trivial changes. Recorded approval of the
|
||||||
|
same scope authorizes implementation and task-scoped validation; clerical
|
||||||
|
backfill and repeated planning injection do not restart approval.
|
||||||
- Keep edits scoped to the current Trellis task.
|
- Keep edits scoped to the current Trellis task.
|
||||||
- Commit in Chinese and include the Trellis task name or task ID.
|
- Commit only when explicitly requested, in Chinese with the task name or ID.
|
||||||
|
|
||||||
|
## Risk-Scoped Execution
|
||||||
|
|
||||||
|
- Pure consultation and small isolated edits do not need a task-creation
|
||||||
|
question. Non-trivial work uses the task contract; new scope, risk or
|
||||||
|
acceptance decisions require approval, not every intermediate action.
|
||||||
|
- Read the current phase and affected-layer contracts on demand. Reuse
|
||||||
|
unchanged context; do not require every document or thinking guide per turn.
|
||||||
|
- Prefer an available native plan tool. If unavailable, keep the checklist
|
||||||
|
in the current task implementation notes rather than stopping or creating
|
||||||
|
another planning system.
|
||||||
|
- Select checks from the task diff and its risks, not all unrelated dirty
|
||||||
|
files. Reuse passing checks with unchanged inputs; report pre-existing
|
||||||
|
failures without modifying unrelated code.
|
||||||
|
- Preserve privacy, permission/payment controls and separately approved
|
||||||
|
production operations. Rules-only work does not require public deployment.
|
||||||
|
- Report local validation, independent review and public availability
|
||||||
|
separately. If independent review is unavailable, leave that gate pending
|
||||||
|
rather than claiming main-session tests fulfilled it.
|
||||||
|
- A finished stage may leave its parent task active. Never auto-commit,
|
||||||
|
auto-archive, clean unrelated tasks or create duplicate retrospectives.
|
||||||
|
|
||||||
## Solo Developer Operating Mode
|
## Solo Developer Operating Mode
|
||||||
|
|
||||||
@@ -327,12 +350,35 @@ preview platform scope -> conservative update/migrate -> merge overlays
|
|||||||
- Sub-agent mode requires at least one real entry in each manifest.
|
- Sub-agent mode requires at least one real entry in each manifest.
|
||||||
- Switching dispatch mode changes the validation policy, not historical task
|
- Switching dispatch mode changes the validation policy, not historical task
|
||||||
evidence and not the requirement to read specs before implementation.
|
evidence and not the requirement to read specs before implementation.
|
||||||
|
- Trellis 0.6.16 start and validate share get_codex_dispatch_mode. Inline
|
||||||
|
tolerates old seed-only rows; real entries require non-empty file/reason
|
||||||
|
and resolved paths contained within the project. Sub-agent mode retains
|
||||||
|
the curated-entry gate. The start override alone does not change validate.
|
||||||
|
- The inline seed exception applies only to an object whose sole key is
|
||||||
|
`_example`. Any additional key, including a falsey file/path, reason, type
|
||||||
|
or an unknown key, makes it a mixed placeholder that validate must reject
|
||||||
|
with ``Placeholder `_example` row left by an older task.py create``.
|
||||||
|
Field absence is not equivalent to an explicitly empty or invalid value.
|
||||||
|
- Update task.py and task_context.py as a coherent set: the newer start
|
||||||
|
imports curated_entry_count. Keeping the old conflict file breaks imports.
|
||||||
|
- The fixed 0.6.16 upstream hooks do not enforce this marker contract:
|
||||||
|
session-start counts truthy file fields; inject-subagent-context accepts
|
||||||
|
truthy file/path fields. They can count or inject mixed marker rows that
|
||||||
|
validate rejects. Hook readiness/injection is not manifest validation.
|
||||||
|
Preserve the reviewed upstream hooks; do not treat their success as a
|
||||||
|
substitute for the validator gate.
|
||||||
|
|
||||||
### 4. Validation & Error Matrix
|
### 4. Validation & Error Matrix
|
||||||
|
|
||||||
- Inline + zero real entries -> pass with `*_inline_optional`.
|
- Inline + zero real entries -> pass with `*_inline_optional`.
|
||||||
- Inline + malformed real entry -> fail.
|
- Inline + malformed real entry -> fail.
|
||||||
- Sub-agent + zero real entries -> fail with `*_empty`.
|
- Either mode + `_example` with any additional key -> fail at that row;
|
||||||
|
a valid file/reason pair does not make a mixed placeholder acceptable.
|
||||||
|
- Sub-agent + empty manifest -> fail: governance reports `*_empty`;
|
||||||
|
`task.py validate` reports `0 curated entries`.
|
||||||
|
- Sub-agent + seed-only manifest -> fail: `task.py validate` reports
|
||||||
|
``Placeholder `_example` row left by an older task.py create`` before the
|
||||||
|
empty-manifest gate; governance reports `*_seed` and `*_empty`.
|
||||||
- Either mode + non-spec/non-research context -> warning for review.
|
- Either mode + non-spec/non-research context -> warning for review.
|
||||||
|
|
||||||
### 5. Good/Base/Bad Cases
|
### 5. Good/Base/Bad Cases
|
||||||
@@ -346,7 +392,13 @@ preview platform scope -> conservative update/migrate -> merge overlays
|
|||||||
|
|
||||||
- Fixture test for inline seed-only pass.
|
- Fixture test for inline seed-only pass.
|
||||||
- Fixture test for sub-agent seed-only failure.
|
- Fixture test for sub-agent seed-only failure.
|
||||||
|
- Negative fixtures for `_example` plus empty/false/null file or path,
|
||||||
|
reason/type/unknown keys and valid file/reason, in inline/sub-agent/auto.
|
||||||
|
Assert exit 1 and exact placeholder diagnostics for both manifests.
|
||||||
|
- Assert pure inline seeds count as zero entries and cannot hide a malformed
|
||||||
|
following row; run new cases against the old condition to prove they fail.
|
||||||
- Run both Trellis governance and `task.py validate` for the active task.
|
- Run both Trellis governance and `task.py validate` for the active task.
|
||||||
|
- Run `cd server && npm test -- tests/unit/scripts/trellis-runtime-contract.test.js tests/unit/scripts/check-trellis-governance.test.js` for mode gates, real hook routing, path containment and explicit closeout boundaries.
|
||||||
|
|
||||||
### 7. Wrong vs Correct
|
### 7. Wrong vs Correct
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"_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."}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# 升级与规则治理设计
|
||||||
|
|
||||||
|
## 基线和证据
|
||||||
|
|
||||||
|
2026-09-05:全局 CLI 与项目模板均为 0.6.12;npm latest 为 0.6.16,beta 为 0.7.0-beta.3。Node.js 22.22.2 满足目标包要求,CLI 软链接目标与 npm 全局 prefix 一致。
|
||||||
|
|
||||||
|
已通过 npm 元数据确认上游为 mindfold-ai/Trellis,并读取 v0.6.16 标签下的 0.6.13 至 0.6.16 迁移清单;四份清单均声明 breaking=false、recommendMigrate=false、migrations=[]。来源为该仓库 packages/cli/src/migrations/manifests/ 下对应版本 JSON,不依赖无法读取的网页摘要。
|
||||||
|
|
||||||
|
旧 CLI 的只读预览发现四处本地定制:`.trellis/config.yaml`、`.trellis/.gitignore`、`.trellis/workflow.md`、`.agents/skills/trellis-channel/references/command-reference.md`。这不是 0.6.16 的完整差异清单。
|
||||||
|
|
||||||
|
任务创建前有 37 项未提交修改或未跟踪条目;实施前重新核对,不覆盖其他任务成果。
|
||||||
|
|
||||||
|
## 分层与边界
|
||||||
|
|
||||||
|
1. 全局 CLI 升级不等于更新项目;先恢复和验证工具能力,再在批准范围内备份、升级、只读预览。
|
||||||
|
2. 全局规则只承载通用安全与协作;项目承载业务与交付约束;Trellis 受管模板按来源合并。
|
||||||
|
3. 项目模板更新后再落实精简,避免上游更新重新引入被删规则。
|
||||||
|
4. 项目仅使用 Codex,不顺带安装其他平台;不启用自动提交或生产操作。
|
||||||
|
|
||||||
|
## 兼容性重点
|
||||||
|
|
||||||
|
上游 0.6.16 的 task.py start 新增空上下文门禁,并提供 --allow-empty-context。当前 inline 模式要求跳过 JSONL 整理,应在未来更新项目时核对该例外的适用性,不伪造上下文条目,也不全局取消门禁。CLI 升级本身不会更新本地 task.py。
|
||||||
|
|
||||||
|
上游 check 技能新增任务范围约束,但仍存在较宽的测试要求。因此升级不是规则治理的替代,仍需按任务风险审查。
|
||||||
|
|
||||||
|
## 待审查位置
|
||||||
|
|
||||||
|
| 位置 | 影响 | 建议 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| AGENTS.md:15 | 非微小改动分类不明确 | 定义风险分类与一次批准的执行范围 |
|
||||||
|
| AGENTS.md:17 | 强制不存在的计划工具导致停工 | 定义工具不可用时的最小替代 |
|
||||||
|
| AGENTS.md:23 | 公网门禁可能泛化到纯治理 | 保留业务交付保障,明确治理豁免 |
|
||||||
|
| AGENTS.md:53 | 独立 reviewer 与 inline 限制需协调 | 高风险复核保留,明确兼容方式与未验证状态 |
|
||||||
|
| .agents/skills/trellis-start/SKILL.md | 简单咨询也初始化多份资料 | 先分类,再按需读取 |
|
||||||
|
| .trellis/spec/guides/index.md | ANY/ALWAYS 搜索约束过宽 | 按共享契约和跨层风险触发 |
|
||||||
|
|
||||||
|
用户级存在同名 Trellis 技能和若干候选重复技能,尚未核对内容与加载关系,不按名称删除。用户级 AGENTS 读取为空;不能假设所有会话指令都来自该文件。
|
||||||
|
|
||||||
|
## 回滚
|
||||||
|
|
||||||
|
备份保存在技能发现路径及仓库之外,权限收紧,记录 CLI 原安装、版本、受管文件与元数据。全局恢复沿用原 npm 安装体系安装固定 0.6.12;项目文件按本次清单恢复,不整仓 reset、不笼统删除未跟踪文件。恢复旧 CLI 不等于恢复项目模板,两者分别验证。
|
||||||
|
|
||||||
|
## 新 CLI 实际预览与下一阶段
|
||||||
|
|
||||||
|
2026-09-05 已完成全局升级;新版预览实际识别 31 个待更新文件、51 个未变文件及以下 5 个需人工决定的路径:
|
||||||
|
|
||||||
|
| 路径 | 下一阶段合并重点 |
|
||||||
|
| --- | --- |
|
||||||
|
| `.trellis/scripts/common/task_context.py` | 新发现的冲突,先核对本地上下文规则与上游空上下文门禁,保留 inline 合同 |
|
||||||
|
| `.trellis/config.yaml` | 保留关闭自动提交、Codex 平台及 inline 模式,不用上游默认覆盖 |
|
||||||
|
| `.trellis/.gitignore` | 保留项目现有个人日志、运行状态的忽略边界 |
|
||||||
|
| `.trellis/workflow.md` | 对照规划、确认、检查和收尾要求,避免更新重新引入冗余门禁 |
|
||||||
|
| `.agents/skills/trellis-channel/references/command-reference.md` | 核对本地定制的来源与必要性,当前 inline 不启用 channel 代理 |
|
||||||
|
|
||||||
|
31 个更新涉及任务运行脚本、before-dev/brainstorm/check/continue/meta 技能及 Codex 配置和 hooks。本阶段只预览,未修改这些文件。
|
||||||
|
|
||||||
|
建议下一阶段先只读比较以上冲突和同名全局技能,再给出最小补丁与验证清单。项目模板写入、全局技能删除/重装、治理规则精简仍需针对实际差异批准;不将本阶段 CLI 升级授权扩大解释为所有后续写入授权。
|
||||||
|
|
||||||
|
## 第二阶段审查结论(2026-09-05)
|
||||||
|
|
||||||
|
### 五处冲突的真实性质
|
||||||
|
|
||||||
|
| ID | 位置与证据 | 影响 | 拟处理 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| P1 | `.trellis/scripts/common/task_context.py` 与备份中的 0.6.12 原厂模板逐字一致;0.6.16 task.py:71-76 新增导入 curated_entry_count | 冲突标签不代表业务定制;若全部 skip,混合脚本将 ImportError,task 工具不可用 | 接受新运行时的完整依赖,再加窄范围 inline 兼容;不能只保留旧冲突文件 |
|
||||||
|
| P2 | `.trellis/config.yaml:33` 关闭自动提交,并显式设置 inline;上游两项均是注释默认 | 直接覆盖会丢失项目安全与编排约束 | 合并注释与新版选项,明确保留 session_auto_commit=false 和 dispatch_mode=inline |
|
||||||
|
| P3 | `.trellis/.gitignore:18` 比上游多 workspace/ 及解释 | 覆盖可能暴露个人日志到 Git 状态 | 保留该忽略规则,无需清理已有日志 |
|
||||||
|
| P4 | `.trellis/workflow.md:3` 有项目 overlay;新版另有 task_error 状态和平台路由更新 | 盲目覆盖丢本地边界;仅保留旧版又漏新运行时契约 | 保留必要项目边界并压缩重复说明,合入新状态块,不启用其他平台 |
|
||||||
|
| P5 | `.agents/skills/trellis-channel/references/command-reference.md:479` 与新版仅差一个末尾空行 | 无语义定制,不值得成为人工门禁 | 接受新版格式,不修改 channel 行为 |
|
||||||
|
|
||||||
|
不能以 --skip-all 代替合并,也不能直接 --force。模板回执与版本由官方 update 管理,不伪造哈希来隐藏本地改动。
|
||||||
|
|
||||||
|
### 已复现的兼容风险
|
||||||
|
|
||||||
|
隔离目录位于私有备份内,不改项目。完整 0.6.12 与完整 0.6.16 的 task.py --help 均通过;0.6.16 脚本搭配旧 task_context.py 退出码 1,明确报缺少 curated_entry_count。
|
||||||
|
|
||||||
|
在临时项目配置 inline 后,直接调用新版验证器:空清单与旧 _example 清单均返回 1。start 的 --allow-empty-context 仅绕过启动计数,不修复 validate 的拒绝;因此前阶段建议必须补充验证器适配。当前项目两份清单均含旧 seed 行,治理脚本允许 inline seed,与上游不同。
|
||||||
|
|
||||||
|
拟合同:inline 可为空;现存 seed 仅作为兼容历史输入处理,不作为真实上下文;真实条目仍严格检查格式、路径和引用。子代理模式必须有真实上下文,保留上游路径安全加固。不批量重写历史任务,不生成虚假条目凑门禁。start 与 validate 使用同一模式判断,避免两套判断漂移。
|
||||||
|
|
||||||
|
### 全局盘点
|
||||||
|
|
||||||
|
以下用户级路径使用相对用户目录表达,不记录完整本地路径。
|
||||||
|
|
||||||
|
| ID | 位置与证据 | 影响 | 拟处理 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| G1 | `.codex/skills/china-contract-review` 与 `.agents/skills/china-contract-review` 的 2 个文件完全相同;document-review 的两个目录各 6 个文件也完全相同,内容与模式均一致 | 同一技能有两份可发现副本,重复描述和后续更新易分叉 | Codex 仅启用 .agents 共享副本;先用用户级 skills.config 停用冗余 .codex 路径,不删其他工具可能依赖的目录 |
|
||||||
|
| G2 | find-skills 两个入口解析到同一文件;安装锁指向 vercel-labs/skills | 是链接,不是两套实现 | 保留目标和兼容链接;只审查描述触发,不当作重复资产删除 |
|
||||||
|
| G3 | 9 组同名全局/项目 Trellis 技能中 7 组正文不同,finish-work 和 update-spec 相同;另有全局 trellis-spec-bootstarp 拼写遗留 | 不能假设项目同名技能覆盖全局;会混用不同代际流程 | 10 个全局 Trellis 技能改为显式调用的兼容后备,项目内优先使用项目路径;暂不移除,避免影响尚未迁移的其他项目 |
|
||||||
|
| G4 | 用户级 trellis-start:44 仅凭 PRD 存在就路由到实施;全局 brainstorm:8 要求穷尽式问答,:39 又要求总是先有 task | 与本项目按状态及已批准方案推进的合同冲突 | 后备描述注明项目 workflow 为准;不继续把旧全局正文当最新项目合同 |
|
||||||
|
| G5 | 合同/文档审核描述列举大量类型;find-skills 描述含普通 how-do-I 问法;ui-ux-pro-max 描述常驻大量数据库数量 | 描述负担与宽触发风险,不代表技能功能应删除 | 缩短描述,保留法律/非法律排除条件;普通业务问答不触发技能发现;库数量与功能清单放正文 |
|
||||||
|
|
||||||
|
全局 Trellis 目录均没有 agents/openai.yaml 的显式调用限制;用户 Codex 配置未发现 skills.config 条目。两套审核技能没有安装锁记录,不能编造上游或自动重装。当前确认的 find-skills 上游之外,其余自建/复制来源暂保留为未核实。
|
||||||
|
|
||||||
|
### 项目规则精简清单
|
||||||
|
|
||||||
|
| ID | 位置 | 问题与拟改合同 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| R1 | `.trellis/workflow.md:178,219,232`、项目 trellis-start、trellis-brainstorm:8,61 | 先按意图分流;纯咨询无需先询问建 task。规划未批准时停留;已完成方案且用户已批准该版本则 start 并继续,不因回填文档或重复注入 planning 文字重新申请同一授权 |
|
||||||
|
| R2 | `AGENTS.md:15,17`、project-governance | 明确小改/普通/高风险边界;update_plan 不可用时使用已有任务执行文档记录,不为工具缺失无限停工,也不另建计划系统 |
|
||||||
|
| R3 | 项目 trellis-start:12、trellis-before-dev:33、`.trellis/spec/guides/index.md:54` | 先分类后加载;已读且未变化的上下文不重复读取;跨层/共享契约变更再读思考指南和查引用,取消 ANY value/ALWAYS 的无差别规则 |
|
||||||
|
| R4 | 项目 trellis-check:39、`.trellis/workflow.md:583` | 先按当前任务识别改动,不把工作区其他任务的 dirty 文件算进来;相关增量验证后做一次任务级最终验证,不等于全仓每轮测试;既有失败报告,不越界修业务代码 |
|
||||||
|
| R5 | 项目 trellis-finish-work:22,32,56 | 取消无条件归档当前任务、顺带盘点其他任务和声称总会自动提交;区分阶段完成与整个任务完成;提交/归档按明确授权,保留 session_auto_commit=false |
|
||||||
|
| R6 | `AGENTS.md:23,53,54` | 纯规则治理不要求公网部署;业务本地验证完成与可公网测试分别陈述;生产、资金、真实数据仍另批;高风险独立复核不能被主会话自查冒充 |
|
||||||
|
| R7 | `AGENTS.md:22,29,55` | 当前可用技能目录及会话未提供 frontend-design,规则引用不可直接执行;不擅自安装或用其他视觉体系替换,明确缺失时仅受限维护既有样式,需视觉裁决时报告缺项 |
|
||||||
|
|
||||||
|
规则内容分工:AGENTS 保留入口、风险和边界;project-governance 承载项目合同;workflow 保留状态路由与阶段索引;Skills 承载对应步骤。优先消除语义重复,不以文件长度为删除依据,不删除解析器依赖的状态标签或阶段编号。
|
||||||
|
|
||||||
|
### 官方与上游参考
|
||||||
|
|
||||||
|
- OpenAI Codex Skills:`https://developers.openai.com/codex/skills/`;说明渐进加载、同名技能不合并、allow_implicit_invocation 及按路径 enabled=false。仅借用这些加载能力,不把网站跳转后的其他产品内容混入项目规则。
|
||||||
|
- Trellis 固定源码:`https://github.com/mindfold-ai/Trellis/tree/v0.6.16/packages/cli/src/templates`;本轮逐文件比较以已安装的同版本模板为实际证据。
|
||||||
|
- Trellis 版本清单:同仓库 `packages/cli/src/migrations/manifests/0.6.16.json`,前阶段已读取。
|
||||||
|
- find-skills:安装锁确认 `https://github.com/vercel-labs/skills`;其他无来源记录的自建技能不臆测更新地址。
|
||||||
|
|
||||||
|
本轮只读比较、完整目录摘要和隔离复现完成;上述 G/P/R 处置均为拟改方案,尚未应用。
|
||||||
|
|
||||||
|
## 第三阶段:加载验收与全局覆盖(2026-09-05)
|
||||||
|
|
||||||
|
### 实际加载结果与前述结论更正
|
||||||
|
|
||||||
|
此前从当前会话仍显示重复名称推断去重未生效,证据不充分。本轮独立启动本机 app-server 进程,仅调用 initialize 和 skills/list(forceReload=true),未启动模型 turn、代理或新业务任务。
|
||||||
|
|
||||||
|
- 项目上下文发现 47 个技能条目,用户上下文发现 35 个,均 0 个加载错误;条目总数包含停用项,不能当作启用总数。
|
||||||
|
- china-contract-review 与 document-review 各仅一份 enabled=true(共享目录);旧 Codex 目录副本均 enabled=false。停用配置已由真实加载器确认,而非仅由 TOML 文本推断。
|
||||||
|
- 10 个全局旧 Trellis 技能仍可显式调用,其 openai.yaml 均 allow_implicit_invocation=false;12 个项目技能正常发现。
|
||||||
|
- 两份停用审核副本的旧 YAML 仍设置 allow_implicit_invocation=true。按已批准去重目标补为 false,保留共享副本 true,不修改 UI 文本或删除目录。备份和 YAML 语义对比通过。
|
||||||
|
- skills/list 未返回 invocation policy 字段,因此策略值由 YAML 解析验证,不把它说成 API 已返回的字段;模型实际选技行为未运行。
|
||||||
|
- 当前客户端提示词可能保留旧列表或展示停用目录清单,具体来源未确认;不能以当前会话显示的名称断言新加载器失效。若以后客户端仍重复注入,需检查其生成列表是否过滤 enabled 和 implicit policy,不能继续盲目删除文件。
|
||||||
|
|
||||||
|
证据:私有第二阶段备份目录的 skill-discovery-final.json,包含两次上下文结果与方法/限制;本轮没有模型请求或子代理。
|
||||||
|
|
||||||
|
### 全局覆盖清单
|
||||||
|
|
||||||
|
审查采用“发现清单与描述全覆盖、正文按风险抽查”,不把所有 reference、assets 和脚本强制通读,也不声称已验证各领域算法、法律条款或输出内容。
|
||||||
|
|
||||||
|
| 范围 | 已覆盖与结论 | 处置 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 用户 AGENTS 与覆盖入口 | 用户 AGENTS 为 0 字节,未发现该用户目录的覆盖文件或 .agents AGENTS | 无需新增一份通用约束来填空 |
|
||||||
|
| 用户通用配置 | 未配置 instructions/developer_instructions 等额外规则注入;hooks 只有状态根和 3 条项目审核状态,未配置全局 executable command | 保留配置与其他项目状态,不把历史审核状态当全局工作流删除 |
|
||||||
|
| 系统内置技能 | 检查描述与来源边界,发现目录中也存在未向当前会话暴露的内部技能 | 随官方工具更新,不把它们当项目可改规则,也不擅自调用隐藏审查代理 |
|
||||||
|
| 10 个旧全局 Trellis 技能 | 已隔离为显式调用后备;项目运行流程由项目技能和 workflow 承担 | 保留以兼容尚未逐个迁移的项目 |
|
||||||
|
| 两组审核技能 | 完整目录重复已验证,实际启用状态已验证 | 共享副本保留;旧副本停用并禁用隐式调用 |
|
||||||
|
| find-skills、UI 技能 | 已缩短描述与宽触发;find-skills 链接保持一个实现 | 已完成批准调整;正文资源按需读取 |
|
||||||
|
| grill-me | 穷尽式问答是用户明确请求的特殊用途,不是默认 brainstorm | 保留,不因描述强势就删掉该能力 |
|
||||||
|
| security-best-practices、playwright | 触发对象和排除条件相对明确 | 保留;本轮不执行浏览器或审计业务安全 |
|
||||||
|
| gjb438c-doc-factory、regulated-sdk-delivery、new-project-research、paper-aigc-review、letpub-skills | 领域触发明确,阅读入口指向相应流程和参考资料 | 元数据/入口检查后保留;不审核专业内容或运行领域任务 |
|
||||||
|
| testing、workspace-hygiene、agents-bootstrap、mcp-tools-catalog、high-fidelity-sim-core | 正文抽查发现下表五项可复用旧约束 | 形成具体提案,未纳入上一阶段四批写入,不擅自修改 |
|
||||||
|
|
||||||
|
### 新增全局精简建议(已批准并于第四阶段实施)
|
||||||
|
|
||||||
|
以下路径相对用户技能根 .codex/skills,不记录完整本地路径。
|
||||||
|
|
||||||
|
| ID | 文件位置 | 影响 | 最小建议与保留保障 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| G6 | testing/SKILL.md:19-20,41 | 每条回复都输出状态胶囊且跨轮保持技能活跃,易形成重复状态噪声 | 仅测试阶段变化、交接或用户要求时输出紧凑状态;保留真实命令、结果、确定性与风险匹配验证 |
|
||||||
|
| G7 | workspace-hygiene/SKILL.md:13-20,28-29 | 将本地产物默认视为可删,硬编码不存在于多数项目的脚本,并把证据/日志目录归入可删除清单 | 先识别归属、可再生性和保留价值;未知/审计数据不默认删除;发现当前项目实际命令再按风险验证 |
|
||||||
|
| G8 | agents-bootstrap/SKILL.md:18,29,37-38 | 强制 MCP 目录、跨轮重新激活、旧 issues 流程和当前不存在的 plan 技能,可能把旧门禁重新生成进新 AGENTS | 沿用目标项目实际 task/issue 合同;可选工具和目录不作为无条件门禁;保留覆盖前方案和备份 |
|
||||||
|
| G9 | mcp-tools-catalog/SKILL.md:23,31 | 硬编码 functions.mcp__ 前缀和必须回复 CONFIRM,不适配不同客户端及已经给出的批准 | 从实际暴露工具名称生成目录,缺失如实报告;自然语言明确批准即可,不因换词再次确认 |
|
||||||
|
| G10 | high-fidelity-sim-core/SKILL.md:55,70 | 把每个模块提交作为继续工作的条件,技能暗含 Git 写入授权 | 改为模块级验证与可独立提交的变更范围,提交仅在明确要求时执行;保留数值精度、状态边界与可回滚证据 |
|
||||||
|
|
||||||
|
以上为第三阶段提出的 G6–G10 修改清单;用户随后明确批准,第四阶段已逐项实施,证据见 implement.md。没有重做 Trellis 升级、删除技能资源或运行无关业务任务。
|
||||||
|
|
||||||
|
### 来源和其他模型边界
|
||||||
|
|
||||||
|
- 内置 Codex 技能与 enabled/implicit 能力:官方 OpenAI 文档及当前本机真实加载器,避免套用其他客户端实现。
|
||||||
|
- 项目和旧全局 Trellis:固定 0.6.16 上游模板与原安装备份。
|
||||||
|
- find-skills:安装锁指向 vercel-labs/skills;其余五项新增提案未发现可信安装来源记录,不编造上游更新地址或自动重装。
|
||||||
|
- 其他模型继续使用清晰的文本适用范围、确认边界与完成标准;Codex 的 YAML/TOML 策略是平台适配,不宣称它能控制其他客户端。保留实体目录,未修改其他工具配置或业务项目。
|
||||||
|
|
||||||
|
独立只读复核仍未完成:当前会话明确要求主会话直接实施/检查,禁止派发 implement/check 子代理,不通过改名代理或另启模型进程绕过。真实加载器进程只验证配置发现,不是独立 reviewer。
|
||||||
|
|
||||||
|
## 第四阶段:G6–G10 实施结论(2026-09-05)
|
||||||
|
|
||||||
|
- testing 保留确定性、实际命令和最小有效验证;状态胶囊仅在结果/阻塞变化、交接或用户要求时输出,不跨任务强制保持技能活跃。
|
||||||
|
- workspace-hygiene 先识别归属、引用、可再生性和保留价值;生成物或未跟踪状态不等于可删。删除前明确路径与恢复方式,日志、报告和备份不默认归类为垃圾,使用当前项目实际检查命令。
|
||||||
|
- agents-bootstrap 沿用目标项目的真实工作流,不再生成强制 Issue CSV、plan 技能、提交循环或 MCP 目录门禁。保留覆盖前审阅、可恢复备份和原有用户修改保护。
|
||||||
|
- mcp-tools-catalog 从当前客户端实际工具清单取证,不假设命名前缀或配置即连通;明确自然语言批准有效,不要求特定确认词,不暗含 AGENTS 或服务器配置写入授权。
|
||||||
|
- high-fidelity-sim-core 仅调整两条提交纪律,改为模块级验证、可独立提交的变更集和明确授权后提交;数值精度、状态/时序、测量与参考资料要求未变。
|
||||||
|
|
||||||
|
五个技能均保留现有资源。描述排除条件和文本契约适用于其他模型;没有新增仅 Codex 可理解的工具依赖,也未改动其他工具配置。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"_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."}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# 执行清单
|
||||||
|
|
||||||
|
## 授权与阶段
|
||||||
|
|
||||||
|
用户已审阅落盘方案并批准 CLI 升级与项目只读预览,随后于 2026-09-05 批准本文件第二阶段四个补丁批次。任务为 in_progress,按该范围实施并验证,不重复申请同一授权;提交、归档、部署与业务修改仍未授权。
|
||||||
|
|
||||||
|
当前无原生 update_plan 工具,以工作流要求的本文件记录清单,不使用外部计划系统。已找到本机 Codex 二进制内置 apply_patch 入口,可用于规范编辑,无需更换会话或安装另一套工具。
|
||||||
|
|
||||||
|
## 已完成
|
||||||
|
|
||||||
|
- [x] 核对 0.6.12 安装来源与 Node.js 兼容性。
|
||||||
|
- [x] 查询目标固定版本 0.6.16,读取四个版本的上游迁移清单。
|
||||||
|
- [x] 执行旧 CLI 的 update --dry-run,并明确其预览限制。
|
||||||
|
- [x] 核对目标版本 inline 空上下文门禁例外。
|
||||||
|
- [x] 补齐 PRD、设计和执行文件,保留已批准范围。
|
||||||
|
|
||||||
|
## CLI 升级与只读预览
|
||||||
|
|
||||||
|
1. 在规划门禁允许进入实施后,核对当前 task 和受保护工作区基线;不启动其他任务。
|
||||||
|
2. 在仓库与技能发现目录外建立私有备份,覆盖本次拟影响的 CLI 安装及项目受管文件清单,校验归档可读。
|
||||||
|
3. 执行 trellis upgrade --tag 0.6.16,失败时先检查安装结果,不自动使用 sudo、force 或另建 npm prefix。
|
||||||
|
4. 验证 trellis --version 和可执行文件来源;异常则按设计回滚。
|
||||||
|
5. 执行 trellis update --dry-run,不加 --force、--migrate 或 --allow-downgrade,不应用模板。
|
||||||
|
6. 对比项目前后摘要,验证只读预览未改写项目模板;记录新增冲突与兼容性事项。
|
||||||
|
|
||||||
|
## 后续需单独审阅
|
||||||
|
|
||||||
|
输出全局和项目逐文件精简清单后,再请求相应写入授权。默认不强制覆盖、不重装技能、不启用其他平台、不部署。若改动工作流,遵循高风险只读复核要求;当前 inline 不派发代理,不虚报独立复核通过。
|
||||||
|
|
||||||
|
## 本阶段执行结果(2026-09-05)
|
||||||
|
|
||||||
|
- 已备份原 CLI 安装及 89 个项目受管路径;两份 tar.gz 均通过归档可读性与 SHA-256 校验。
|
||||||
|
- 私有备份 ID:`hyb-oa-0.6.12-to-0.6.16-hOadBvGw`,位于用户本地状态目录的 trellis-maintenance 下,不在仓库或 Skills 发现路径内。
|
||||||
|
- 已执行 `trellis upgrade --tag 0.6.16`,退出码 0;`trellis --version` 和 npm 安装检查均确认 0.6.16,npm root 与可执行文件来源保持一致。
|
||||||
|
- 已执行新版 `trellis update --dry-run`,退出码 0:31 个模板更新、5 个本地定制冲突、51 个未变文件;未应用更新。
|
||||||
|
- 升级与预览前后核对 2,897 个 Git 跟踪及非忽略未跟踪文件,摘要和模式变化为 0。此核验发生在本次验收记录回填之前;回填仅修改当前任务文档。
|
||||||
|
- 项目 `.trellis/.version` 保持 0.6.12;CLI 提醒项目可更新属于预期,不是升级失败。
|
||||||
|
- 原项目 `task.py validate` 通过,inline 的两个上下文清单均为 0 个真实条目;没有为新门禁伪造条目。
|
||||||
|
- 原始升级日志、预览日志、安装信息、项目前置摘要和 verification.json 保留在同一私有备份目录,不复制完整本地路径到仓库文档。
|
||||||
|
- 本阶段没有修改业务代码、全局规则、项目模板,没有部署、提交或归档;未运行无关业务测试。
|
||||||
|
|
||||||
|
恢复旧 CLI 可沿原 npm 安装体系执行 `npm install -g @mindfoldhq/[email protected]`,随后核对 CLI 和安装来源;离线安装目录备份作为补充恢复材料。恢复操作本轮未执行。
|
||||||
|
|
||||||
|
## 验证与完成
|
||||||
|
|
||||||
|
- 本轮检查任务 JSON、规划文档非占位内容及尾随空白。
|
||||||
|
- CLI 阶段验证版本、安装来源、备份和项目不变性,不跑业务测试。
|
||||||
|
- 规则阶段按触及范围运行治理检查及正反触发场景;先记录既有失败,不为过门禁改业务代码。
|
||||||
|
- 交付汇报明确实际执行命令、未执行项和下一阶段边界;不自动提交或归档。
|
||||||
|
|
||||||
|
## 第二阶段执行与待批准补丁批次
|
||||||
|
|
||||||
|
已完成五处冲突对照、全局技能描述盘点、两组完整目录重复校验、旧模板对照和隔离兼容性复现。没有运行真实项目 update,没有修改全局配置/技能或项目治理文件。任务材料中的审查结论是本轮唯一仓库写入。
|
||||||
|
|
||||||
|
建议一次审阅以下写入范围,再在该范围内自主完成实现和针对性验证,不逐文件反复询问:
|
||||||
|
|
||||||
|
1. 项目模板:经官方 update 合并 31 个新版模板与五个冲突,保持现有配置、workspace 忽略和项目边界;任务运行时作为完整依赖组更新。
|
||||||
|
2. inline 兼容:最小适配 `.trellis/scripts/task.py`、`.trellis/scripts/common/task_context.py` 及必要的共享模式读取;同步已有治理规约和针对性测试,保持子代理门禁。不得只使用 --allow-empty-context 声称 validate 已解决。
|
||||||
|
3. 项目规则:调整 AGENTS、workflow、project-governance、guides/index 及 start/before-dev/brainstorm/check/finish-work 五个技能,使批准、读取、验证、收尾合同一致。只改本任务规则,不改现有业务实现。
|
||||||
|
4. 全局最小治理:两套完全重复审核技能在 Codex 中仅启用共享副本;10 个全局 Trellis 技能增加显式调用限制并明确兼容后备定位;缩短审核、find-skills、UI 技能描述。先备份拟改用户文件,保留其他工具可访问的目录,不重装或批量删除。
|
||||||
|
|
||||||
|
### 风险匹配验证矩阵
|
||||||
|
|
||||||
|
| 变化 | 最小验证 | 不默认执行 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 模板运行时 | task.py --help、get_context.py、当前任务 validate、Python 语法、完整依赖导入 | 业务全量测试/部署 |
|
||||||
|
| inline 适配 | inline 空清单、历史 seed、合法条目、坏 JSON、越界路径;子代理空清单仍失败;start/validate 一致 | 重写历史任务、伪造 JSONL |
|
||||||
|
| 状态与确认 | 无任务咨询、未批准规划、已批准规划、只回填文档、范围实质变化分别检查预期路由 | 每轮重新批准同一方案 |
|
||||||
|
| 读取与质量 | 单层小改只读相关规约;既有未变资料不重读;其他任务失败不越界修复 | 所有技能与业务文档通读 |
|
||||||
|
| 全局技能 | 元数据/配置语法、明确/隐式调用场景;新会话检查重复描述和发现结果 | 批量升级技能或其他平台配置 |
|
||||||
|
| 收尾 | 已授权阶段完成但整体未完成时不归档;无提交/生产授权不越界 | 自动提交、归档、真实数据操作 |
|
||||||
|
|
||||||
|
高风险工作流/运行时修改仍需只读复核;当前 inline 不派发 implement/check 代理,不能把主会话验证冒充独立 reviewer,交付记录必须明确复核方式与剩余缺口。
|
||||||
|
|
||||||
|
## 第二阶段实施结果(2026-09-05)
|
||||||
|
|
||||||
|
- 用户已批准上述四批写入,实际完成,不再停留于提案。CLI 与项目版本均为 0.6.16。
|
||||||
|
- 官方 `trellis update --create-new` 更新 31 个模板;五个冲突逐项处理,全部新生成的 .new 副本已处置。配置合入新版说明,保留关闭自动提交和 inline;保留 workspace 忽略及项目 workflow overlay;task_context 更新后作 inline 适配;channel 文档保留现有末尾空白规范,语义与上游一致。
|
||||||
|
- task.py 与 task_context.py 使用同一 get_codex_dispatch_mode。inline 空清单和历史 seed-only 可用,真实条目仍验证;子代理空上下文仍拒绝。验证器拒绝坏 JSON、缺失/空路径、空 reason、不存在文件及越界路径/符号链接。
|
||||||
|
- AGENTS、workflow、project-governance、guides/index 和五个步骤技能已精简:按风险读资料与验证;已批准规划可启动;缺少可选工具不循环停工;不自动提交、归档、部署或清理其他任务。
|
||||||
|
- 全局两份重复审核技能通过 Codex 路径配置停用,保留共享副本与原目录;10 个全局 Trellis 技能增加显式调用限制和兼容后备说明。四个技能描述缩短,法律/非法律边界保留,其他工具配置未改。
|
||||||
|
- 第二阶段私有备份 ID:`hyb-oa-rules-stage2-FcbImknm`,包含修改前 92 个项目路径与 15 个全局备份入口;两份归档可读并通过 SHA-256。官方 update 自动创建的仓库内备份触发敏感路径检查,已完整移入同一私有目录并收紧权限,未删除备份内容。
|
||||||
|
|
||||||
|
### 验证结果
|
||||||
|
|
||||||
|
- `cd server && npm test -- tests/unit/scripts/trellis-runtime-contract.test.js tests/unit/scripts/check-trellis-governance.test.js`:19/19 通过。涵盖两种模式的 start/validate、历史 seed、合法及非法条目、路径逃逸、真实 hook 状态解析与提交/归档边界。
|
||||||
|
- `python3 .trellis/scripts/task.py --help`、当前任务 validate、`get_context.py --mode phase --step 2.1 --platform codex`:通过;31 个 Python 文件 AST 语法检查通过。
|
||||||
|
- `node scripts/qa/check-trellis-governance.mjs`:PASS,0 errors / 0 warnings;已修复本任务 PRD 章节与 scope 缺失,未改业务检查规则。
|
||||||
|
- `node scripts/qa/check-process-contract.mjs --baseline --json` 与 `node scripts/qa/audit-docs.mjs --check-registry`:通过。
|
||||||
|
- 26 个技能 frontmatter、10 个显式调用策略、项目 YAML 解析通过;本机 Codex `features list` 能加载用户配置。与备份逐字对比确认原用户配置保留,仅追加两项已批准的停用设置。
|
||||||
|
- `git diff --check`:通过。保护基线核验:46 个路径变化均在批准范围;业务代码、原先用户修改的前后端 spec 均未触碰。
|
||||||
|
- 新版 update 只读复查无待自动更新项;剩余 11 个提示为明确保留的本地定制或末尾空白差异,不伪造回执哈希隐藏这些差异。
|
||||||
|
|
||||||
|
### 未完成的独立验收
|
||||||
|
|
||||||
|
- 新会话技能发现与实际隐式/显式调用尚未验证,当前运行会话可能仍使用旧的技能列表与已注入指令。配置解析成功不是实际新会话发现通过。
|
||||||
|
- 当前 inline 禁止派发 implement/check 代理,未启动独立 reviewer。高风险工作流与运行时独立只读复核仍待完成;19 项测试和主会话自查不冒充该门禁。
|
||||||
|
- 本轮没有提交、推送、归档、部署、业务代码修改或其他项目更新。任务保持 in_progress,以便继续复核,不声称整个治理任务完成。
|
||||||
|
|
||||||
|
## 第三阶段执行结果(2026-09-05)
|
||||||
|
|
||||||
|
- 补齐全局描述/来源/配置覆盖及有疑点技能正文审查;具体保留和新增五项建议见 design.md 第三阶段。
|
||||||
|
- 启动新的本机 app-server,仅执行 initialize 与 skills/list(forceReload=true),分别验证项目和用户上下文;不发送 turn/start、不启动模型或子代理。
|
||||||
|
- 真实发现断言通过:两组审核技能各一份启用,旧副本停用;项目 12 个技能可发现;10 个全局 Trellis 后备仍显式可用。两个停用副本同时补 allow_implicit_invocation=false,共享副本保持 true。
|
||||||
|
- 原先“重复名称仍出现即去重未生效”的说法不准确,已在 design.md 更正;总发现条目包括停用项。当前客户端提示词来源与模型实际选择行为未测试,不夸大为端到端 UI 验收。
|
||||||
|
- 额外备份 duplicate-policy-before.tar.gz 已校验;两份 YAML 语义对比确认只有调用策略布尔值改变。脱敏真实加载证据为 skill-discovery-final.json,位于既有私有备份目录。
|
||||||
|
- 未修改 testing/workspace-hygiene/agents-bootstrap/mcp-tools-catalog/high-fidelity-sim-core:它们是新增的明确提案,不属于前阶段已批准的实际写入清单。
|
||||||
|
- 剩余强制验收为独立只读复核;主会话和现有 19 项回归不充当独立 reviewer。当前模式不允许派发该 reviewer,不能以“继续”反复申请已经充分的操作授权。
|
||||||
|
|
||||||
|
## 第四阶段执行结果(2026-09-05)
|
||||||
|
|
||||||
|
用户已批准 G6–G10 五个具体技能文件。已使用本机原生补丁入口完成 testing、workspace-hygiene、agents-bootstrap、mcp-tools-catalog 和 high-fidelity-sim-core 的 SKILL.md 修改;未扩大到附属模板、脚本、调用元数据或其他技能。
|
||||||
|
|
||||||
|
- 私有备份 ID:hyb-oa-global-five-VmWeZjgM。skills-before.tar.gz 保存五个原目录,通过归档可读性和 SHA-256 校验;不存放在技能发现路径中。
|
||||||
|
- 完整备份比对确认只有预期五个 SKILL.md 内容变化;所有附属文件、链接和文件权限保持一致,无意外新增资源。
|
||||||
|
- YAML frontmatter、资源引用、尾随空白及针对性规则断言通过。保留测试确定性、删除前审批/证据保护、覆盖前备份、实际工具取证和仿真物理语义;移除每轮状态输出、硬编码验证、旧流程依赖、特殊确认词和暗含提交授权。
|
||||||
|
- 新的 app-server 进程执行 skills/list(forceReload=true),用户与项目上下文各找到五个唯一启用技能,返回描述与更新文件一致,加载错误为 0;未发送模型 turn 或派发代理。
|
||||||
|
- validation.json 和 discovery.json 保存在同一私有备份目录。真实加载验证不等于模型逐场景行为测试,后者未运行。
|
||||||
|
- 本轮修改为技能文本和当前任务记录,Trellis 运行时代码及业务代码未变化;复用此前 19 项运行时回归结果,不重复执行无关测试。
|
||||||
|
|
||||||
|
至此,所有已审查并获批的修改批次均已实施。任务仍不标记整体完成:唯一剩余强制门禁是独立只读复核,受当前 inline 模式限制未执行。没有提交、归档、部署或修改其他项目。
|
||||||
|
|
||||||
|
## 本轮收尾(2026-09-05)
|
||||||
|
|
||||||
|
用户要求先完成本轮工作。获批修改和可执行验证均已完成,任务由 in_progress 转为 review,停止继续扩展清理范围。复用输入未变化的 19 项回归与真实技能发现结果,完成最终治理、上下文和差异格式检查。
|
||||||
|
|
||||||
|
独立只读复核保留为唯一未完成的强制验收,不将本轮收尾解释为豁免或通过,不设置 completedAt。未提交、归档、部署或清理其他任务;后续只需针对本任务已有差异完成独立复核,不重做升级和已完成的精简。
|
||||||
|
|
||||||
|
## 项目复核更正与增量加固(2026-09-06)
|
||||||
|
|
||||||
|
用户已认可原项目 diff 的规则删改、hooks 和来源/哈希审查,并用 main 实测纠正 TR-01 归因。main 对假值 file 一律跳过;升级已拒绝无 marker 的空 file,仅保留带 marker 的遗留宽松。相对固定 0.6.16 原厂的本地差异,不等于相对 main 新引入回归;撤回因此阻断升级的结论。旧探针结果及原报告保留,三方对照另存 seed-attribution.json,不把旧失败改写成修后通过。
|
||||||
|
|
||||||
|
本会话选择用户允许的“原 task 内先小修再合”路径,不新建 task,不重做规划。只修改纯历史 seed 的识别条件、现有回归和对应 spec;不改 start、hooks、模板回执或用户级技能。原项目审查 PASS 与本次新增补丁待增量复核分别记录;G1–G10 用户级修改不在此次项目 diff 的独立复核范围。
|
||||||
|
|
||||||
|
- [x] 保存 48 文件增量前基线;main 保持干净,不覆盖其他升级改动。
|
||||||
|
- [x] main / 原厂 0.6.16 / 升级未修版用同一组合成输入、独立 Python 子进程完成对照。
|
||||||
|
- [x] 先补负向回归并在旧实现上确认失败,再将豁免缩到 inline 且对象仅有 `_example` 一个键。
|
||||||
|
- [x] 验证纯 seed 仍为零上下文、混合字段必拒绝,保留非 inline 门禁;同步精确 spec。
|
||||||
|
- [x] 跑针对性回归、治理/敏感扫描、语法/差异检查,交仅本轮增量 diff;不自动提交、合并、推送、归档或部署。
|
||||||
|
- [x] 用户对本次新增 seed 补丁完成独立增量复核并判定通过;独立探针及红绿复现见本文件最终验收记录,未借用原升级 PASS。
|
||||||
|
|
||||||
|
### 增量验证与交接
|
||||||
|
|
||||||
|
- 运行时代码只替换一行判定为 `inline and set(data) == {"_example"}`。混合 marker 统一进入既有 placeholder 拒绝分支,start、hooks 与模板哈希未改。
|
||||||
|
- 现有测试增加 30 个组合(10 种混合字段 × inline/sub-agent/auto)和 1 个纯 seed 后跟坏条目的场景;纯 seed 原用例补零计数及显式 sub-agent 断言。两份 manifest 均断言具体行号、完整 placeholder 诊断与 exit 1,没有宽泛正则。
|
||||||
|
- 新测试 + 修改前实现:exit 1,9 failed / 41 passed;9 个失败均为 inline 混合 seed 的假通过。应用一行加固后:exit 0,50/50 PASS,约 13 秒。负向结果保留于 seed-red-tests.log,正向结果另存 seed-green-tests.log。
|
||||||
|
- 四方对照见 seed-attribution-after.json:纯 seed 及合法条目仍通过,带 marker 的空/false/null file 从 0 收紧为 1;没有覆盖旧三方结果文件。
|
||||||
|
- 测试命令在复用的隔离 fixture 内执行,修改后的源码与测试先同步并逐字比对;只复用主工作区已有 node_modules,不安装依赖,测试所用 task/session 均为合成数据,不连接生产:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
review_root="$(cat /tmp/hyb-trellis-upgrade-review-pointer)"
|
||||||
|
(cd "$review_root/fixture/server" && env -i PATH="$PATH" HOME="$review_root/home" PYTHONDONTWRITEBYTECODE=1 node node_modules/vitest/vitest.mjs run --config vitest.review.config.cjs)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `python3 .trellis/scripts/task.py validate .trellis/tasks/09-05-trellis-upgrade-rule-cleanup`、`node --check server/tests/unit/scripts/trellis-runtime-contract.test.js`、28 个运行时 Python 文件 AST 语法检查通过。
|
||||||
|
- `node scripts/qa/check-trellis-governance.mjs`(含敏感扫描):0 errors / 0 warnings;`node scripts/qa/check-process-contract.mjs --baseline --json`、`node scripts/qa/audit-docs.mjs --check-registry`、`git diff --check`:exit 0。文档注册检查仍有既有 1 项 warning(22 个未登记文件),不计成零告警,也不顺手修复。
|
||||||
|
- 本轮仅 6 文件有增量:validator、既有回归文件、governance spec 与当前 task 的 prd/implement/task.json;其余 42 文件、全部文件模式、hooks、task.py 及哈希回执与增量前基线一致。main 未写入升级改动,保持干净。
|
||||||
|
- 本轮增量在主工作区忽略目录 `artifacts/qa-flow/trellis-upgrade-review/seed-hardening.diff`,报告更正位于同目录 review.md;旧完整 diff 和原探针结果均保留。任务回到 review,不以主会话加固验证冒充新的独立复核,不设置 completedAt。
|
||||||
|
|
||||||
|
## 独立复核通过与合入授权(2026-09-06)
|
||||||
|
|
||||||
|
- 用户独立逐文件对照两个审查 diff:48 文件、无增删,seed 增量仅 6 文件变化、42 文件不变;hooks、task.py 和模板哈希未变。独立复制 common 包验证各输入,仅收紧混合 marker,纯 seed、合法条目和具体诊断/行号符合合同。
|
||||||
|
- 用户先 cmp 核对 fixture 与受审源码/测试,再独立运行:修后 50/50 PASS(14.3 秒);另拷并还原旧判定后 9 failed / 41 passed。9 个失败均为 inline 混合 seed 的假通过,不是采信本会话日志。
|
||||||
|
- 历史兼容性分清扫描范围:main(492920c)有 138 行纯 `_example`;受审 worktree 有 136 行,混合键均为 0。两侧差异仅为 main 的错误可解释性/部署隔离两任务共 4 行,以及 worktree 的本任务 2 行;合并后预期为 140 行纯 seed。升级版 task_store 不再生成 seed 行。本会话复扫已确认提交前两侧计数,合入后再核验 140 行,不将不同基线计数混写。
|
||||||
|
- 两项非阻断观察已沉淀到现有 governance spec:固定上游 hook 按 file/file-or-path 真值计数或注入,不能替代更严的 validator;sub-agent 的纯 seed 先报 placeholder,而空清单报零上下文,区分 Python 文案与 governance 诊断键。不改 hooks、运行时代码、测试或哈希回执。
|
||||||
|
- 用户明确批准:只补上述文档收尾,把受审的 48 文件作为一个项目升级提交,在独立 worktree 提交后合入 main,保留 main 已有业务提交。不 push、归档、部署、修改业务或用户级技能,也不移除独立 worktree。
|
||||||
|
- 执行时仅 4 个文档/任务文件相对已审版本补充收尾:project-governance.md、prd.md、implement.md、task.json;其他 44 文件内容及模式必须保持一致。复用输入未变的独立运行时验收,补跑治理/敏感扫描、文档/上下文检查及合入后的针对性回归。
|
||||||
|
- 项目原 diff 与 seed 增量的独立复核均已通过;G1–G10 用户级全量独立复核仍不在这份项目 diff 的证明范围,不标记整体 completed、不设置 completedAt、不自动归档。提交和合并的实际标识以 Git 历史及本次交付回复为准。
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Trellis 升级与规则精简
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
先完成全局 Trellis CLI 稳定版升级与当前项目只读更新预览,再提出按来源、任务和风险分层的规则精简方案。全局只做一轮,项目分别处理,不因模型升级直接删除必要保障。
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
用户已明确批准 CLI 升级与只读预览,并于 2026-09-05 审阅逐文件方案后批准:项目模板合并至 0.6.16、inline 启动与验证适配、项目规则精简、全局重复技能停用及旧 Trellis 技能显式调用限制;随后批准 G6–G10 五个全局技能文件精简。无需再次询问相同范围。
|
||||||
|
|
||||||
|
2026-09-06:用户已完成原项目 diff 与 seed 加固增量的独立复核并判定通过,明确授权补齐文档收尾后,将此次项目升级独立提交并合入 main;不 push、归档或部署。G1–G10 用户级修改不在本次项目 diff 复核范围。
|
||||||
|
|
||||||
|
## 需求
|
||||||
|
|
||||||
|
- R1:升级前核对安装来源、兼容性和恢复路径,升级后验证 CLI 版本。
|
||||||
|
- R2:使用新 CLI 预览项目模板差异,识别本地定制;不得将旧 CLI 的预览当成目标版本预览。
|
||||||
|
- R3:记录同名技能、链接、全局与项目冲突及不合理门禁,先给逐文件方案再修改。
|
||||||
|
- R4:保护已有工作区改动、隐私及关闭自动提交的配置;按风险确定验证与完成边界。
|
||||||
|
|
||||||
|
## Out Of Scope
|
||||||
|
|
||||||
|
除上述明确获准的项目升级独立提交及合入 main 外,不执行未列入方案的迁移、强制覆盖、项目清理、push、部署、真实数据操作、stash、重置或归档。不审查或修改业务代码,不读取凭据或无关会话历史,不批量更新其他项目,不删除其他工具可能依赖的全局技能目录。
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- [x] 备份可验证,记录原版本、安装来源和恢复步骤。
|
||||||
|
- [x] 全局 CLI 为 0.6.16,实际可执行文件仍来自原安装体系。
|
||||||
|
- [x] 新版 CLI 的项目只读预览完成;项目模板版本和受保护文件未被改写。
|
||||||
|
- [x] 输出冲突清单、inline 上下文门禁处理建议和后续精简范围。
|
||||||
|
- [x] 如实区分规划完成、CLI 升级完成和项目规则治理完成,不以业务部署作为本任务完成条件。
|
||||||
|
|
||||||
|
以上为第一阶段历史验收。第二阶段已批准并实施;整体验收以第二阶段清单与待复核项为准,不自动归档。
|
||||||
|
|
||||||
|
## 第二阶段:逐文件审查
|
||||||
|
|
||||||
|
用户先批准只读冲突与重复技能审查,随后明确批准具体补丁写入;实施结果记录于 implement.md。
|
||||||
|
|
||||||
|
- [x] 五个预览冲突逐项区分真实定制、格式差异与旧模板内容。
|
||||||
|
- [x] 核对同名技能的正文及候选重复技能的完整目录,不凭名称删除。
|
||||||
|
- [x] 通过隔离复现验证混合版本导入风险及 inline 清单兼容性。
|
||||||
|
- [x] 给出最小改动批次、确认与完成标准、上游来源及验证矩阵。
|
||||||
|
- [x] 用户批准第二阶段方案中的项目和全局写入。
|
||||||
|
- [x] 模板与 inline 兼容完成并通过针对性测试。
|
||||||
|
- [x] 项目与全局规则精简完成,保留安全与质量边界。
|
||||||
|
- [x] 记录新会话发现验证和独立只读复核的实际完成状态,不虚报交付。
|
||||||
|
- [x] 新进程真实技能发现验证:app-server skills/list 强制刷新,项目/用户两种上下文均通过;停用副本与调用策略校验通过。现有客户端提示列表刷新和模型实际选技不在此项证明范围内。
|
||||||
|
- [x] 原项目 diff 的高风险工作流及运行时只读复核经用户认可;2026-09-06 已更正 TR-01 为遗留宽松的后续加固,不是升级回归。用户级 G1–G10 不在本次项目 diff 复核范围,不能据此标记其全量独立复核通过。
|
||||||
|
- [x] 本次 seed 加固及负向回归经用户独立增量复核通过:独立行为探针、50/50 绿与还原判定后的 9 红/41 绿均重现;历史 seed 兼容性及 6 变/42 不变的范围核验通过,不借用原升级 PASS 替代。
|
||||||
|
- [x] 补齐全局审查覆盖与保留/调整清单;G6–G10 五个技能文件经用户批准后已实施并验证。
|
||||||
|
- [x] 第四阶段仅修改五个 SKILL.md;附属资源、调用元数据、文件权限及专业仿真约束保持不变,新进程加载新描述通过。
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
按 implement.md 风险矩阵执行隔离运行时回归、技能/配置解析、工作流状态与引用检查、当前任务治理及任务外文件保护核验;不运行无关业务测试。新会话技能发现和独立只读复核需分别记录,不以静态检查替代。
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"id": "trellis-upgrade-rule-cleanup",
|
||||||
|
"name": "trellis-upgrade-rule-cleanup",
|
||||||
|
"title": "Trellis 升级与规则精简",
|
||||||
|
"description": "全局 CLI 与项目模板升级至 0.6.16,适配 inline 并精简已批准的项目及用户级规则,不涉及业务代码。",
|
||||||
|
"status": "review",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": "workflow-governance",
|
||||||
|
"package": null,
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "shunli",
|
||||||
|
"assignee": "shunli",
|
||||||
|
"createdAt": "2026-09-05",
|
||||||
|
"completedAt": null,
|
||||||
|
"branch": null,
|
||||||
|
"base_branch": "main",
|
||||||
|
"worktree_path": null,
|
||||||
|
"commit": null,
|
||||||
|
"pr_url": null,
|
||||||
|
"subtasks": [],
|
||||||
|
"children": [],
|
||||||
|
"parent": null,
|
||||||
|
"relatedFiles": [],
|
||||||
|
"notes": "2026-09-06 原项目升级及 seed 增量均经用户独立复核 PASS;TR-01 是遗留宽松的后续加固,不是升级回归。独立重现 50/50 绿与旧判定 9 红/41 绿,确认增量 6 变/42 不变;main 历史 138 行 seed 全为纯 marker。已补 hook/validator 不对称及诊断差异规约,用户明确授权本次项目升级独立提交并合入 main,执行结果以 Git 历史为准。不 push、归档或部署;保留 review 和空 completedAt,不将未覆盖的 G1–G10 用户级全量独立复核算作通过。",
|
||||||
|
"meta": {}
|
||||||
|
}
|
||||||
+39
-27
@@ -78,8 +78,10 @@ python3 ./.trellis/scripts/task.py list [--mine] [--status <s>]
|
|||||||
python3 ./.trellis/scripts/task.py list-archive
|
python3 ./.trellis/scripts/task.py list-archive
|
||||||
|
|
||||||
# Code-spec context (injected into implement/check agents via JSONL).
|
# Code-spec context (injected into implement/check agents via JSONL).
|
||||||
# `implement.jsonl` / `check.jsonl` are seeded on `task create` for sub-agent-capable
|
# `implement.jsonl` / `check.jsonl` are seeded (empty) on `task create` for sub-agent-capable
|
||||||
# platforms; the AI curates real spec + research entries during planning when needed.
|
# platforms; the AI curates real spec + research entries during planning. `validate` fails
|
||||||
|
# and `start` refuses empty manifests in sub-agent mode. Explicit Codex inline mode
|
||||||
|
# permits empty manifests; real entries still validate in either mode.
|
||||||
python3 ./.trellis/scripts/task.py add-context <name> <action> <file> <reason>
|
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 list-context <name> [action]
|
||||||
python3 ./.trellis/scripts/task.py validate <name>
|
python3 ./.trellis/scripts/task.py validate <name>
|
||||||
@@ -99,7 +101,7 @@ 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.
|
> 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.
|
**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, `task.py start` reports degraded session tracking but still records the task state; retain the task path in context and do not create a replacement task. `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
|
### Workspace System
|
||||||
|
|
||||||
@@ -145,6 +147,7 @@ python3 ./.trellis/scripts/get_context.py --mode phase --step <X.Y> # detailed
|
|||||||
|
|
||||||
TAG ↔ PHASE scoping:
|
TAG ↔ PHASE scoping:
|
||||||
[workflow-state:no_task] → no active task; before Phase 1
|
[workflow-state:no_task] → no active task; before Phase 1
|
||||||
|
[workflow-state:task_error] → active task record is unreadable; repair it before continuing
|
||||||
[workflow-state:planning] → all of Phase 1 (status='planning')
|
[workflow-state:planning] → all of Phase 1 (status='planning')
|
||||||
[workflow-state:planning-inline] → Codex inline variant of Phase 1
|
[workflow-state:planning-inline] → Codex inline variant of Phase 1
|
||||||
[workflow-state:in_progress] → Phase 2 + Phase 3.2-3.4
|
[workflow-state:in_progress] → Phase 2 + Phase 3.2-3.4
|
||||||
@@ -177,7 +180,7 @@ Phase 3: Finish → verify, update spec, commit, and wrap up
|
|||||||
|
|
||||||
### Request Triage
|
### 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.
|
- Pure consultation and small, isolated edits need no task unless requested. Non-trivial work needs a task and an approved plan; ask for task creation only when not already authorized.
|
||||||
- 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.
|
- 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.
|
- User approval to create a task is not approval to start implementation. Planning still happens first.
|
||||||
|
|
||||||
@@ -201,10 +204,18 @@ Create new children with `task.py create "<title>" --slug <name> --parent <paren
|
|||||||
|
|
||||||
[workflow-state:no_task]
|
[workflow-state:no_task]
|
||||||
No active task. First classify the current turn and ask for task-creation consent before creating any Trellis 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.
|
Pure consultation or a small isolated edit: respond or execute within scope without asking to create a task.
|
||||||
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.
|
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]
|
[/workflow-state:no_task]
|
||||||
|
|
||||||
|
<!-- Per-turn breadcrumb: shown when the active task record cannot be read. -->
|
||||||
|
|
||||||
|
[workflow-state:task_error]
|
||||||
|
The active task record could not be read. Do not create or activate another task.
|
||||||
|
Inspect the task directory named above and repair its task.json. It must be a valid JSON object with a non-empty status.
|
||||||
|
Preserve existing task fields and artifacts. If the correct status cannot be determined safely, ask the user before reconstructing the record.
|
||||||
|
[/workflow-state:task_error]
|
||||||
|
|
||||||
### Phase 1: Plan
|
### Phase 1: Plan
|
||||||
- 1.0 Create task `[required · once]` (only after task-creation consent)
|
- 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.1 Requirement exploration `[required · repeatable]` (`prd.md`; complex tasks also need `design.md` + `implement.md`)
|
||||||
@@ -216,8 +227,8 @@ Complex task: ask the user if you can create a Trellis task and enter the planni
|
|||||||
<!-- Per-turn breadcrumb: shown throughout Phase 1 (status='planning') -->
|
<!-- Per-turn breadcrumb: shown throughout Phase 1 (status='planning') -->
|
||||||
|
|
||||||
[workflow-state:planning]
|
[workflow-state:planning]
|
||||||
Load `trellis-brainstorm`; stay in planning.
|
If the current plan is complete and the user has approved that version, run `task.py start` and continue execution. Otherwise use `trellis-brainstorm` to complete only the missing decisions and artifacts. Do not repeat approval for unchanged scope or clerical backfill.
|
||||||
Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`.
|
Lightweight: `prd.md` can be enough. Complex: `prd.md`, `design.md`, and `implement.md` must be reviewed before start; an already recorded approval satisfies this gate.
|
||||||
Multi-deliverable scope: consider a parent task plus independently verifiable child tasks; dependencies must be written in child artifacts, not implied by tree position.
|
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.
|
Sub-agent mode: curate `implement.jsonl` and `check.jsonl` as spec/research manifests before start.
|
||||||
[/workflow-state:planning]
|
[/workflow-state:planning]
|
||||||
@@ -229,8 +240,8 @@ Sub-agent mode: curate `implement.jsonl` and `check.jsonl` as spec/research mani
|
|||||||
into a sub-agent. -->
|
into a sub-agent. -->
|
||||||
|
|
||||||
[workflow-state:planning-inline]
|
[workflow-state:planning-inline]
|
||||||
Load `trellis-brainstorm`; stay in planning.
|
If the current plan is complete and the user has approved that version, run `task.py start` and continue execution. Otherwise use `trellis-brainstorm` to complete only the missing decisions and artifacts. Do not repeat approval for unchanged scope or clerical backfill.
|
||||||
Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`.
|
Lightweight: `prd.md` can be enough. Complex: `prd.md`, `design.md`, and `implement.md` must be reviewed before start; an already recorded approval satisfies this gate.
|
||||||
Multi-deliverable scope: consider a parent task plus independently verifiable child tasks; dependencies must be written in child artifacts, not implied by tree position.
|
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`.
|
Inline mode: skip jsonl curation; Phase 2 reads artifacts/specs via `trellis-before-dev`.
|
||||||
[/workflow-state:planning-inline]
|
[/workflow-state:planning-inline]
|
||||||
@@ -306,13 +317,13 @@ When a user request matches one of these intents inside an active task, route fi
|
|||||||
|
|
||||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
[/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]
|
[codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness]
|
||||||
|
|
||||||
- Planning or unclear requirements -> `trellis-brainstorm`.
|
- Planning or unclear requirements -> `trellis-brainstorm`.
|
||||||
- Before editing -> `trellis-before-dev`; after editing -> `trellis-check`.
|
- Before editing -> `trellis-before-dev`; after editing -> `trellis-check`.
|
||||||
- Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`.
|
- Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`.
|
||||||
|
|
||||||
[/codex-inline, Kilo, Antigravity, Devin]
|
[/codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness]
|
||||||
|
|
||||||
### Guardrails
|
### Guardrails
|
||||||
|
|
||||||
@@ -347,7 +358,7 @@ python3 ./.trellis/scripts/task.py create "<task title>" --slug <name>
|
|||||||
|
|
||||||
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.
|
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.
|
After creation, the breadcrumb reflects planning. Complete missing artifacts and approval; if the current plan is already approved, start it without repeating confirmation.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -389,11 +400,11 @@ Spawn the research sub-agent:
|
|||||||
|
|
||||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
[/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]
|
[codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness]
|
||||||
|
|
||||||
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.
|
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]
|
[/codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness]
|
||||||
|
|
||||||
**Research artifact conventions**:
|
**Research artifact conventions**:
|
||||||
- One file per research topic (e.g. `research/auth-library-comparison.md`)
|
- One file per research topic (e.g. `research/auth-library-comparison.md`)
|
||||||
@@ -453,11 +464,11 @@ 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]
|
[/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]
|
[codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness]
|
||||||
|
|
||||||
Skip this step. Context is loaded directly by the `trellis-before-dev` skill in Phase 2.
|
Skip this step. Context is loaded directly by the `trellis-before-dev` skill in Phase 2.
|
||||||
|
|
||||||
[/codex-inline, Kilo, Antigravity, Devin]
|
[/codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness]
|
||||||
|
|
||||||
#### 1.4 Activate task `[required · once]`
|
#### 1.4 Activate task `[required · once]`
|
||||||
|
|
||||||
@@ -467,11 +478,11 @@ After artifact review, flip the task status to `in_progress`:
|
|||||||
python3 ./.trellis/scripts/task.py start <task-dir>
|
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.
|
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. In this project, explicit Codex inline mode permits empty and legacy seed-only manifests; real entries still validate. Sub-agent mode requires curated entries. Start and validate both read the configured dispatch mode.
|
||||||
|
|
||||||
After this command succeeds, the breadcrumb auto-switches to `[workflow-state:in_progress]`, and the rest of Phase 2 / 3 follows.
|
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.
|
If session identity is unavailable, inspect the actual command result: 0.6.16 can start in degraded tracking mode. Preserve the explicit task path and report the missing pointer rather than repeatedly restarting.
|
||||||
|
|
||||||
#### 1.5 Completion criteria
|
#### 1.5 Completion criteria
|
||||||
|
|
||||||
@@ -541,15 +552,15 @@ The platform prelude auto-handles the context load requirement:
|
|||||||
|
|
||||||
[/Kiro]
|
[/Kiro]
|
||||||
|
|
||||||
[codex-inline, Kilo, Antigravity, Devin]
|
[codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness]
|
||||||
|
|
||||||
1. Load the `trellis-before-dev` skill to read project guidelines
|
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
|
2. Read `{TASK_DIR}/prd.md`, then `design.md` if present, then `implement.md` if present
|
||||||
3. Consult materials under `{TASK_DIR}/research/`
|
3. Consult materials under `{TASK_DIR}/research/`
|
||||||
4. Implement the code per reviewed artifacts
|
4. Implement the code per reviewed artifacts
|
||||||
5. Run project lint and type-check
|
5. Run the checks selected for the current task and affected layers; do not run unrelated business checks for documentation or rule-only work.
|
||||||
|
|
||||||
[/codex-inline, Kilo, Antigravity, Devin]
|
[/codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness]
|
||||||
|
|
||||||
#### 2.2 Quality check `[required · repeatable]`
|
#### 2.2 Quality check `[required · repeatable]`
|
||||||
|
|
||||||
@@ -569,18 +580,18 @@ The check agent's job:
|
|||||||
|
|
||||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
[/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]
|
[codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness]
|
||||||
|
|
||||||
Load the `trellis-check` skill and verify the code per its guidance:
|
Load the `trellis-check` skill and verify the code per its guidance:
|
||||||
- Spec compliance
|
- Spec compliance
|
||||||
- lint / type-check / tests
|
- lint / type-check / tests
|
||||||
- Cross-layer consistency (when changes span layers)
|
- Cross-layer consistency (when changes span layers)
|
||||||
|
|
||||||
If issues are found → fix → re-check, until green.
|
Fix task-caused issues and rerun affected checks. Report pre-existing or unrelated failures without widening scope; stop retrying when a genuine external prerequisite prevents progress.
|
||||||
|
|
||||||
[/codex-inline, Kilo, Antigravity, Devin]
|
[/codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness]
|
||||||
|
|
||||||
**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.
|
**Final pass**: verify the complete task diff once against its acceptance criteria and affected layers, not every dirty file in the repository. Reuse passing results if their inputs have not changed. Expand checks only for shared contracts, cross-layer behavior or risk identified by the task.
|
||||||
|
|
||||||
#### 2.3 Rollback `[on demand]`
|
#### 2.3 Rollback `[on demand]`
|
||||||
|
|
||||||
@@ -616,7 +627,7 @@ Update the docs under `.trellis/spec/` accordingly. Even if the conclusion is "n
|
|||||||
|
|
||||||
**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.
|
**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.
|
Only commit when the user explicitly requests it. Otherwise report the verified changes and continue to 3.5 without treating an uncommitted diff as incomplete implementation. Never auto-commit or archive as a side effect of finishing a session.
|
||||||
|
|
||||||
**Step-by-step**:
|
**Step-by-step**:
|
||||||
|
|
||||||
@@ -666,7 +677,7 @@ The AI drives a batched commit of this task's code changes so `/finish-work` can
|
|||||||
|
|
||||||
#### 3.5 Wrap-up reminder
|
#### 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).
|
Report implementation, verification, independent-review and deployment status separately. Archive only a genuinely completed task when explicitly requested; a partial stage remains active. Update existing task notes instead of creating duplicate retrospective documents.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -686,6 +697,7 @@ All tag blocks live in the `## Phase Index` section above, immediately after eac
|
|||||||
| Scope | Corresponding tag |
|
| Scope | Corresponding tag |
|
||||||
|---|---|
|
|---|---|
|
||||||
| No active task (before Phase 1) | `[workflow-state:no_task]` (after the Phase Index ASCII art) |
|
| No active task (before Phase 1) | `[workflow-state:no_task]` (after the Phase Index ASCII art) |
|
||||||
|
| Active task record unreadable | `[workflow-state:task_error]` (repair the existing task before continuing) |
|
||||||
| All of Phase 1 (task created → ready for implementation) | `[workflow-state:planning]` (after Phase 1 summary) |
|
| All of Phase 1 (task created → ready for implementation) | `[workflow-state:planning]` (after Phase 1 summary) |
|
||||||
| Codex inline Phase 1 | `[workflow-state:planning-inline]` |
|
| 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) |
|
| Phase 2 + Phase 3.2–3.4 (implementation + check + wrap-up) | `[workflow-state:in_progress]` (after Phase 2 summary) |
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## Trellis-first 入口
|
## Trellis-first 入口
|
||||||
|
|
||||||
- 开始任何非微小开发、审查、测试或文档治理任务前,先读 `.trellis/workflow.md`。
|
- 非微小任务先读 `.trellis/workflow.md` 的阶段索引和当前步骤,按需读取关联规约;本会话已读且未变化的资料不重复通读。纯咨询与小改不默认建 task。
|
||||||
- Trellis task 是后续需求拆解、执行、验证和复盘的执行合同;新任务不再强制绑定 `issues/*.csv`。
|
- Trellis task 是后续需求拆解、执行、验证和复盘的执行合同;新任务不再强制绑定 `issues/*.csv`。
|
||||||
- `prd.md` 是任务需求和验收入口;复杂任务补 `design.md` 或 `implement.md`;`implement.jsonl` / `check.jsonl` 只登记稳定 spec 或 research 上下文。
|
- `prd.md` 是任务需求和验收入口;复杂任务补 `design.md` 或 `implement.md`;`implement.jsonl` / `check.jsonl` 只登记稳定 spec 或 research 上下文。
|
||||||
- `issues/*.csv` 保留为历史审计材料或特殊外部交付导出格式,不再作为新开发的默认前置合同。
|
- `issues/*.csv` 保留为历史审计材料或特殊外部交付导出格式,不再作为新开发的默认前置合同。
|
||||||
@@ -12,15 +12,15 @@
|
|||||||
|
|
||||||
## 不可违反约束
|
## 不可违反约束
|
||||||
|
|
||||||
- 编码前先方案:非微小改动必须先给实现方案并等待批准。
|
- 编码前先方案:非微小改动先给方案并批准;已批准的同一范围自主完成实施和针对性验证,只有范围、风险或验收发生实质变化才重新确认。
|
||||||
- 先有 Trellis task 再开发:非微小改动必须先创建或更新 `.trellis/tasks/<task>/`。
|
- 先有 Trellis task 再开发:非微小改动必须先创建或更新 `.trellis/tasks/<task>/`。
|
||||||
- 计划工具约束:使用 Codex 原生 `update_plan` 做执行清单,不用外部自定义 plan 替代。
|
- 执行清单优先使用可用的原生 `update_plan`;不可用时用当前任务已有 `implement.md`,小改可在回复记录,不另建计划系统或因此停工。
|
||||||
- Trellis-only:hyb-oa 交付、试运行、视觉复核、报销/OCR/附件、企业微信或部署类任务统一从当前 Trellis task、`.trellis/workflow.md`、`.trellis/spec/hyb-oa/` 和 `docs/` 组织上下文。
|
- Trellis-only:hyb-oa 交付、试运行、视觉复核、报销/OCR/附件、企业微信或部署类任务统一从当前 Trellis task、`.trellis/workflow.md`、`.trellis/spec/hyb-oa/` 和 `docs/` 组织上下文。
|
||||||
- 中文沟通;commit message 使用中文并引用 Trellis task 名称或任务 ID。
|
- 中文沟通;commit message 使用中文并引用 Trellis task 名称或任务 ID。
|
||||||
- 不泄露密码、token、Secret、真实票据、合同正文、银行资料、完整账号、完整本地路径或原始 payload。
|
- 不泄露密码、token、Secret、真实票据、合同正文、银行资料、完整账号、完整本地路径或原始 payload。
|
||||||
- 不轻易新建文档;优先更新现有事实源、模块规约、操作手册、证据文档或当前 Trellis task。
|
- 不轻易新建文档;优先更新现有事实源、模块规约、操作手册、证据文档或当前 Trellis task。
|
||||||
- 页面视觉与交互设计以 `frontend-design` skill 为准;`docs/产品设计规则.md` 仅保留业务体验事实、信息架构和验收边界。
|
- 页面视觉裁决使用可用的 `frontend-design` skill;缺失时仅维护既有样式,需新视觉方案则报告缺项,不擅自安装或替换设计体系。`docs/产品设计规则.md` 保留业务体验、信息架构和验收边界。
|
||||||
- 面向用户可测试的改动,只有部署与公网检查通过后才能告知用户可以测试。
|
- 区分本地验证完成和公网可测试:只有部署与公网检查通过后才能告知用户可公网测试;纯规则、文档和本地工具治理不要求部署。
|
||||||
|
|
||||||
## 当前项目事实源
|
## 当前项目事实源
|
||||||
|
|
||||||
@@ -52,6 +52,7 @@
|
|||||||
- 默认按 Trellis workflow 执行:task/prd -> design/implement notes(按需) -> implement -> check -> spec/docs update -> commit -> finish。
|
- 默认按 Trellis workflow 执行:task/prd -> design/implement notes(按需) -> implement -> check -> spec/docs update -> commit -> finish。
|
||||||
- 涉及权限模型、状态机、workflow、数据迁移、审批规则时,实施后必须启动只读 review,不让 reviewer 改代码。
|
- 涉及权限模型、状态机、workflow、数据迁移、审批规则时,实施后必须启动只读 review,不让 reviewer 改代码。
|
||||||
- 公网部署、生产配置、真实数据写入、数据库重置、企业微信写入和真实付款必须另行获得明确批准。
|
- 公网部署、生产配置、真实数据写入、数据库重置、企业微信写入和真实付款必须另行获得明确批准。
|
||||||
|
- 独立复核受工具或 inline 模式限制时,完成可执行的验证并明确标记待独立复核,不虚报通过或反复索取同一授权。提交、推送、归档必须有明确授权;阶段完成不等于整个任务完成。
|
||||||
- 若 Trellis 规则与业务文档冲突:当前 Trellis task PRD/设计 > 当前模块规约 > 业务规约和 `docs/产品设计规则.md` 中的体验事实 > Trellis spec > 历史证据;前端视觉裁决优先使用 `frontend-design` skill,历史 Issue CSV 只用于追溯。
|
- 若 Trellis 规则与业务文档冲突:当前 Trellis task PRD/设计 > 当前模块规约 > 业务规约和 `docs/产品设计规则.md` 中的体验事实 > Trellis spec > 历史证据;前端视觉裁决优先使用 `frontend-design` skill,历史 Issue CSV 只用于追溯。
|
||||||
|
|
||||||
<!-- TRELLIS:START -->
|
<!-- TRELLIS:START -->
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { spawnSync } = require("node:child_process");
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(__dirname, "../../../..");
|
||||||
|
const scriptsRoot = path.join(repoRoot, ".trellis/scripts");
|
||||||
|
|
||||||
|
describe("Trellis runtime manifest contract", () => {
|
||||||
|
let root;
|
||||||
|
let taskDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
root = fs.mkdtempSync(path.join(os.tmpdir(), "trellis-runtime-contract-"));
|
||||||
|
taskDir = path.join(root, ".trellis/tasks/fixture");
|
||||||
|
fs.mkdirSync(taskDir, { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(root, ".trellis/spec"));
|
||||||
|
fs.writeFileSync(path.join(root, ".trellis/spec/contract.md"), "# Contract\n");
|
||||||
|
fs.writeFileSync(path.join(taskDir, "task.json"), JSON.stringify({ status: "planning" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function runCase(mode, content, command = "validate") {
|
||||||
|
fs.writeFileSync(path.join(root, ".trellis/config.yaml"), `session_auto_commit: false\ncodex:\n dispatch_mode: ${mode}\n`);
|
||||||
|
for (const name of ["implement", "check"]) {
|
||||||
|
fs.writeFileSync(path.join(taskDir, `${name}.jsonl`), content);
|
||||||
|
}
|
||||||
|
const runner = `
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
sys.path.insert(0, sys.argv[1])
|
||||||
|
import task
|
||||||
|
from common.task_context import cmd_validate
|
||||||
|
root = Path(sys.argv[2])
|
||||||
|
args = argparse.Namespace(dir=".trellis/tasks/fixture", allow_empty_context=False)
|
||||||
|
if sys.argv[3] == "start":
|
||||||
|
with patch("task.get_repo_root", return_value=root), patch("task.resolve_context_key", return_value=None), patch("task._record_start_state"):
|
||||||
|
result = task.cmd_start(args)
|
||||||
|
else:
|
||||||
|
with patch("common.task_context.get_repo_root", return_value=root):
|
||||||
|
result = cmd_validate(args)
|
||||||
|
sys.exit(0 if result == 0 else 1)
|
||||||
|
`;
|
||||||
|
const result = spawnSync("python3", ["-c", runner, scriptsRoot, root, command], {
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, PYTHONDONTWRITEBYTECODE: "1" },
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
expect(result.stderr).not.toContain("Traceback");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each(["validate", "start"])("permits empty inline manifests at %s", (command) => {
|
||||||
|
expect(runCase("inline", "", command).status).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["validate", "start"])("keeps the sub-agent empty gate at %s", (command) => {
|
||||||
|
expect(runCase("sub-agent", "", command).status).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts legacy seeds only in inline mode", () => {
|
||||||
|
const seed = JSON.stringify({ _example: "legacy" });
|
||||||
|
const result = runCase("inline", seed);
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
for (const name of ["implement", "check"]) {
|
||||||
|
expect(result.stdout).toContain(`${name}.jsonl: ✓ (0 entries)`);
|
||||||
|
}
|
||||||
|
for (const mode of ["sub-agent", "auto"]) {
|
||||||
|
expect(runCase(mode, seed).status).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.each(["inline", "sub-agent", "auto"])("mixed legacy markers in %s", (mode) => {
|
||||||
|
it.each([
|
||||||
|
{ file: "", reason: "" },
|
||||||
|
{ file: false, reason: "invalid" },
|
||||||
|
{ file: null, reason: "invalid" },
|
||||||
|
{ path: "", reason: "invalid" },
|
||||||
|
{ path: false, reason: "invalid" },
|
||||||
|
{ path: null, reason: "invalid" },
|
||||||
|
{ reason: "" },
|
||||||
|
{ type: "directory" },
|
||||||
|
{ unexpected: false },
|
||||||
|
{ file: ".trellis/spec/contract.md", reason: "runtime contract" },
|
||||||
|
])("rejects a marker combined with %j", (fields) => {
|
||||||
|
const result = runCase(mode, JSON.stringify({ _example: "legacy", ...fields }));
|
||||||
|
expect(result.status).toBe(1);
|
||||||
|
for (const name of ["implement", "check"]) {
|
||||||
|
expect(result.stdout).toContain(`${name}.jsonl:1: Placeholder \`_example\` row left by an older task.py create`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a pure seed hide an invalid following row", () => {
|
||||||
|
const content = [
|
||||||
|
{ _example: "legacy" },
|
||||||
|
{ file: "", reason: "" },
|
||||||
|
].map((entry) => JSON.stringify(entry)).join("\n");
|
||||||
|
const result = runCase("inline", content);
|
||||||
|
expect(result.status).toBe(1);
|
||||||
|
for (const name of ["implement", "check"]) {
|
||||||
|
expect(result.stdout).toContain(`${name}.jsonl:2: file must be a non-empty string path`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["inline", "auto"])("accepts curated entries in %s", (mode) => {
|
||||||
|
const entry = JSON.stringify({ file: ".trellis/spec/contract.md", reason: "runtime contract" });
|
||||||
|
expect(runCase(mode, entry).status).toBe(0);
|
||||||
|
expect(runCase(mode, entry, "start").status).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"{bad json",
|
||||||
|
"[]",
|
||||||
|
JSON.stringify({ file: ".trellis/spec/contract.md", reason: "" }),
|
||||||
|
JSON.stringify({ file: "", reason: "missing path" }),
|
||||||
|
JSON.stringify({ file: 42, reason: "wrong type" }),
|
||||||
|
JSON.stringify({ file: ".trellis/spec/missing.md", reason: "missing file" }),
|
||||||
|
])("rejects malformed inline input: %s", (content) => {
|
||||||
|
expect(runCase("inline", content).status).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects paths and symlinks outside the project", () => {
|
||||||
|
const outside = `${root}-outside.md`;
|
||||||
|
fs.writeFileSync(outside, "# Outside\n");
|
||||||
|
try {
|
||||||
|
fs.symlinkSync(outside, path.join(root, ".trellis/spec/outside.md"));
|
||||||
|
for (const file of [outside, `../${path.basename(outside)}`, ".trellis/spec/outside.md"]) {
|
||||||
|
expect(runCase("inline", JSON.stringify({ file, reason: "escape" })).status).toBe(1);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(outside, { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Trellis workflow routing contract", () => {
|
||||||
|
it("loads consultation and approved-planning rules through the real hook parser", () => {
|
||||||
|
const runner = `
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
root = Path(sys.argv[1])
|
||||||
|
sys.path.insert(0, str(root / ".trellis/scripts"))
|
||||||
|
spec = importlib.util.spec_from_file_location("workflow_hook", root / ".codex/hooks/inject-workflow-state.py")
|
||||||
|
hook = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(hook)
|
||||||
|
templates = hook.load_breadcrumbs(root)
|
||||||
|
assert "without asking to create a task" in templates["no_task"]
|
||||||
|
key = hook.resolve_breadcrumb_key("planning", "codex", {"codex": {"dispatch_mode": "inline"}})
|
||||||
|
assert key == "planning-inline"
|
||||||
|
message = hook.build_breadcrumb("fixture", "planning", templates, breadcrumb_key=key)
|
||||||
|
assert "already recorded approval satisfies this gate" in message
|
||||||
|
assert "stay in planning" not in message
|
||||||
|
assert "task_error" in templates
|
||||||
|
assert "Do not create or activate another task" in templates["task_error"]
|
||||||
|
print("Routing contract passed")
|
||||||
|
`;
|
||||||
|
const result = spawnSync("python3", ["-c", runner, repoRoot], {
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, PYTHONDONTWRITEBYTECODE: "1" },
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
expect(result.stderr).not.toContain("Traceback");
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves explicit commit and archival boundaries", () => {
|
||||||
|
const finish = fs.readFileSync(path.join(repoRoot, ".agents/skills/trellis-finish-work/SKILL.md"), "utf8");
|
||||||
|
expect(finish).toContain("Commit only when explicitly requested");
|
||||||
|
expect(finish).toContain("task is genuinely complete and archival is explicitly requested");
|
||||||
|
expect(finish).not.toContain("current active task is always archived");
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户