From c3cef5cfc4d985deb768046bc1d9bb1626b57a6a Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 6 Sep 2026 11:14:41 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=8D=87=E7=BA=A7=20Trellis=20?= =?UTF-8?q?=E5=B9=B6=E7=B2=BE=E7=AE=80=E8=A7=84=E5=88=99=EF=BC=8809-05-tre?= =?UTF-8?q?llis-upgrade-rule-cleanup=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/trellis-before-dev/SKILL.md | 43 +- .agents/skills/trellis-brainstorm/SKILL.md | 18 +- .agents/skills/trellis-check/SKILL.md | 101 +- .agents/skills/trellis-continue/SKILL.md | 2 +- .agents/skills/trellis-finish-work/SKILL.md | 71 +- .agents/skills/trellis-meta/SKILL.md | 2 +- .../local-architecture/bundled-skills.md | 62 +- .../local-architecture/context-injection.md | 2 +- .../local-architecture/task-system.md | 6 +- .../references/platform-files/agents.md | 2 +- .../platform-files/hooks-and-settings.md | 2 +- .../references/platform-files/platform-map.md | 7 +- .agents/skills/trellis-start/SKILL.md | 64 +- .codex/agents/trellis-check.toml | 6 +- .codex/hooks/inject-subagent-context.py | 93 +- .codex/hooks/inject-workflow-state.py | 51 +- .codex/hooks/session-start.py | 7 +- .trellis/.template-hashes.json | 63 +- .trellis/.version | 2 +- .trellis/config.yaml | 86 +- .trellis/scripts/add_session.py | 1022 +++++++++++++-- .trellis/scripts/common/__init__.py | 2 + .trellis/scripts/common/active_task.py | 315 ++++- .trellis/scripts/common/config.py | 251 ++-- .trellis/scripts/common/developer.py | 2 + .trellis/scripts/common/git.py | 129 ++ .trellis/scripts/common/io.py | 111 +- .trellis/scripts/common/paths.py | 130 +- .trellis/scripts/common/safe_commit.py | 13 +- .trellis/scripts/common/session_context.py | 34 +- .trellis/scripts/common/task_context.py | 208 ++- .trellis/scripts/common/task_store.py | 1165 +++++++++++++++-- .trellis/scripts/common/task_utils.py | 380 ++++-- .trellis/scripts/common/tasks.py | 16 +- .trellis/scripts/common/trellis_config.py | 125 +- .trellis/scripts/common/workflow_phase.py | 25 +- .trellis/scripts/task.py | 264 +++- .trellis/spec/guides/index.md | 13 +- .trellis/spec/hyb-oa/project-governance.md | 58 +- .../check.jsonl | 1 + .../design.md | 181 +++ .../implement.jsonl | 1 + .../implement.md | 168 +++ .../09-05-trellis-upgrade-rule-cleanup/prd.md | 54 + .../task.json | 26 + .trellis/workflow.md | 66 +- AGENTS.md | 11 +- .../scripts/trellis-runtime-contract.test.js | 180 +++ 48 files changed, 4588 insertions(+), 1053 deletions(-) create mode 100644 .trellis/tasks/09-05-trellis-upgrade-rule-cleanup/check.jsonl create mode 100644 .trellis/tasks/09-05-trellis-upgrade-rule-cleanup/design.md create mode 100644 .trellis/tasks/09-05-trellis-upgrade-rule-cleanup/implement.jsonl create mode 100644 .trellis/tasks/09-05-trellis-upgrade-rule-cleanup/implement.md create mode 100644 .trellis/tasks/09-05-trellis-upgrade-rule-cleanup/prd.md create mode 100644 .trellis/tasks/09-05-trellis-upgrade-rule-cleanup/task.json create mode 100644 server/tests/unit/scripts/trellis-runtime-contract.test.js diff --git a/.agents/skills/trellis-before-dev/SKILL.md b/.agents/skills/trellis-before-dev/SKILL.md index 5a4b8523..150a0bcb 100644 --- a/.agents/skills/trellis-before-dev/SKILL.md +++ b/.agents/skills/trellis-before-dev/SKILL.md @@ -1,40 +1,15 @@ --- 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**: - - `prd.md` for requirements and acceptance criteria - - `design.md` if present for technical design - - `implement.md` if present for execution order and validation plan - -2. **Discover packages and their spec layers**: - ```bash - python3 ./.trellis/scripts/get_context.py --mode packages - ``` - -3. **Identify which specs apply** to your task based on: - - Which package you're modifying (e.g., `cli/`, `docs-site/`) - - What type of work (backend, frontend, unit-test, docs, etc.) - - Any spec/research paths referenced by the task artifacts - -4. **Read the spec index** for each relevant module: - ```bash - cat .trellis/spec///index.md - ``` - Follow the **"Pre-Development Checklist"** section in the index. - -5. **Read the specific guideline files** listed in the Pre-Development Checklist that are relevant to your task. The index is NOT the goal — it points you to the actual guideline files (e.g., `error-handling.md`, `conventions.md`, `mock-strategies.md`). Read those files to understand the coding standards and patterns. - -6. **Always read shared guides**: - ```bash - cat .trellis/spec/guides/index.md - ``` - -7. Understand the coding standards and patterns you need to follow, then proceed with your development plan. - -This step is **mandatory** before writing any code. +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. diff --git a/.agents/skills/trellis-brainstorm/SKILL.md b/.agents/skills/trellis-brainstorm/SKILL.md index 096eeda1..e79d76ea 100644 --- a/.agents/skills/trellis-brainstorm/SKILL.md +++ b/.agents/skills/trellis-brainstorm/SKILL.md @@ -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. -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`. @@ -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: ```bash -TASK_DIR=$(python3 ./.trellis/scripts/task.py create "" --slug ) +TASK_DIR=$(python3 ./.trellis/scripts/task.py create "" --description "" --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. @@ -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. 6. When no user-owned decision remains, create or update `design.md` and `implement.md` for complex tasks. 7. Run the requirement convergence gate, then the PRD convergence pass. -8. Present the final planning summary and stop. Do not run `task.py start` or edit product code in the same turn. -9. Only a subsequent user message that explicitly approves the latest planning summary authorizes `task.py start` and implementation. If the artifacts change materially after approval, repeat the final review. +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. 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. @@ -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. -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 @@ -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`. -`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 @@ -195,6 +195,6 @@ Before declaring planning ready: - Complex tasks have `design.md` and `implement.md`. - Sub-agent-dispatch tasks have real curated entries in both `implement.jsonl` and `check.jsonl`; seed-only manifests are not ready. - The latest final planning summary has been presented to the user. -- In a subsequent message, the user explicitly approved that summary for implementation. +- 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. diff --git a/.agents/skills/trellis-check/SKILL.md b/.agents/skills/trellis-check/SKILL.md index c695abda..5c6cbc06 100644 --- a/.agents/skills/trellis-check/SKILL.md +++ b/.agents/skills/trellis-check/SKILL.md @@ -1,98 +1,15 @@ --- 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. ---- - -## 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///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. +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. diff --git a/.agents/skills/trellis-continue/SKILL.md b/.agents/skills/trellis-continue/SKILL.md index 4aceae79..3d815d8f 100644 --- a/.agents/skills/trellis-continue/SKILL.md +++ b/.agents/skills/trellis-continue/SKILL.md @@ -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` + `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=in_progress` + implementation not started → **2.1** - `status=in_progress` + implementation done, not yet checked → **2.2** diff --git a/.agents/skills/trellis-finish-work/SKILL.md b/.agents/skills/trellis-finish-work/SKILL.md index 5caebb55..089cbe44 100644 --- a/.agents/skills/trellis-finish-work/SKILL.md +++ b/.agents/skills/trellis-finish-work/SKILL.md @@ -1,71 +1,14 @@ --- 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 -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 - -```bash -python3 ./.trellis/scripts/get_context.py --mode record -``` - -This prints: - -- **My active tasks** — review whether any besides the current one are actually done (code merged, AC met) and should be archived this round. -- **Git status** — quick visual on what's dirty. -- **Recent commits** — you'll need their hashes in Step 4 for `--commit`. - -If `--mode record` surfaces other completed tasks not tied to the current session, surface them to the user with a one-shot confirmation: "These N tasks look done — archive them too in this round? [y/N]". Default is no; the current active task is always archived in Step 3 regardless. - -## Step 2: Sanity check — classify dirty paths - -Run: - -```bash -git status --porcelain -``` - -Filter out paths under `.trellis/workspace/` and `.trellis/tasks/` — those are managed by `add_session.py` and `task.py archive` auto-commits and will appear dirty as part of this skill's own work. - -For each remaining dirty path, decide whether it belongs to **the current task** or to **other parallel work** (e.g., another terminal window editing the same repo). Heuristics: - -- Paths referenced in the current task's `prd.md` / `implement.jsonl` / `check.jsonl` → current task -- Paths in code areas matching the task's stated scope, or that you remember editing this session → current task -- Paths in unrelated areas you have no recollection of touching this session → other parallel work - -Then route: - -- **Any remaining path looks like current-task work** — bail out with: - > "Working tree has uncommitted code changes from this task: ``. Return to workflow Phase 3.4 to commit them before running ``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: ``." -- **Genuinely unsure** — ask the user once: "Are `` this task's work I forgot to commit, or another window's? (commit / ignore)" — then route per their answer. - -## Step 3: Archive task(s) - -```bash -python3 ./.trellis/scripts/task.py archive -``` - -At minimum: the current active task (if any). Plus any extra tasks the user confirmed in Step 1. Each archive produces a `chore(task): archive ...` commit via the script's auto-commit. - -If there is no active task and the user did not confirm any cleanup archives, skip this step. - -## Step 4: Record session journal - -```bash -python3 ./.trellis/scripts/add_session.py \ - --title "Session Title" \ - --commit "hash1,hash2" \ - --summary "Brief summary" -``` - -Use the work-commit hashes produced in Phase 3.4 (visible in Step 1's `Recent commits` list, or via `git log --oneline`) for `--commit`. Do not include the archive commit hashes from Step 3. This produces a `chore: record journal` commit. - -Final git log order: `` → `chore(task): archive ...` (one or more) → `chore: record journal`. +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. diff --git a/.agents/skills/trellis-meta/SKILL.md b/.agents/skills/trellis-meta/SKILL.md index 0ffe5939..91b23b69 100644 --- a/.agents/skills/trellis-meta/SKILL.md +++ b/.agents/skills/trellis-meta/SKILL.md @@ -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: - `.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//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//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//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//SKILL.md`, and installs the same agent prompts as custom sub-agents under `.kimi-code/agents/.md`. - Shared skill layer: `.agents/skills/`. - User-owned channel store outside the project tree: `~/.trellis/channels///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). diff --git a/.agents/skills/trellis-meta/references/local-architecture/bundled-skills.md b/.agents/skills/trellis-meta/references/local-architecture/bundled-skills.md index ae288703..d8f97631 100644 --- a/.agents/skills/trellis-meta/references/local-architecture/bundled-skills.md +++ b/.agents/skills/trellis-meta/references/local-architecture/bundled-skills.md @@ -31,30 +31,42 @@ The list is discovered at runtime, so adding a new directory under `bundled-skil ## Where Bundled Skills Land Per Platform -Each platform configurator calls `writeSkills(, , 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 `collectTemplates()` in `packages/cli/src/configurators/.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(, , )` folds them into the platform's `Map` under `//`. -| Platform | Bundled skill root | Notes | -| --- | --- | --- | -| Claude Code | `.claude/skills//` | `configureClaude` | -| Cursor | `.cursor/skills//` | `configureCursor` | -| Codex | `.agents/skills//` | `configureCodex` writes the shared `.agents/skills/` root, which Gemini CLI 0.40+ also reads | -| Gemini CLI | `.agents/skills//` | Same shared root as Codex; the two configurators are required to produce byte-identical output | -| Kiro | `.kiro/skills//` | `configureKiro` (skills-based platform — no commands) | -| Qoder | `.qoder/skills//` | `configureQoder` | -| Codebuddy | `.codebuddy/skills//` | `configureCodebuddy` | -| Copilot | `.github/skills//` | `configureCopilot` | -| Droid | `.factory/skills//` | `configureDroid` | -| Antigravity | `.agent/skills//` | `configureAntigravity` | -| Devin | `.devin/skills//` | `configureDevin` | -| Kilo | `.kilocode/skills//` | `configureKilo` | -| ZCode | `.zcode/skills//` | `configureZcode` | -| OpenCode | (handled by `collectOpenCodeTemplates`) | Uses the same `resolveBundledSkills(ctx)` output | -| Pi, Reasonix | (their own collectors) | Same `resolveBundledSkills(ctx)` output | +All 21 platforms receive the full bundled-skill set: -Two paths exercise the same data: +| Platform | Bundled skill root | +| --- | --- | +| Claude Code | `.claude/skills//` | +| Cursor | `.cursor/skills//` | +| OpenCode | `.opencode/skills//` | +| Codex | `.agents/skills//` | +| Gemini CLI | `.agents/skills//` | +| Pi | `.agents/skills//` | +| Kimi | `.agents/skills//` | +| Kilo | `.kilocode/skills//` | +| Kiro | `.kiro/skills//` | +| Antigravity | `.agent/skills//` | +| Devin | `.devin/skills//` | +| Qoder | `.qoder/skills//` | +| Codebuddy | `.codebuddy/skills//` | +| Copilot | `.github/skills//` | +| Droid | `.factory/skills//` | +| Reasonix | `.reasonix/skills//` | +| ZCode | `.zcode/skills//` | +| Trae | `.trae/skills//` | +| OMP | `.omp/skills//` | +| Grok | `.grok/skills//` | +| Snow | `.snow/skills//` | -1. `configureX(cwd)` writes files during `trellis init`. -2. `collectPlatformTemplates(platformId)` (in `configurators/index.ts`) returns a `Map` 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))`. +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. + +One description, two consumers: + +1. `trellis init` → `configurePlatform(platformId, cwd)` → `writeTemplateMap(cwd, collectTemplates())`. For 18 of the 21 platforms the registry entry in `configurators/index.ts` is literally `fromTemplates(collectTemplates)`, which *is* that composition. Claude Code, Codex and ZCode spell out a `configure` of their own, each for work a `Map` 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) @@ -67,10 +79,10 @@ The mechanism that auto-dispatches bundled skills to platform skill roots lives 2. `packages/cli/src/configurators/shared.ts` - `resolveBundledSkills(ctx)` flattens that list into `ResolvedSkillFile[]` with `/` paths and resolved placeholders. - - `writeSkills(skillsRoot, workflowSkills, bundledSkills)` writes both workflow skills and bundled skill files under `skillsRoot`. - - `collectSkillTemplates(skillsRoot, workflowSkills, bundledSkills)` returns the same shape as a `Map` for the update / hash pipeline. + - `collectSkillTemplates(skillsRoot, workflowSkills, bundledSkills)` returns workflow skills and bundled skill files together as a `Map` rooted at `skillsRoot`. + - `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 `collectTemplates()` — 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 @@ -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. -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: `collectTemplates()` 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 diff --git a/.agents/skills/trellis-meta/references/local-architecture/context-injection.md b/.agents/skills/trellis-meta/references/local-architecture/context-injection.md index 4a7517bb..91ba5c73 100644 --- a/.agents/skills/trellis-meta/references/local-architecture/context-injection.md +++ b/.agents/skills/trellis-meta/references/local-architecture/context-injection.md @@ -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"} ``` -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 diff --git a/.agents/skills/trellis-meta/references/local-architecture/task-system.md b/.agents/skills/trellis-meta/references/local-architecture/task-system.md index 71334958..37d5b202 100644 --- a/.agents/skills/trellis-meta/references/local-architecture/task-system.md +++ b/.agents/skills/trellis-meta/references/local-architecture/task-system.md @@ -59,7 +59,7 @@ Use child tasks for deliverables that can move through planning, implementation, Create new children with: ```bash -python3 ./.trellis/scripts/task.py create "" --slug --parent +python3 ./.trellis/scripts/task.py create "" --description "" --slug --parent ``` Link or unlink existing tasks with: @@ -101,12 +101,12 @@ Rules: - Include spec and research files. - Do not include code files that are about to be modified. - Do not treat temporary conclusions in chat as the only context. -- Seed rows have no `file` field; they only prompt the AI to fill in real entries. +- Rows without a `file` field are skipped by readers. Legacy `{"_example": ...}` placeholder rows are rejected by `task.py validate` — delete them. ## Common Commands ```bash -python3 ./.trellis/scripts/task.py create "" --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 current --source python3 ./.trellis/scripts/task.py add-context <task> implement <file> <reason> diff --git a/.agents/skills/trellis-meta/references/platform-files/agents.md b/.agents/skills/trellis-meta/references/platform-files/agents.md index 26f472ab..7e5e47f2 100644 --- a/.agents/skills/trellis-meta/references/platform-files/agents.md +++ b/.agents/skills/trellis-meta/references/platform-files/agents.md @@ -34,7 +34,7 @@ Agent files should not become generic chat prompts. They should define input sou | Pi Agent | `.pi/agents/trellis-*.md` | | Reasonix | `.reasonix/skills/trellis-*/SKILL.md` (subagent frontmatter) | | ZCode | `.zcode/agents/trellis-*.md` | -| Kimi Code | `.kimi-code/skills/trellis-*/SKILL.md` (agent prompts as skills, dispatched to the built-in `coder`; research needs its file-editing tools to persist findings) | +| 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. diff --git a/.agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md b/.agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md index a2ff389c..85f7f11a 100644 --- a/.agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md +++ b/.agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md @@ -29,7 +29,7 @@ Common files: | Pi Agent | `.pi/settings.json`, `.pi/extensions/trellis/` | | Trae IDE | `.trae/hooks.json` | -Reasonix is a pull-based platform whose agent files contain prelude instructions to read context after startup. ZCode uses `.zcode/config.json` with shared hooks, including PreToolUse for sub-agent prompt injection. Kimi Code is likewise pull-based and has no project-level settings/hooks file Trellis writes (hooks live only in the user-level `~/.kimi-code/config.toml`), so its agent prompts ship as skills with the same prelude. +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. diff --git a/.agents/skills/trellis-meta/references/platform-files/platform-map.md b/.agents/skills/trellis-meta/references/platform-files/platform-map.md index 73b113a6..f66e1313 100644 --- a/.agents/skills/trellis-meta/references/platform-files/platform-map.md +++ b/.agents/skills/trellis-meta/references/platform-files/platform-map.md @@ -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` | | GitHub Copilot | `--copilot` | `.github/` | `.github/skills/` | `.github/agents/` | `.github/copilot/hooks/` + prompts | | 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` | | Trae IDE | `--trae` | `.trae/` | `.trae/skills/` | `.trae/agents/` | `.trae/hooks/` + `.trae/hooks.json` | | Reasonix | `--reasonix` | `.reasonix/` | `.reasonix/skills/` | None — sub-agents are skills with `runAs: subagent` frontmatter | None | | ZCode | `--zcode` | `.zcode/` | `.zcode/skills/` | `.zcode/agents/` | `.zcode/hooks/` + `.zcode/config.json` (SessionStart + UserPromptSubmit + PreToolUse Agent/Task); sub-agents use hook-injected context | | Grok Build | `--grok` | `.grok/` | `.grok/skills/` | `.grok/agents/` | pull-based prelude (no hooks; flat `.grok/commands/trellis-*.md`) | -| Kimi Code | `--kimi` | `.kimi-code/` | `.agents/skills/` (shared) + `.kimi-code/skills/` | None — agent prompts are skills under `.kimi-code/skills/` and dispatch to the built-in `coder` | None (pull-based prelude; no project hooks/settings) | +| 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` | ## 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) - ZCode - Grok Build (`.grok/agents/`; dispatch via `spawn_subagent` with `subagent_type`) -- Kimi Code (delivered as skills under `.kimi-code/skills/`; dispatched to the built-in `coder`, including research because it must persist files) +- 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) 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/` -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 diff --git a/.agents/skills/trellis-start/SKILL.md b/.agents/skills/trellis-start/SKILL.md index 3c7980d2..db07536a 100644 --- a/.agents/skills/trellis-start/SKILL.md +++ b/.agents/skills/trellis-start/SKILL.md @@ -1,64 +1,14 @@ --- 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 -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. ---- - -## 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`. +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. diff --git a/.codex/agents/trellis-check.toml b/.codex/agents/trellis-check.toml index 50ceae33..cbbd04c4 100644 --- a/.codex/agents/trellis-check.toml +++ b/.codex/agents/trellis-check.toml @@ -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. - 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: - Verify behavior against the actual code paths, not assumptions. @@ -33,7 +33,7 @@ Review checklist: - Run lint and type-check; fix any failures. - 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. 2. Re-run lint and type-check until green. 3. Record what you changed and why. @@ -45,7 +45,7 @@ Output format: - Fix: <what you changed> ## Findings (not fixed) -Only list issues you could not self-fix (e.g. missing product decision, out-of-scope). Explain why. +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 - Lint: pass/fail diff --git a/.codex/hooks/inject-subagent-context.py b/.codex/hooks/inject-subagent-context.py index cfe7b15b..b804d5cd 100755 --- a/.codex/hooks/inject-subagent-context.py +++ b/.codex/hooks/inject-subagent-context.py @@ -92,9 +92,16 @@ def _detect_platform(input_data: dict) -> str | None: return "codex" if isinstance(input_data.get("cursor_version"), str): 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 = { "ZCODE_PROJECT_DIR": "zcode", - "CLAUDE_PROJECT_DIR": "claude", "CURSOR_PROJECT_DIR": "cursor", "CODEBUDDY_PROJECT_DIR": "codebuddy", "FACTORY_PROJECT_DIR": "droid", @@ -102,6 +109,9 @@ def _detect_platform(input_data: dict) -> str | None: "QODER_PROJECT_DIR": "qoder", "KIRO_PROJECT_DIR": "kiro", "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(): if os.environ.get(env_name): @@ -238,9 +248,33 @@ class _Budget: 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: """Read raw file bytes, return None if file doesn't exist.""" 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): try: with open(full_path, "rb") as f: @@ -365,12 +399,12 @@ def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[dict]: Schema: {"file": "path/to/file.md", "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 - by ``task.py create`` before the agent has curated entries) are skipped - silently. If the resulting entry list is empty, a stderr warning is - emitted so the operator can debug missing context. + Rows without a ``file`` field (e.g. the placeholder line older Trellis + versions wrote at ``task.py create`` time) are skipped silently. If the + resulting entry list is empty, a stderr warning is emitted so the operator + can debug missing context. Returns: [{"file": path, "type": "file" | "directory", "reason": reason}, ...] @@ -459,6 +493,17 @@ def get_agent_context( """ agent_jsonl = f"{task_dir}/{agent_type}.jsonl" 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) @@ -877,8 +922,15 @@ def _handle_codex_subagent_start(input_data: dict) -> None: if not subagent_type or not parent_session_id: return - cwd = _string_value(input_data.get("cwd")) or os.getcwd() - repo_root = find_repo_root(cwd) + # Payload cwd first, then our own — some hosts (CodeBuddy IDE 4.10.4) + # 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: return @@ -979,6 +1031,8 @@ def _extract_subagent_type(tool_input: dict) -> str: "subagentType", "subagent_type_name", "subagentTypeName", + "subagent_name", + "subagentName", "agent_type", "agentType", "name", @@ -994,7 +1048,8 @@ def _parse_hook_input(input_data: dict) -> tuple[str, str, dict]: Returns (subagent_type, original_prompt, tool_input). 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 - Copilot CLI: toolName=task (camelCase key, lowercase value) - ZCode: toolName=Agent, toolInput/tool_input.subagent_type @@ -1073,8 +1128,24 @@ def main(): if subagent_type in AGENTS_REQUIRE_TASK: if not task_dir: sys.exit(0) - # Check if task directory exists - task_dir_full = os.path.join(repo_root, task_dir) + # Contain the pointer before reading anything through it. `task.py` now + # 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): sys.exit(0) diff --git a/.codex/hooks/inject-workflow-state.py b/.codex/hooks/inject-workflow-state.py index ab8e276c..37586e37 100755 --- a/.codex/hooks/inject-workflow-state.py +++ b/.codex/hooks/inject-workflow-state.py @@ -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) the broken state instead of the hook silently masking it. -Shared across all hook-capable platforms (Claude, Cursor, Codex, Qoder, -CodeBuddy, Droid, Gemini, Copilot, Kiro). Kiro wires this via the CLI +Which platforms register this hook is decided by SHARED_HOOKS_BY_PLATFORM +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`` ``promptSubmit`` event; its output branch emits a plain-text breadcrumb -(Kiro adds hook stdout directly to the conversation context). Written to -each platform's hooks directory via writeSharedHooks() at init time. +(Kiro adds hook stdout directly to the conversation context). -Silent exit 0 cases (no output): +Silent exit 0 case (no output): - 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 @@ -95,11 +101,16 @@ def find_trellis_root(start: Path) -> Optional[Path]: def _detect_platform(input_data: dict) -> str | None: if isinstance(input_data.get("cursor_version"), str): 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 = { - # ZCode may set both ZCODE_PROJECT_DIR and CLAUDE_PROJECT_DIR; check - # ZCODE first so ZCode sessions aren't misdetected as claude. "ZCODE_PROJECT_DIR": "zcode", - "CLAUDE_PROJECT_DIR": "claude", "CURSOR_PROJECT_DIR": "cursor", "CODEBUDDY_PROJECT_DIR": "codebuddy", "FACTORY_PROJECT_DIR": "droid", @@ -108,6 +119,8 @@ def _detect_platform(input_data: dict) -> str | None: "KIRO_PROJECT_DIR": "kiro", "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(): 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)) -def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, str]]: - """Return (task_id, status, source) from the current active task.""" +def get_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) if not active.task_path: 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" if not task_json.is_file(): - return None + return task_dir.name, "task_error", active.source try: data = json.loads(task_json.read_text(encoding="utf-8")) 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 status = data.get("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 diff --git a/.codex/hooks/session-start.py b/.codex/hooks/session-start.py index ca5608f3..f7105d50 100755 --- a/.codex/hooks/session-start.py +++ b/.codex/hooks/session-start.py @@ -128,9 +128,10 @@ def configure_project_encoding(project_dir: Path) -> None: def _has_curated_jsonl_entry(jsonl_path: Path) -> bool: """Return True iff jsonl has at least one row with a ``file`` field. - A freshly seeded jsonl only contains a ``{"_example": ...}`` row (no - ``file`` key) — that is NOT "ready". Readiness requires at least one - curated entry. Matches the contract used by ``inject-subagent-context.py``. + A newly created jsonl is empty, and older tasks may still carry a + ``{"_example": ...}`` placeholder row (no ``file`` key) — neither is + "ready". Readiness requires at least one curated entry. Matches the + contract used by ``inject-subagent-context.py``. """ try: for line in jsonl_path.read_text(encoding="utf-8").splitlines(): diff --git a/.trellis/.template-hashes.json b/.trellis/.template-hashes.json index 093dc952..3066316f 100644 --- a/.trellis/.template-hashes.json +++ b/.trellis/.template-hashes.json @@ -1,12 +1,12 @@ { "__version": 2, "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-before-dev/SKILL.md": "859894f2e8258cbfa142d710363433761c54984c69f7ed7bd44512fb4eb165cd", - ".agents/skills/trellis-brainstorm/SKILL.md": "a0f226ddcb8a3e846acd2a35d121996e9ca55165ce76202095d0b65e2b48a5e8", + ".agents/skills/trellis-before-dev/SKILL.md": "00c9d1c83bc318e91b27a019bc954c2957cfad907ff20d854b596f16cad2c0f3", + ".agents/skills/trellis-brainstorm/SKILL.md": "17d9bf209730c14f584eb97f38198d0683ad3f8da982d9a6e6d4e84c853f9a94", ".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-meta/references/customize-local/add-project-local-conventions.md": "86009ccb5d0373f399582da0bc570c4e5c6053c3c764857424ff93384f0e04e5", ".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-workflow.md": "43fa780a2ca580de121b10893d49b99f978873deebbf45008c466e5ac6651519", ".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/overview.md": "50638fd9eaaba2e0edf2f2a84d920578d5b2fb7934031b174e417d3dc510b2a6", ".agents/skills/trellis-meta/references/local-architecture/spec-system.md": "b8d8a6a0888b44a232c8f50161b9e20e903cf621ad7be4021715ab6fab226f47", - ".agents/skills/trellis-meta/references/local-architecture/task-system.md": "2b561d49c390f7d0db5391912946133be4bf73189231e2b8cc9afa1c5ac6165a", + ".agents/skills/trellis-meta/references/local-architecture/task-system.md": "b154568e74f1c738a51633df9f2503f416b3afa5f309325224422162e0b707e4", ".agents/skills/trellis-meta/references/local-architecture/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6", ".agents/skills/trellis-meta/references/local-architecture/workspace-memory.md": "e6427b46aba744563c2444b30df4043cd856561b7709ec2dece26095416421fd", - ".agents/skills/trellis-meta/references/platform-files/agents.md": "9f41349b78f7ae64698a38490a317882561f3805b26a79bda587a13d03fda245", - ".agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "154af08f7ee8afe7a704968ec0b9fc21e905b7cfb03871c9dbafcdd6cc654319", + ".agents/skills/trellis-meta/references/platform-files/agents.md": "b806b1f0de6dfc74720014aee15ede767a58925b2b3b964f2e557ed39a65bbc0", + ".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/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/SKILL.md": "208e92ace3b8d979d8158b3bd0169185f508f2e1424fcd78fcd03cbcc39516a1", + ".agents/skills/trellis-meta/SKILL.md": "ee7bcfd0023def62688339c4b63c50f2fbeea12a34eb89e8bb63a992b58cb168", ".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-research.toml": "4435ce73197ba1d29d40359a3279b6423f7e4f559a449f934c016808090066c4", - ".codex/hooks/session-start.py": "14de3be1cf6eb9c9feba348d8998b407f3837d6c0756b74210c9200543440677", - ".codex/hooks/inject-workflow-state.py": "9ce43910ac39cbb0e4d1783fbde931761eb04536c7f82661a96d95ea72c14bde", + ".codex/hooks/session-start.py": "efdd015c7c4687227ce34a84ac6249d5554c704f737e00d4844c9284ddcf696f", + ".codex/hooks/inject-workflow-state.py": "71ef1aa1ed1051a697cae3c9ebffa518958a3bbd4d147434f382348829915f8c", ".codex/hooks.json": "85a58ba7cdf1e19e7f75ddcc64e5680180c487ca266a74bd5005f31abeee2e02", ".codex/config.toml": "9f2d20e28f0bc9c886312eca3ad3bba41533ef4615aaaafe25e98152302267bb", ".trellis/config.yaml": "5c9207418cecc390e9d86d589b4183b831f76697d92fb42fefd5221cd8772e51", ".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c", - ".trellis/scripts/add_session.py": "876dad478edf70db59acccaae9cb4db646a155681f730bd99af48de72ddc9881", - ".trellis/scripts/common/__init__.py": "3d5e9347141f0296319a5beb29d69ae714c5a474b9078caeb3edd7c5f6562e22", - ".trellis/scripts/common/active_task.py": "31271e3b69b5a5eca958d8ce25f61fe852eb6e48d32c150471da8e7272fd6119", + ".trellis/scripts/add_session.py": "f61a452ec5f7eece8d7e0f0dd56ce5851166cbc83ca4822e30acbb36eeb5896f", + ".trellis/scripts/common/__init__.py": "f258cd873681dc5a4d71db0dd71d10fd78656de55bec96418d6702503c16e87d", + ".trellis/scripts/common/active_task.py": "f52715bbcc0677607055903e799674d7d50d6fcbab413a8f453c4e4efc954726", ".trellis/scripts/common/cli_adapter.py": "5d6bd9d6f5c631e7e792db7dd343351317f9643bc87b73a9a98abd51cefb4307", - ".trellis/scripts/common/config.py": "8d2e5f8ccfcd5f622cd2af002aa761f3d3ffcc653182fefb2268afd102e77bca", - ".trellis/scripts/common/developer.py": "f5f833123abe68890171b4da825a324216d24913f6b5ad9245afc556424ffd7b", - ".trellis/scripts/common/git.py": "6fc5845d0104dd506ebd8b366a24cb4b1e3d8777e4e6acc12ea15c9d8e2662f2", + ".trellis/scripts/common/config.py": "9a042d6f0e25a33a567510760e87c1e389af14a41faf1daeb1190a00f3df7f5e", + ".trellis/scripts/common/developer.py": "a0f61bb58063e2f4cb357bcb2c22fd5a6f1241286398beaa7130b2a8994a4afa", + ".trellis/scripts/common/git.py": "5605b71662e524b47632030d9db9245121a5cba85b5521eeb524ed464d840ff5", ".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/packages_context.py": "efe158d7c99c2268851d0216fbb08de22836e418a8dbeb73575b8cc249eed7b7", - ".trellis/scripts/common/paths.py": "05898ef136cc7c4d861b05fbf2b16d53ddd3e6f311a231d4fcfcb81bde7c45ee", - ".trellis/scripts/common/safe_commit.py": "baa5c82324eb62154374ec63394ecdc8609bb37d93892e3bcb88f452bb7d6446", - ".trellis/scripts/common/session_context.py": "4ed3e13b2878ba367e9f2e2cd709b396f806152902df2cd1cd1478317d069017", + ".trellis/scripts/common/paths.py": "d8586d4d3f9bb2dd46b7f11b0f038b5340d283f41b6fa2c76bb8235c13bbb0b7", + ".trellis/scripts/common/safe_commit.py": "262ad740552279fe6ec6a2a9865b2965ce2448577cf973a4d702dfe2f5f91f04", + ".trellis/scripts/common/session_context.py": "3379ef1766e4e5ca77cbb7c040dbba3883fcca2548580299e3b38dbf22f4f7d5", ".trellis/scripts/common/task_context.py": "51301e180ce702c6a5b1a2dd1b155a6eed1bf2b05cb2ac1e51081212628b11d4", ".trellis/scripts/common/task_queue.py": "0be61f713462b1fe4574927c82fc4704e678afe72dcb9813543aedf2f9e9e0c5", - ".trellis/scripts/common/task_store.py": "e3c2fbf8b79b591e39fc3c9f4e2f3ee0c840c8201c94a16709ec743fa45037f6", - ".trellis/scripts/common/task_utils.py": "90c0a6d50bad502c3f01cb24c1ccfeb0eece5e2c69efaff8d65eb827fba43871", - ".trellis/scripts/common/tasks.py": "4436a8b0b53c270a35989e26d9dbd92669408c6562d88c02083a404562da85fe", - ".trellis/scripts/common/trellis_config.py": "e282e897183e3ec2f4e6e56349431946e5f98c1c31d3eca4de7fc44e1383a7bf", + ".trellis/scripts/common/task_store.py": "5712b0405e1fd0c2b7be5e5fbd420fa6eec2c49c8e4cf2b8fc69b7d2e4aeb016", + ".trellis/scripts/common/task_utils.py": "35f544317a95e98d05fe089794071482175739e8c1f3b3b0228dae48602ffe90", + ".trellis/scripts/common/tasks.py": "87aca6b73ec4524450cfff8e2ae561b1f2e53bd62a185f036a7a2d484d90c8a2", + ".trellis/scripts/common/trellis_config.py": "f1fc705ec463b0b9c163672dc9e50f10d29da967775a1900429205f7c4fcb51c", ".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_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f", ".trellis/scripts/hooks/linear_sync.py": "e09cc4ce4699aada908808718698f33f705a3edf55c4dcf8f777ad892f80ca79", ".trellis/scripts/init_developer.py": "f9e6c0d882406e81c8cd6b1c5abb204b0befc0069ff89cf650cd536a80f8c60e", - ".trellis/scripts/task.py": "e0ffed9f14994069f0c992141e3ec168524be5af32e3681e6ea30ba0a5da4bc4", + ".trellis/scripts/task.py": "b415e0a2dbc9e8cba1ada0c7997999aaaaea0eec96d3419db640830e88e84cfe", ".trellis/workflow.md": "f4b8a6f89017f62071986d6d36cc32c4c7f01fe6f023f0fd9311eddc57d8c94f", ".trellis/agents/implement.md": "66e25ad046c94869442834bc3cdfbd5a9a7412d3ff54561d64d2886552c27e87", ".trellis/agents/check.md": "edb4f57361407249a53bf5998ebf91c40d2b969e826a2c5e1b4e813a08bcb175", @@ -76,7 +76,7 @@ ".agents/skills/trellis-channel/references/workers.md": "d4481b2858bca82050cb8d131916da8a2ccc3857f199cdb9ced8bb41d592e2a7", ".agents/skills/trellis-channel/references/workflows.md": "0782d602efde6e94a7d7d7950fd605eedc9a1f5a523908284e76b628fd618fd4", ".agents/skills/trellis-channel/SKILL.md": "ff84801c511a736da018b4b1149348ebc9a16b7fc18d905ec10cfa7acbf269a3", - ".agents/skills/trellis-meta/references/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-session-insight/references/cli-quick-reference.md": "c520353fe3fc00b9702ed4f780647c8fbd6d342e66527a03647f533a9fe09779", ".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-writing.md": "e9800fe9ed4a4cd87062ea1829cf2caa8d170ec15e141678a6a30e74c497f47d", ".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" } } \ No newline at end of file diff --git a/.trellis/.version b/.trellis/.version index e9acb99e..a12cadd8 100644 --- a/.trellis/.version +++ b/.trellis/.version @@ -1 +1 @@ -0.6.12 \ No newline at end of file +0.6.16 \ No newline at end of file diff --git a/.trellis/config.yaml b/.trellis/config.yaml index e06751b3..4d0167b1 100644 --- a/.trellis/config.yaml +++ b/.trellis/config.yaml @@ -58,34 +58,23 @@ session_auto_commit: false # Trellis auto-detects workspaces during `trellis init`, but you can also # 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: -# backend: -# path: server # frontend: -# path: client +# path: packages/frontend +# backend: +# path: packages/backend # 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: backend - -#------------------------------------------------------------------------------- -# 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 +# default_package: frontend #------------------------------------------------------------------------------- # Channel worker OOM guard @@ -118,27 +107,52 @@ channel: # - /work/user/trellis_workspace # auto_trust_trellis_symlinks: false +#------------------------------------------------------------------------------- +# Codex (dispatch behavior) +#------------------------------------------------------------------------------- +# Codex-only knob; other platforms ignore it. Default ("auto") dispatches +# trellis-implement / trellis-check / trellis-research sub-agents. This does +# not rely on inherited parent transcripts: `fork_turns` remains +# caller-controlled, while Codex's native SubagentStart hook injects task +# context when trusted and child-side loading remains the fallback when it is +# unavailable. Set to "inline" only to keep implementation and checks in the +# main session. "sub-agent" remains a backwards-compatible alias for "auto". +# Invalid explicit values safely use inline mode. +# +# In "auto" mode, dispatched sub-agents inherit the main session's model +# unless you pin one. To use a cheaper/faster model for implement/check/ +# research sub-agent work, edit `model` / `model_reasoning_effort` directly +# on the generated `.codex/agents/trellis-*.toml` files (see the commented +# hint lines in those files) — there is no config.yaml knob for this, +# `trellis update` preserves your edits across regeneration. +# +codex: + dispatch_mode: inline + #------------------------------------------------------------------------------- # Sub-agent context injection limits #------------------------------------------------------------------------------- -# Trellis 0.6.12 caps injected task artifacts and referenced context. Oversized -# files are truncated with notice; total-budget overflow degrades remaining -# files to path/reason/size index lines. Defaults are sufficient for hyb-oa; -# keep them documented but implicit until a real task proves they need tuning. -# `0` disables the corresponding byte limit. +# Caps how much task context (implement.jsonl / check.jsonl referenced files, +# plus prd.md / design.md / implement.md) gets inlined into a sub-agent's +# first prompt. Oversized files are truncated with a notice; once the total +# payload cap is reached, remaining files degrade to index lines (path + +# reason + size) instead of being inlined. +# +# All values are byte counts. `0` disables the corresponding limit. # # context_injection: -# max_file_bytes: 32768 -# max_artifact_bytes: 65536 -# max_total_bytes: 131072 +# max_file_bytes: 32768 # per implement.jsonl / check.jsonl referenced file +# max_artifact_bytes: 65536 # per task artifact (prd.md / design.md / implement.md) +# max_total_bytes: 131072 # whole injected payload; overflow degrades to index lines #------------------------------------------------------------------------------- # Per-turn prompt injection #------------------------------------------------------------------------------- -# Optional one-turn escape hatch for the per-turn `<workflow-state>` breadcrumb. -# It uses a case-insensitive standalone-word match and does not affect session -# start or sub-agent context injection. The project keeps it disabled so task -# state remains deterministic. +# Escape hatch for the per-turn <workflow-state> breadcrumb. When a user +# prompt contains the skip keyword as a standalone word (case-insensitive, +# word-boundary match — "no-trellisfoo" does NOT count), the breadcrumb is +# skipped for that turn only. Does not affect SessionStart or sub-agent +# context injection. # # prompt_injection: -# skip_keyword: "no-trellis" +# skip_keyword: "no-trellis" # "" disables the escape hatch entirely diff --git a/.trellis/scripts/add_session.py b/.trellis/scripts/add_session.py index 6cd66362..40f36808 100755 --- a/.trellis/scripts/add_session.py +++ b/.trellis/scripts/add_session.py @@ -15,6 +15,27 @@ Usage: # Structured content (repeatable; a section with no bullets is omitted): python3 add_session.py --title "Title" --change "Did X" --test "Ran Y" --next-step "Do Z" + # Commit evidence that cannot be resolved from the local object database + # (amended, not yet fetched) needs an explicit subject, one per OID: + python3 add_session.py --title "Title" --commit "abc1234" \ + --commit-subject "abc1234=fix(cli): stop truncating the manifest" + +Commit evidence: + Every --commit token is a bounded hex OID (7-40 chars). Each one is + resolved to its real subject from the local object database BEFORE + anything is written. An OID that cannot be resolved fails the command + before the journal or index is touched — no placeholder prose is ever + recorded. Pass "-" (the default) for a planning session with no commits. + +Retry convergence: + The journal append, the index row and the optional auto-commit are one + resumable operation. Every entry carries a fingerprint marker derived from + its own inputs, so re-running an interrupted command repairs the pending + record instead of appending a second one, and a failed auto-commit exits + non-zero with the checkpoint to resume from. A record that is already + committed is never reused: an identical later request is a new session + unless an explicit --idempotency-key says otherwise. + Branch resolution order: 1. --branch CLI arg (explicit) 2. task.json branch field (from active task, if still exists) @@ -25,6 +46,8 @@ Branch resolution order: from __future__ import annotations import argparse +import hashlib +import json import re import sys from datetime import datetime @@ -40,7 +63,8 @@ from common.paths import ( get_workspace_dir, ) from common.developer import ensure_developer -from common.git import run_git +from common.git import branch_exists_locally, run_git +from common.io import write_text_atomic from common.log import Colors, colored from common.safe_commit import ( print_gitignore_warning, @@ -60,6 +84,41 @@ from common.config import ( ) +# Bounded, argv-safe input shapes. Anything outside them is rejected during +# preflight, before a single byte is written. +COMMIT_TOKEN_RE = re.compile(r"^[0-9a-fA-F]{7,40}$") +IDEMPOTENCY_KEY_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$") +MAX_SUBJECT_LEN = 500 + +# Machine-readable record identity. An HTML comment renders as nothing, so the +# human evidence in the entry is unchanged by its presence. +MARKER_PREFIX = "<!-- trellis-session:" +# Bumped when the fingerprint inputs change. Untagged markers are v1, whose +# fingerprint mixed in the calendar date; see compute_record_fingerprint. +MARKER_VERSION = 2 +LEGACY_MARKER_RE = re.compile(r"^<!-- trellis-session: fp=([0-9a-f]{16}) -->$") +ENTRY_DATE_RE = re.compile(r"^\*\*Date\*\*: (\d{4}-\d{2}-\d{2})\s*$") +SESSION_HEADING_RE = re.compile(r"^## Session (\d+):", re.MULTILINE) + +# Recording states, in the order the operation walks them. +STATE_ABSENT = "absent" +STATE_JOURNAL_RECORDED = "journal-recorded" +STATE_INDEX_RECORDED = "index-recorded" +STATE_COMMITTED = "committed" + +# Auto-commit outcomes. +COMMIT_DONE = "committed" +COMMIT_SKIPPED = "skipped" +COMMIT_BLOCKED = "blocked" +COMMIT_FAILED = "failed" + +# Git probes against other refs are best-effort; never let one hang a session. +GIT_PROBE_TIMEOUT = 15.0 +# Upper bound on the refs folded into session numbering. A repo with hundreds +# of stale branches must not turn session recording into a tree walk. +MAX_CONVERGENCE_REFS = 100 + + # ============================================================================= # Helper Functions # ============================================================================= @@ -97,6 +156,11 @@ def get_current_session(index_file: Path) -> int: return 0 content = index_file.read_text(encoding="utf-8") + return _max_session_in_index(content) + + +def _max_session_in_index(content: str) -> int: + """Read the `Total Sessions` counter out of an index.md body.""" for line in content.splitlines(): if "Total Sessions" in line: match = re.search(r":\s*(\d+)", line) @@ -105,6 +169,12 @@ def get_current_session(index_file: Path) -> int: return 0 +def _max_session_in_journal(content: str) -> int: + """Highest `## Session N:` heading in a journal body (0 when none).""" + numbers = [int(m.group(1)) for m in SESSION_HEADING_RE.finditer(content)] + return max(numbers) if numbers else 0 + + def _extract_journal_num(filename: str) -> int: """Extract journal number from filename for sorting.""" match = re.search(r"(\d+)", filename) @@ -226,8 +296,8 @@ def warn_if_parallel_worktree(repo_root: Path) -> None: def create_new_journal_file( dev_dir: Path, num: int, developer: str, today: str, max_lines: int = 2000, -) -> Path: - """Create a new journal file.""" +) -> Path | None: + """Create a new journal file. Returns None when the write fails.""" prev_num = num - 1 new_file = dev_dir / f"{FILE_JOURNAL_PREFIX}{num}.md" @@ -239,10 +309,566 @@ def create_new_journal_file( --- """ - new_file.write_text(content, encoding="utf-8") + if not write_text_atomic(new_file, content): + return None return new_file +# ============================================================================= +# Commit evidence (R1) — accurate subjects or nothing +# ============================================================================= + +def parse_commit_tokens(commit: str) -> tuple[list[str], str | None]: + """Split --commit into bounded hex OIDs. Returns (tokens, error).""" + raw = (commit or "").strip() + if not raw or raw == "-": + return [], None + + tokens: list[str] = [] + for part in raw.split(","): + token = part.strip() + if not token: + continue + if not COMMIT_TOKEN_RE.match(token): + return [], ( + f"invalid --commit token '{token}': expected a 7-40 character hex " + "commit OID, or '-' for a planning session with no commits" + ) + token = token.lower() + if token not in tokens: + tokens.append(token) + return tokens, None + + +def parse_subject_overrides(values: list[str] | None) -> tuple[dict[str, str], str | None]: + """Parse `--commit-subject <oid>=<subject>` into a one-to-one mapping.""" + overrides: dict[str, str] = {} + for raw in values or []: + if "=" not in raw: + return {}, ( + f"invalid --commit-subject '{raw}': expected <oid>=<subject>" + ) + oid_part, subject = raw.split("=", 1) + oid = oid_part.strip().lower() + subject = subject.strip() + if not COMMIT_TOKEN_RE.match(oid): + return {}, ( + f"invalid --commit-subject key '{oid_part.strip()}': expected a " + "7-40 character hex commit OID" + ) + if not subject: + return {}, f"invalid --commit-subject for '{oid}': subject is empty" + if len(subject) > MAX_SUBJECT_LEN: + return {}, ( + f"invalid --commit-subject for '{oid}': subject exceeds " + f"{MAX_SUBJECT_LEN} characters" + ) + if oid in overrides: + return {}, f"duplicate --commit-subject mapping for '{oid}'" + overrides[oid] = subject + return overrides, None + + +def resolve_commit_subject(repo_root: Path, oid: str) -> str | None: + """Resolve one OID to its subject from the local object database. + + Passed as argv (never interpolated into a shell) and peeled with + ``^{commit}`` so a hex-looking ref name cannot resolve to a tree or tag. + Returns None when the object is missing, ambiguous, or has no subject. + """ + rc, out, _ = run_git( + ["show", "-s", "--format=%s", f"{oid}^{{commit}}", "--"], cwd=repo_root + ) + if rc != 0: + return None + for line in out.splitlines(): + subject = line.strip() + if subject: + return subject[:MAX_SUBJECT_LEN] + return None + + +def build_commit_evidence( + repo_root: Path, + tokens: list[str], + overrides: dict[str, str], +) -> tuple[list[tuple[str, str]], str | None]: + """Pair every commit OID with an accurate subject, or fail. + + There is no placeholder path: an OID that neither resolves locally nor + carries an explicit mapping stops the command before any mutation. + """ + evidence: list[tuple[str, str]] = [] + unresolved: list[str] = [] + + for oid in tokens: + if oid in overrides: + evidence.append((oid, overrides[oid])) + continue + subject = resolve_commit_subject(repo_root, oid) + if subject is None: + unresolved.append(oid) + continue + evidence.append((oid, subject)) + + if unresolved: + return [], ( + "cannot resolve commit evidence for: " + + ", ".join(unresolved) + + ". Fetch the objects, correct the OID, or pass the exact subject " + "with --commit-subject <oid>=<subject>. Nothing was written." + ) + + unused = sorted(oid for oid in overrides if oid not in tokens) + if unused: + return [], ( + "--commit-subject supplied for OIDs that are not in --commit: " + + ", ".join(unused) + + ". The mapping must be one-to-one. Nothing was written." + ) + + return evidence, None + + +def escape_markdown_cell(text: str) -> str: + """Make a commit subject safe inside a Markdown table cell.""" + collapsed = " ".join(text.split()) + return collapsed.replace("\\", "\\\\").replace("|", "\\|") + + +# ============================================================================= +# Record identity (R2) +# ============================================================================= + +def _normalize_text(value: str | None) -> str: + return " ".join((value or "").split()) + + +def _fingerprint_payload( + developer: str, + title: str, + summary: str, + package: str | None, + branch: str | None, + evidence: list[tuple[str, str]], + changes: list[str] | None, + extra_content: str | None, + tests: list[str] | None, + next_steps: list[str] | None, + idempotency_key: str | None, +) -> dict: + """Normalized semantic inputs of one record, shared by both schemes.""" + return { + "developer": developer, + "title": _normalize_text(title), + "summary": _normalize_text(summary), + "package": package or "", + "branch": branch or "", + "commits": [[oid, _normalize_text(subject)] for oid, subject in evidence], + "changes": [_normalize_text(c) for c in changes or []], + "extra": _normalize_text(extra_content), + "tests": [_normalize_text(t) for t in tests or []], + "next_steps": [_normalize_text(n) for n in next_steps or []], + "idempotency_key": idempotency_key or "", + } + + +def _hash_payload(payload: dict) -> str: + encoded = json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8") + return hashlib.sha256(encoded).hexdigest()[:16] + + +def compute_record_fingerprint(payload: dict) -> str: + """Bounded fingerprint over the normalized semantic inputs of one record. + + This is the retry key while the record is still pending in the worktree — + not a global dedupe key. Two genuinely separate sessions with identical + prose differ only if the caller says so, which is why an already-committed + match is never adopted (see classify_record). + + Deliberately date-free (v2). v1 mixed in the calendar date, which made a + record unfindable by its own retry across a midnight rollover: the journal + and index were already written, the commit had failed, and the recomputed + fingerprint no longer matched the marker sitting in the journal, so the + retry appended a second entry for one session. + + Dropping the date cannot over-collapse two same-day sessions, because the + date was equal for both of them anyway. Across days it only ever separated + records with byte-identical prose, commits, branch and package — and a + *committed* record of that shape is already refused for reuse by + cmd_add_session, which steps to the next `generation` of the payload + rather than reusing the committed marker. What is left + is exactly the question this key should answer: is there an uncommitted + record in this worktree matching these inputs? + """ + return _hash_payload(payload) + + +def compute_legacy_fingerprint(payload: dict, date: str) -> str: + """The v1 fingerprint, for resolving markers written before the change. + + Lookup only — nothing writes this scheme any more. + """ + return _hash_payload({**payload, "date": date}) + + +def render_marker(fingerprint: str) -> str: + """Machine-readable record marker; renders as nothing in Markdown.""" + return f"{MARKER_PREFIX} v={MARKER_VERSION} fp={fingerprint} -->" + + +def render_legacy_marker(fingerprint: str) -> str: + """The v1 marker form: no version tag. Read, never written.""" + return f"{MARKER_PREFIX} fp={fingerprint} -->" + + +# ============================================================================= +# Session numbering (collision-proof across concurrent branches) +# ============================================================================= + +def _repo_relative(repo_root: Path, path: Path) -> str | None: + try: + return path.resolve().relative_to(repo_root.resolve()).as_posix() + except (OSError, ValueError): + return None + + +def _default_branch_local(repo_root: Path) -> str | None: + """Resolve the default branch without ever touching the network. + + `resolve_default_branch()` falls back to `git remote show origin`, which + can block on a fetch. Session recording is a hot path, so it stops at the + local symbolic ref and the two conventional names. + """ + rc, out, _ = run_git( + ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"], + cwd=repo_root, + timeout=GIT_PROBE_TIMEOUT, + ) + if rc == 0 and out.strip(): + return out.strip().rsplit("/", 1)[-1] + for candidate in ("main", "master"): + if branch_exists_locally(candidate, repo_root): + return candidate + return None + + +def _convergence_refs(repo_root: Path) -> list[str]: + """Refs whose recorded workspace state must be folded into the counter. + + Ordered by how much they matter, then capped: HEAD and the default branch + first (the merge target every branch eventually meets), then every other + local head — a parallel worktree's branch lives there and is the case that + actually collided — then remote-tracking refs. + """ + refs: list[str] = [] + + def add(ref: str) -> None: + if ref not in refs: + refs.append(ref) + + rc, _, _ = run_git(["rev-parse", "--verify", "--quiet", "HEAD"], cwd=repo_root) + if rc == 0: + add("HEAD") + + default = _default_branch_local(repo_root) + if default: + for ref in (f"refs/heads/{default}", f"refs/remotes/origin/{default}"): + rc, _, _ = run_git( + ["show-ref", "--verify", "--quiet", ref], cwd=repo_root + ) + if rc == 0: + add(ref) + + for pattern in ("refs/heads/", "refs/remotes/"): + rc, out, _ = run_git( + ["for-each-ref", "--format=%(refname)", pattern], + cwd=repo_root, + timeout=GIT_PROBE_TIMEOUT, + ) + if rc != 0: + continue + for line in out.splitlines(): + ref = line.strip() + # A symbolic ref like refs/remotes/origin/HEAD is a duplicate of + # the branch it points at and git grep rejects some of them. + if ref and not ref.endswith("/HEAD"): + add(ref) + + return refs[:MAX_CONVERGENCE_REFS] + + +def _max_session_across_refs(repo_root: Path, refs: list[str], dev_rel: str) -> int: + """Highest session number recorded under `dev_rel` in any of `refs`. + + One `git grep` over every ref rather than an ls-tree + show per ref: the + number of branches in a real repo is unbounded, the per-ref cost is not. + Exit status 1 (no match) is a normal outcome, not an error. + """ + if not refs: + return 0 + + rc, out, _ = run_git( + [ + "grep", + "--no-color", + "-h", + "-E", + "-e", + r"^## Session [0-9]+:", + "-e", + r"Total Sessions", + ] + + refs + + ["--", dev_rel], + cwd=repo_root, + timeout=GIT_PROBE_TIMEOUT, + ) + if rc > 1 or not out: + return 0 + + # `git grep` prefixes each hit with `<rev>:<path>:`, so the numbers are + # matched anywhere in the line rather than anchored. + numbers = [int(m.group(1)) for m in re.finditer(r"## Session (\d+):", out)] + numbers += [ + int(m.group(1)) for m in re.finditer(r"Total Sessions[^\d]{0,8}(\d+)", out) + ] + return max(numbers) if numbers else 0 + + +def max_local_session(dev_dir: Path, index_file: Path) -> int: + """Highest session number visible in the working tree.""" + highest = get_current_session(index_file) + for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"): + if not f.is_file(): + continue + try: + content = f.read_text(encoding="utf-8") + except OSError: + continue + highest = max(highest, _max_session_in_journal(content)) + return highest + + +def resolve_next_session(repo_root: Path, dev_dir: Path, index_file: Path) -> int: + """Next session number, converged across the local tree and other branches. + + Deriving the number from the working tree alone lets two branches that + each record before merging claim the same number (observed twice on + 2026-08-06). Taking the union with every recorded ref — the default + branch, and the other local heads a parallel worktree records on — makes + the number monotonic across concurrent branches; `journal-*.md` + merge=union then keeps both entries when the branches meet. + """ + highest = max_local_session(dev_dir, index_file) + + dev_rel = _repo_relative(repo_root, dev_dir) + if dev_rel: + highest = max( + highest, + _max_session_across_refs( + repo_root, _convergence_refs(repo_root), dev_rel + ), + ) + + return highest + 1 + + +# ============================================================================= +# Pending-record classifier (R3/R4) +# ============================================================================= + +def find_marker_entries(dev_dir: Path, marker: str) -> list[tuple[Path, int | None]]: + """Locate every journal entry carrying `marker`. + + Returns (journal_file, session_number); the number is None when the marker + is not directly under a `## Session N:` heading, which is malformed + pending evidence and must never be guessed into completion. + """ + hits: list[tuple[Path, int | None]] = [] + for f in sorted(dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md")): + if not f.is_file(): + continue + try: + lines = f.read_text(encoding="utf-8").splitlines() + except OSError: + continue + for i, line in enumerate(lines): + if line.strip() != marker: + continue + session_num: int | None = None + for j in range(i - 1, max(-1, i - 4), -1): + match = SESSION_HEADING_RE.match(lines[j]) + if match: + session_num = int(match.group(1)) + break + hits.append((f, session_num)) + return hits + + +def content_at_head(repo_root: Path, path: Path) -> str | None: + """File content as committed at HEAD, or None when it isn't there. + + The `./` prefix makes the path cwd-relative. `HEAD:<path>` alone is + relative to the *git* toplevel, which is not always `repo_root` — the + Trellis root is the nearest directory holding `.trellis/`, which can sit + below the git root. Without it, every lookup in that layout fails and a + committed record looks pending. + """ + rel = _repo_relative(repo_root, path) + if not rel: + return None + rc, out, _ = run_git(["show", f"HEAD:./{rel}"], cwd=repo_root) + return out if rc == 0 else None + + +def index_has_session_row(index_file: Path, session_num: int) -> bool: + """Whether the session-history block already holds this session's row.""" + if not index_file.is_file(): + return False + try: + lines = index_file.read_text(encoding="utf-8").splitlines() + except OSError: + return False + + row_re = re.compile(r"^\|\s*%d\s*\|" % session_num) + in_history = False + for line in lines: + if "@@@auto:session-history" in line: + in_history = True + continue + if "@@@/auto:session-history" in line: + in_history = False + continue + if in_history and row_re.match(line): + return True + return False + + +def _entry_date_at(lines: list[str], marker_index: int) -> str | None: + """The `**Date**:` value belonging to the entry whose marker is at `marker_index`. + + `generate_session_content` renders it two lines below the marker. Scanning + a short window rather than a fixed offset keeps this working if blank-line + spacing around the header ever changes, and stops well before it could + reach the next entry. + """ + for line in lines[marker_index + 1:marker_index + 6]: + match = ENTRY_DATE_RE.match(line.strip()) + if match: + return match.group(1) + return None + + +def resolve_effective_marker( + dev_dir: Path, + payload: dict, + marker: str, +) -> tuple[str, str | None]: + """The marker this record actually carries, across both schemes. + + Returns (marker, error). The v2 marker wins whenever an entry carries it, + which is every record written since the change and costs one scan. + + Otherwise this looks for a pending record written under v1. Those markers + are only a hash, but the entry renders its own `**Date**:` line right + below, so the date that produced the v1 fingerprint is recoverable from + the entry itself: recompute with that date and compare against what is + actually written there. That is exact, and unlike a +/-1 day window it does + not quietly fail a retry resumed after a weekend. + + Returning the v1 marker leaves the entry untouched — it is an in-flight + record about to be committed, and rewriting its marker mid-repair would + move it to a state the machine does not model. + """ + if find_marker_entries(dev_dir, marker): + return marker, None + + matches: set[str] = set() + for journal in sorted(dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md")): + if not journal.is_file(): + continue + try: + lines = journal.read_text(encoding="utf-8").splitlines() + except OSError: + continue + for i, line in enumerate(lines): + legacy = LEGACY_MARKER_RE.match(line.strip()) + if not legacy: + continue + date = _entry_date_at(lines, i) + if date is None: + continue + if compute_legacy_fingerprint(payload, date) == legacy.group(1): + matches.add(line.strip()) + + if len(matches) > 1: + return "", ( + f"found {len(matches)} pending journal entries matching this record " + "under the pre-versioning marker scheme. Refusing to guess which one " + "to resume — remove the duplicate entry or pass --idempotency-key to " + "record a new session." + ) + if matches: + return matches.pop(), None + return marker, None + + +def classify_record( + repo_root: Path, + dev_dir: Path, + index_file: Path, + marker: str, +) -> tuple[str, Path | None, int | None, str | None]: + """Classify the current state of this exact record. + + Returns (state, journal_file, session_num, error). Only a unique, exact, + still-uncommitted match is ever adopted; anything ambiguous, malformed or + partially written comes back as an error so the caller fails safely. + """ + hits = find_marker_entries(dev_dir, marker) + + if not hits: + return STATE_ABSENT, None, None, None + + if len(hits) > 1: + files = ", ".join(sorted({p.name for p, _ in hits})) + return "", None, None, ( + f"found {len(hits)} journal entries carrying this record's marker " + f"({files}). Refusing to guess which one to resume — remove the " + "duplicate entry or pass --idempotency-key to record a new session." + ) + + journal_file, session_num = hits[0] + if session_num is None: + return "", None, None, ( + f"{journal_file.name} carries this record's marker without a " + "'## Session N:' heading above it. The pending record is malformed; " + "repair it by hand before retrying." + ) + + head_content = content_at_head(repo_root, journal_file) + if head_content is not None and marker in head_content: + return STATE_COMMITTED, journal_file, session_num, None + + if index_has_session_row(index_file, session_num): + return STATE_INDEX_RECORDED, journal_file, session_num, None + + recorded_total = get_current_session(index_file) + if recorded_total > session_num: + return "", None, None, ( + f"journal entry for session {session_num} has no index row, but " + f"index.md already counts {recorded_total} sessions. Repairing the " + "row would rewrite a later record — resolve index.md by hand." + ) + + return STATE_JOURNAL_RECORDED, journal_file, session_num, None + + +# ============================================================================= +# Rendering +# ============================================================================= + def _render_bullet_section(header: str, items: list[str], bullet_prefix: str = "- ") -> str: """Render a Markdown section as bullets, or "" when there is no content. @@ -267,9 +893,10 @@ def _render_main_changes(changes: list[str], extra_content: str | None) -> str: def generate_session_content( session_num: int, title: str, - commit: str, + evidence: list[tuple[str, str]], summary: str, today: str, + marker: str, package: str | None = None, branch: str | None = None, changes: list[str] | None = None, @@ -278,12 +905,11 @@ def generate_session_content( next_steps: list[str] | None = None, ) -> str: """Generate session content.""" - if commit and commit != "-": + if evidence: commit_table = """| Hash | Message | |------|---------|""" - for c in commit.split(","): - c = c.strip() - commit_table += f"\n| `{c}` | (see git log) |" + for oid, subject in evidence: + commit_table += f"\n| `{oid}` | {escape_markdown_cell(subject)} |" else: commit_table = "(No commits - planning session)" @@ -297,6 +923,7 @@ def generate_session_content( return f""" ## Session {session_num}: {title} +{marker} **Date**: {today} **Task**: {title}{package_line}{branch_line} @@ -315,21 +942,25 @@ def generate_session_content( """ +def format_commit_display(evidence: list[tuple[str, str]]) -> str: + """Index-table rendering of the commit OIDs.""" + if not evidence: + return "-" + return ", ".join(f"`{oid}`" for oid, _ in evidence) + + def update_index( index_file: Path, dev_dir: Path, title: str, - commit: str, + evidence: list[tuple[str, str]], new_session: int, active_file: str, today: str, branch: str | None = None, ) -> bool: """Update index.md with new session info.""" - # Format commit for display - commit_display = "-" - if commit and commit != "-": - commit_display = re.sub(r"([a-f0-9]{7,})", r"`\1`", commit.replace(",", ", ")) + commit_display = format_commit_display(evidence) # Get file number from active_file name match = re.search(r"(\d+)", active_file) @@ -425,7 +1056,9 @@ def update_index( new_lines.append(line) - index_file.write_text("\n".join(new_lines), encoding="utf-8") + if not write_text_atomic(index_file, "\n".join(new_lines)): + print(f"Error: failed to write {index_file}", file=sys.stderr) + return False print("[OK] Updated index.md successfully!") return True @@ -434,7 +1067,7 @@ def update_index( # Main Function # ============================================================================= -def _auto_commit_workspace(repo_root: Path) -> None: +def _auto_commit_workspace(repo_root: Path) -> str: """Stage Trellis-owned workspace + current-task paths and commit. Path scope is restricted to specific products: the current developer's @@ -447,13 +1080,30 @@ def _auto_commit_workspace(repo_root: Path) -> None: Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when set to ``false``, this function returns immediately without touching git (journal/index files are still written to disk by the caller). + + Returns one of ``COMMIT_DONE`` / ``COMMIT_SKIPPED`` / ``COMMIT_BLOCKED`` / + ``COMMIT_FAILED``. ``COMMIT_BLOCKED`` is the gitignored-`.trellis/` case: + the user has told git to stay out of this tree, so it is a configured skip + like ``session_auto_commit: false``, not a failure to retry. """ if not get_session_auto_commit(repo_root): print( "[OK] session_auto_commit: false — skipping git stage/commit.", file=sys.stderr, ) - return + return COMMIT_SKIPPED + + # Not a git repository at all: like the gitignored case, this is an + # environment the user configured, not a transient git error — a retry + # can never succeed, so it must not be COMMIT_FAILED's exit-1 loop. + rc, _, _ = run_git(["rev-parse", "--is-inside-work-tree"], cwd=repo_root) + if rc != 0: + print( + "[WARN] Not a git repository — journal/index written, " + "skipping auto-commit.", + file=sys.stderr, + ) + return COMMIT_BLOCKED commit_msg = get_session_commit_message(repo_root) # Resolve the current task so staging is scoped to its dir only. The ref @@ -475,18 +1125,18 @@ def _auto_commit_workspace(repo_root: Path) -> None: ] if not paths: print("[OK] No workspace changes to commit.", file=sys.stderr) - return + return COMMIT_SKIPPED success, _, err = safe_git_add(paths, repo_root) if not success: if err and "ignored by" in err.lower(): print_gitignore_warning(paths) - else: - print( - f"[WARN] git add failed: {err.strip() if err else 'unknown error'}", - file=sys.stderr, - ) - return + return COMMIT_BLOCKED + print( + f"[WARN] git add failed: {err.strip() if err else 'unknown error'}", + file=sys.stderr, + ) + return COMMIT_FAILED # Check if there are staged changes for the paths we just staged. rc, _, _ = run_git( @@ -494,16 +1144,44 @@ def _auto_commit_workspace(repo_root: Path) -> None: ) if rc == 0: print("[OK] No workspace changes to commit.", file=sys.stderr) - return + return COMMIT_SKIPPED - rc, _, commit_err = run_git(["commit", "-m", commit_msg], cwd=repo_root) + # Commit with an explicit pathspec: a bare `git commit` would sweep any + # unrelated entries the developer had staged before this script ran into + # the chore commit (#579). The pathspec keeps their staged work untouched. + rc, _, commit_err = run_git( + ["commit", "-m", commit_msg, "--", *paths], cwd=repo_root + ) if rc == 0: print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr) - else: - print( - f"[WARN] Auto-commit failed: {commit_err.strip()}", - file=sys.stderr, - ) + return COMMIT_DONE + + print( + f"[WARN] Auto-commit failed: {commit_err.strip()}", + file=sys.stderr, + ) + return COMMIT_FAILED + + +def _print_commit_checkpoint(session_num: int, journal_name: str) -> None: + """Actionable checkpoint for a failed auto-commit (R5).""" + print("", file=sys.stderr) + print( + f"[BLOCKED] Checkpoint: session {session_num} is recorded in " + f"{journal_name} and index.md, but the auto-commit did not complete.", + file=sys.stderr, + ) + print( + "[BLOCKED] The pending record was left exactly as written — nothing was " + "rolled back.", + file=sys.stderr, + ) + print( + "[BLOCKED] Fix the git error above, then re-run the identical " + "add_session.py command: it resumes at the commit step and will not add " + "a second session.", + file=sys.stderr, + ) def add_session( @@ -517,8 +1195,10 @@ def add_session( auto_commit: bool = True, package: str | None = None, branch: str | None = None, + commit_subjects: list[str] | None = None, + idempotency_key: str | None = None, ) -> int: - """Add a new session.""" + """Add a new session, resuming an interrupted one instead of duplicating it.""" repo_root = get_repo_root() warn_if_parallel_worktree(repo_root) ensure_developer(repo_root) @@ -533,66 +1213,203 @@ def add_session( print("Error: Workspace directory not found", file=sys.stderr) return 1 - max_lines = get_max_journal_lines(repo_root) + # ------------------------------------------------------------------- + # Preflight — no writes happen until every one of these succeeds. + # ------------------------------------------------------------------- + if idempotency_key is not None and not IDEMPOTENCY_KEY_RE.match(idempotency_key): + print( + f"Error: invalid --idempotency-key '{idempotency_key}': expected 1-64 " + "characters from [A-Za-z0-9._-]", + file=sys.stderr, + ) + return 1 + tokens, token_error = parse_commit_tokens(commit) + if token_error: + print(f"Error: {token_error}", file=sys.stderr) + return 1 + + overrides, override_error = parse_subject_overrides(commit_subjects) + if override_error: + print(f"Error: {override_error}", file=sys.stderr) + return 1 + + evidence, evidence_error = build_commit_evidence(repo_root, tokens, overrides) + if evidence_error: + print(f"Error: {evidence_error}", file=sys.stderr) + return 1 + + max_lines = get_max_journal_lines(repo_root) index_file = dev_dir / "index.md" today = datetime.now().strftime("%Y-%m-%d") - journal_file, current_num, current_lines = get_latest_journal_info(dev_dir) - current_session = get_current_session(index_file) - new_session = current_session + 1 - - session_content = generate_session_content( - new_session, title, commit, summary, today, package, branch, - changes=changes, extra_content=extra_content, tests=tests, - next_steps=next_steps, + payload = _fingerprint_payload( + developer, title, summary, package, branch, evidence, + changes, extra_content, tests, next_steps, idempotency_key, ) - content_lines = len(session_content.splitlines()) + marker = render_marker(compute_record_fingerprint(payload)) + + # `today` is no longer a fingerprint input; a record has to be findable by + # its own retry after a date rollover. It still dates the rendered entry. + marker, resolve_error = resolve_effective_marker(dev_dir, payload, marker) + if resolve_error: + print(f"Error: {resolve_error}", file=sys.stderr) + return 1 + + state, matched_file, matched_num, classify_error = classify_record( + repo_root, dev_dir, index_file, marker + ) + if classify_error: + print(f"Error: {classify_error}", file=sys.stderr) + return 1 + + if state == STATE_COMMITTED and idempotency_key: + print( + f"[OK] Session {matched_num} with idempotency key " + f"'{idempotency_key}' is already recorded and committed in " + f"{matched_file.name if matched_file else 'the journal'}; " + "nothing to do.", + file=sys.stderr, + ) + return 0 + + # A committed record is finished, so an identical later request is a + # legitimately new session. It must not reuse the committed marker: two + # entries carrying one marker make every later run ambiguous, and + # classify_record refuses to guess between them. Step to the next + # generation of this record instead. The marker stays a pure function of + # (payload, generation), so a retry of the new entry recomputes the same + # marker and still resumes; generation 0 omits the field entirely, leaving + # first-time markers byte-identical to what this scheme already writes. + generation = 0 + while state == STATE_COMMITTED: + generation += 1 + marker = render_marker( + compute_record_fingerprint({**payload, "generation": generation}) + ) + state, matched_file, matched_num, classify_error = classify_record( + repo_root, dev_dir, index_file, marker + ) + if classify_error: + print(f"Error: {classify_error}", file=sys.stderr) + return 1 print("========================================", file=sys.stderr) print("ADD SESSION", file=sys.stderr) print("========================================", file=sys.stderr) print("", file=sys.stderr) - print(f"Session: {new_session}", file=sys.stderr) - print(f"Title: {title}", file=sys.stderr) - print(f"Commit: {commit}", file=sys.stderr) - print("", file=sys.stderr) - print(f"Current journal file: {FILE_JOURNAL_PREFIX}{current_num}.md", file=sys.stderr) - print(f"Current lines: {current_lines}", file=sys.stderr) - print(f"New content lines: {content_lines}", file=sys.stderr) - print(f"Total after append: {current_lines + content_lines}", file=sys.stderr) - print("", file=sys.stderr) - target_file = journal_file - target_num = current_num + # ------------------------------------------------------------------- + # Absent → journal-recorded + # ------------------------------------------------------------------- + target_file: Path | None + target_num: int + new_session: int - if current_lines + content_lines > max_lines: - target_num = current_num + 1 - print(f"[!] Exceeds {max_lines} lines, creating {FILE_JOURNAL_PREFIX}{target_num}.md", file=sys.stderr) - target_file = create_new_journal_file(dev_dir, target_num, developer, today, max_lines) - print(f"Created: {target_file}", file=sys.stderr) + if state == STATE_ABSENT: + journal_file, current_num, current_lines = get_latest_journal_info(dev_dir) + new_session = resolve_next_session(repo_root, dev_dir, index_file) - # Append session content - if target_file: - with target_file.open("a", encoding="utf-8") as f: - f.write(session_content) + session_content = generate_session_content( + new_session, title, evidence, summary, today, marker, package, branch, + changes=changes, extra_content=extra_content, tests=tests, + next_steps=next_steps, + ) + content_lines = len(session_content.splitlines()) + + print(f"Session: {new_session}", file=sys.stderr) + print(f"Title: {title}", file=sys.stderr) + print(f"Commit: {format_commit_display(evidence)}", file=sys.stderr) + print("", file=sys.stderr) + print(f"Current journal file: {FILE_JOURNAL_PREFIX}{current_num}.md", file=sys.stderr) + print(f"Current lines: {current_lines}", file=sys.stderr) + print(f"New content lines: {content_lines}", file=sys.stderr) + print(f"Total after append: {current_lines + content_lines}", file=sys.stderr) + print("", file=sys.stderr) + + target_file = journal_file + target_num = current_num + + if current_lines + content_lines > max_lines: + target_num = current_num + 1 + print(f"[!] Exceeds {max_lines} lines, creating {FILE_JOURNAL_PREFIX}{target_num}.md", file=sys.stderr) + target_file = create_new_journal_file(dev_dir, target_num, developer, today, max_lines) + if target_file is None: + print( + f"Error: failed to create {FILE_JOURNAL_PREFIX}{target_num}.md; " + "nothing was recorded.", + file=sys.stderr, + ) + return 1 + print(f"Created: {target_file}", file=sys.stderr) + + if target_file is None: + print( + "Error: no journal file to append to. Run init_developer.py first.", + file=sys.stderr, + ) + return 1 + + try: + existing = target_file.read_text(encoding="utf-8") if target_file.is_file() else "" + except OSError as exc: + print(f"Error: cannot read {target_file}: {exc}", file=sys.stderr) + return 1 + + if not write_text_atomic(target_file, existing + session_content): + print( + f"Error: failed to append the session to {target_file.name}; " + "the previous journal content is intact and nothing was recorded.", + file=sys.stderr, + ) + return 1 print(f"[OK] Appended session to {target_file.name}", file=sys.stderr) + state = STATE_JOURNAL_RECORDED + else: + if matched_file is None or matched_num is None: + print( + f"Error: resumed state '{state}' without a matching journal entry.", + file=sys.stderr, + ) + return 1 + target_file = matched_file + new_session = matched_num + target_num = _extract_journal_num(target_file.stem) + print( + f"[RESUME] Session {new_session} is already in {target_file.name} and " + f"uncommitted; continuing from '{state}' instead of appending again.", + file=sys.stderr, + ) + print("", file=sys.stderr) print("", file=sys.stderr) - # Update index.md + # ------------------------------------------------------------------- + # Journal-recorded → index-recorded + # ------------------------------------------------------------------- active_file = f"{FILE_JOURNAL_PREFIX}{target_num}.md" - if not update_index( - index_file, - dev_dir, - title, - commit, - new_session, - active_file, - today, - branch, - ): - return 1 + if state == STATE_JOURNAL_RECORDED: + if not update_index( + index_file, + dev_dir, + title, + evidence, + new_session, + active_file, + today, + branch, + ): + print( + f"[BLOCKED] Checkpoint: session {new_session} is in " + f"{active_file} but index.md was not updated. Fix index.md, then " + "re-run the identical command to repair the row without adding a " + "second session.", + file=sys.stderr, + ) + return 1 + state = STATE_INDEX_RECORDED + else: + print(f"[OK] index.md already records session {new_session}.", file=sys.stderr) print("", file=sys.stderr) print("========================================", file=sys.stderr) @@ -603,10 +1420,31 @@ def add_session( print(f" - {target_file.name if target_file else 'journal'}", file=sys.stderr) print(" - index.md", file=sys.stderr) - # Auto-commit workspace changes - if auto_commit: - print("", file=sys.stderr) - _auto_commit_workspace(repo_root) + # ------------------------------------------------------------------- + # Index-recorded → committed + # ------------------------------------------------------------------- + if not auto_commit: + return 0 + + print("", file=sys.stderr) + outcome = _auto_commit_workspace(repo_root) + + if outcome == COMMIT_FAILED: + _print_commit_checkpoint( + new_session, target_file.name if target_file else "the journal" + ) + return 1 + + if outcome == COMMIT_DONE and target_file is not None: + committed = content_at_head(repo_root, target_file) + if committed is None or marker not in committed: + print( + f"[WARN] Auto-commit reported success but {target_file.name} at " + "HEAD does not contain this session's record.", + file=sys.stderr, + ) + _print_commit_checkpoint(new_session, target_file.name) + return 1 return 0 @@ -621,7 +1459,24 @@ def main() -> int: description="Add a new session to journal file and update index.md" ) parser.add_argument("--title", required=True, help="Session title") - parser.add_argument("--commit", default="-", help="Comma-separated commit hashes") + parser.add_argument( + "--commit", + default="-", + help=( + "Comma-separated commit OIDs (7-40 hex chars each). Each one is " + "resolved to its real subject before anything is written; an " + "unresolvable OID fails the command. Use '-' for a planning session." + ), + ) + parser.add_argument( + "--commit-subject", + action="append", + metavar="OID=SUBJECT", + help=( + "Explicit subject for a commit that cannot be resolved locally " + "(repeatable). The mapping must be one-to-one with --commit." + ), + ) parser.add_argument("--summary", default="Session summary was not supplied.", help="Brief summary") parser.add_argument("--content-file", help="Path to file with detailed content") parser.add_argument("--package", help="Package name tag (e.g., cli, docs-site)") @@ -629,6 +1484,13 @@ def main() -> int: parser.add_argument("--change", action="append", help="Main Changes bullet (repeatable)") parser.add_argument("--test", action="append", help="Testing bullet (repeatable)") parser.add_argument("--next-step", action="append", help="Next Steps bullet (repeatable)") + parser.add_argument( + "--idempotency-key", + help=( + "Caller-supplied retry key ([A-Za-z0-9._-], 1-64 chars). Makes an " + "already-committed identical record a no-op instead of a new session." + ), + ) parser.add_argument("--no-commit", action="store_true", help="Skip auto-commit of workspace changes") parser.add_argument("--stdin", action="store_true", @@ -674,6 +1536,8 @@ def main() -> int: auto_commit=not args.no_commit, package=package, branch=branch, + commit_subjects=args.commit_subject, + idempotency_key=args.idempotency_key, ) diff --git a/.trellis/scripts/common/__init__.py b/.trellis/scripts/common/__init__.py index 6d723603..4d8e0fe6 100755 --- a/.trellis/scripts/common/__init__.py +++ b/.trellis/scripts/common/__init__.py @@ -66,6 +66,8 @@ from .paths import ( FILE_CURRENT_TASK, FILE_TASK_JSON, FILE_JOURNAL_PREFIX, + ENV_DEVELOPER, + DEVELOPER_HINT, get_repo_root, get_developer, check_developer, diff --git a/.trellis/scripts/common/active_task.py b/.trellis/scripts/common/active_task.py index 0eec6dfd..b9e4f872 100755 --- a/.trellis/scripts/common/active_task.py +++ b/.trellis/scripts/common/active_task.py @@ -9,7 +9,6 @@ session key there is no active task. from __future__ import annotations import hashlib -import json import os import re import sys @@ -19,12 +18,21 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any +from .io import read_json as _io_read_json, write_json as _io_write_json + DIR_WORKFLOW = ".trellis" DIR_TASKS = "tasks" DIR_RUNTIME = ".runtime" DIR_SESSIONS = "sessions" -DIR_CURSOR_SHELL = "cursor-shell" -CURSOR_SHELL_TICKET_TTL_SECONDS = 30 +DIR_SHELL_TICKETS = "shell-tickets" +# 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"} _SESSION_KEYS = ("session_id", "sessionId", "sessionID") @@ -48,37 +56,87 @@ _KNOWN_PLATFORMS = { "kimi", "zcode", "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, ...]], ...] = ( - ("claude", ("CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID")), - ("codex", ("CODEX_SESSION_ID", "CODEX_THREAD_ID")), - ("cursor", ("CURSOR_SESSION_ID",)), - ("opencode", ("OPENCODE_SESSION_ID", "OPENCODE_SESSIONID", "OPENCODE_RUN_ID")), + # REAL (reported 2026-08-13 against DSH 0.1.0-rc.6 by @SajoLuo, from a live + # run: DSH exports DSH_SESSION_ID plus DSH_SHELL=1 into its managed shell). + # MUST STAY FIRST. A DSH session can inherit an outer host's identity — a + # 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",)), - ("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",)), - ("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",)), + # 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")), - ("pi", ("PI_SESSION_ID", "PI_SESSIONID")), - ("trae", ("TRAE_SESSION_ID",)), - # ZCode reuses CLAUDE_SESSION_ID (it does not document a ZCODE_SESSION_ID). - # Platform-scoped lookup (_iter_env_keys filters by platform name), so this - # only fires when the resolver already detected "zcode" — no collision with + # REASONED, UNVERIFIED (2026-08-05): ZCode is closed-source and not + # installable here. It mirrors Claude's naming elsewhere (CLAUDE_PLUGIN_ROOT + # / CLAUDE_PLUGIN_DATA compat aliases are in its docs), and the previously + # declared CLAUDE_SESSION_ID does not exist on Claude Code either — so the + # 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. - ("zcode", ("CLAUDE_SESSION_ID",)), - # Snow CLI exports SNOW_SESSION_ID into hook/terminal/sub-agent children. - # TRELLIS_CONTEXT_ID remains the preferred override when present. + ("zcode", ("CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID")), + # REAL by vendor design (verified 2026-08-05): Snow's sessionIdentityEnv.ts + # 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",)), ) _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")), ) _ENV_TRANSCRIPT_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = ( - ("claude", ("CLAUDE_TRANSCRIPT_PATH",)), - ("codex", ("CODEX_TRANSCRIPT_PATH",)), + # REAL but HOOK-SCOPE ONLY (verified 2026-08-05): documented for Cursor hook + # scripts; empty in the agent's own shell env. ("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",)), ("droid", ("FACTORY_TRANSCRIPT_PATH", "DROID_TRANSCRIPT_PATH")), ("qoder", ("QODER_TRANSCRIPT_PATH",)), @@ -90,11 +148,15 @@ _ENV_PLATFORM_ALIASES = { "factory-ai": "droid", "github-copilot": "copilot", } -# ZCode intentionally reuses CLAUDE_SESSION_ID. Hooks know the host is ZCode, -# while later shell commands see only the shared env name and resolve it through -# the Claude entry. Canonicalize both paths to one runtime filename. +# ZCode intentionally reuses Claude's session env var name. Hooks know the host +# is ZCode, while later shell commands see only the shared env name and resolve +# it through the claude entry. Canonicalize both paths to one runtime filename. _CONTEXT_KEY_PLATFORM_ALIASES = { "zcode": "claude", + # 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: - """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) if not normalized: return None + try: + root = repo_root.resolve() + except OSError: + return None + path_obj = Path(normalized) 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}/"): - return repo_root / path_obj + # Both sides are resolved because repo_root itself may sit behind a + # 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: @@ -222,6 +319,12 @@ def _iter_env_keys( env_keys: tuple[tuple[str, tuple[str, ...]], ...], platform_name: str | None, ) -> 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: return env_keys 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 -def _cursor_shell_ticket_dir(repo_root: Path) -> Path: - return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_CURSOR_SHELL +def _shell_ticket_dirs(repo_root: Path) -> tuple[Path, ...]: + 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: @@ -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") 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 _remove_file(ticket_path) return False @@ -352,13 +459,18 @@ def _ticket_cwd_matches_repo(ticket: dict[str, Any], repo_root: Path) -> bool: return True -def _matching_cursor_ticket_context_key( +def _matching_ticket_context_key( ticket_path: Path, repo_root: Path, now: float, ) -> 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) - if ticket is None or ticket.get("platform") != "cursor": + if ticket is None: return None if not _ticket_is_fresh(ticket, ticket_path, now): return None @@ -369,29 +481,30 @@ def _matching_cursor_ticket_context_key( return _string_value(ticket.get("context_key")) -def _lookup_cursor_shell_ticket_context_key() -> str | None: - """Resolve Cursor conversation identity from a short-lived shell ticket. +def _lookup_shell_ticket_context_key() -> str | None: + """Resolve session identity from a short-lived shell ticket. - Cursor exposes `conversation_id` to `beforeShellExecution`, but does not - export it into the shell command environment. The Cursor hook writes a - short-lived ticket just before `task.py` runs. We accept a ticket only when - the current `task.py` subcommand matches and exactly one fresh context key - matches, which avoids cross-window pointer contamination. + No researched platform exports its session id into a shell child, but every + hook-capable one hands that id to a hook. So the hook that runs just before + a shell command writes a ticket, and this reads it back. A ticket counts + only when it is fresh, was written for this repo, and matches the `task.py` + 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() if repo_root is None: return None - ticket_dir = _cursor_shell_ticket_dir(repo_root) - if not ticket_dir.is_dir(): - return None - now = time.time() candidates: set[str] = set() - for ticket_path in ticket_dir.glob("*.json"): - context_key = _matching_cursor_ticket_context_key(ticket_path, repo_root, now) - if context_key: - candidates.add(context_key) + for ticket_dir in _shell_ticket_dirs(repo_root): + if not ticket_dir.is_dir(): + continue + for ticket_path in ticket_dir.glob("*.json"): + context_key = _matching_ticket_context_key(ticket_path, repo_root, now) + if context_key: + candidates.add(context_key) if len(candidates) == 1: return next(iter(candidates)) @@ -435,29 +548,32 @@ def resolve_context_key( if env_context_key: return env_context_key - if allow_environment_context and platform_name in (None, "session", "cursor"): - return _lookup_cursor_shell_ticket_context_key() + # Last in the chain on purpose: a platform that genuinely exports identity + # 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 def _read_json(path: Path) -> dict[str, Any] | None: - try: - data = json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return None + """Tolerant read of a session runtime file, non-objects included.""" + data = _io_read_json(path) return data if isinstance(data, dict) else None 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: path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - json.dumps(data, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) - return True except OSError: return False + return _io_write_json(path, data) 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(): return None try: - return full_path.relative_to(repo_root).as_posix() + return full_path.relative_to(repo_root.resolve()).as_posix() 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( @@ -652,6 +805,48 @@ def clear_task_from_sessions(task_path: str, repo_root: Path) -> int: 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( repo_root: Path, platform_input: dict[str, Any] | None = None, diff --git a/.trellis/scripts/common/config.py b/.trellis/scripts/common/config.py index 99f79b23..f8fb7f8d 100755 --- a/.trellis/scripts/common/config.py +++ b/.trellis/scripts/common/config.py @@ -11,157 +11,11 @@ import sys from pathlib import Path from .paths import DIR_WORKFLOW, get_repo_root +from .trellis_config import parse_simple_yaml - -# ============================================================================= -# YAML Simple Parser (no dependencies) -# ============================================================================= - - -def _unquote(s: str) -> str: - """Remove exactly one layer of matching surrounding quotes. - - Unlike str.strip('"'), this only removes the outermost pair, - preserving any nested quotes inside the value. - - Examples: - _unquote('"hello"') -> 'hello' - _unquote("'hello'") -> 'hello' - _unquote('"echo \\'hi\\'"') -> "echo 'hi'" - _unquote('hello') -> 'hello' - _unquote('"hello\\'') -> '"hello\\'' (mismatched, unchanged) - """ - if len(s) >= 2 and s[0] == s[-1] and s[0] in ('"', "'"): - return s[1:-1] - return s - - -def _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, "" +# 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- +# equivalent copies is a drift hazard, not a feature. # Defaults @@ -173,13 +27,46 @@ DEFAULT_CODEX_DISPATCH_MODE = "auto" CONFIG_FILE = "config.yaml" -def _is_true_config_value(value: object) -> bool: - """Return True when a config value represents an enabled flag.""" +TRUE_CONFIG_VALUES = ("true", "yes", "1", "on") +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): return value - if isinstance(value, str): - return value.strip().lower() == "true" - return False + s = str(value).strip().lower() + if s in TRUE_CONFIG_VALUES: + return True + if s in FALSE_CONFIG_VALUES: + 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: @@ -189,13 +76,27 @@ def _get_config_path(repo_root: Path | None = None) -> Path: 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) try: content = config_file.read_text(encoding="utf-8") - return parse_simple_yaml(content) except (OSError, IOError): 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: @@ -231,18 +132,9 @@ def get_session_auto_commit(repo_root: Path | None = None) -> bool: """ config = _load_config(repo_root) raw = config.get("session_auto_commit", DEFAULT_SESSION_AUTO_COMMIT) - if isinstance(raw, bool): - return raw - s = str(raw).strip().lower() - if s in ("true", "yes", "1", "on"): - return True - if s in ("false", "no", "0", "off"): - return False - print( - f"[WARN] invalid session_auto_commit value: {raw!r}; using true (default)", - file=sys.stderr, + return coerce_config_bool( + raw, DEFAULT_SESSION_AUTO_COMMIT, "session_auto_commit" ) - return DEFAULT_SESSION_AUTO_COMMIT 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"). 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: List of shell commands to execute, empty if none configured. """ config = _load_config(repo_root) hooks = config.get("hooks") + if hooks is None: + return [] 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 [] commands = hooks.get(event) + if commands is None: + return [] if isinstance(commands, list): 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 [] @@ -471,7 +384,7 @@ def get_git_packages(repo_root: Path | None = None) -> dict[str, str]: return { name: cfg.get("path", name) 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") } diff --git a/.trellis/scripts/common/developer.py b/.trellis/scripts/common/developer.py index c203a316..52cb9e13 100755 --- a/.trellis/scripts/common/developer.py +++ b/.trellis/scripts/common/developer.py @@ -15,6 +15,7 @@ from datetime import datetime from pathlib import Path from .paths import ( + DEVELOPER_HINT, DIR_WORKFLOW, DIR_WORKSPACE, DIR_TASKS, @@ -160,6 +161,7 @@ def ensure_developer(repo_root: Path | None = None) -> None: if not check_developer(repo_root): print("Error: Developer not initialized.", file=sys.stderr) print(f"Run: python3 ./{DIR_WORKFLOW}/scripts/init_developer.py <your-name>", file=sys.stderr) + print(DEVELOPER_HINT, file=sys.stderr) sys.exit(1) diff --git a/.trellis/scripts/common/git.py b/.trellis/scripts/common/git.py index 426836ce..48e71410 100755 --- a/.trellis/scripts/common/git.py +++ b/.trellis/scripts/common/git.py @@ -7,9 +7,25 @@ Single source of truth for running git commands across all Trellis scripts. from __future__ import annotations import subprocess +import time 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( args: list[str], cwd: Path | None = None, @@ -38,6 +54,49 @@ def run_git( 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: """Resolve the repository's default branch (origin/HEAD target). @@ -63,6 +122,76 @@ def resolve_default_branch(repo_root: Path) -> str | 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: """Check whether a local branch ref exists in the repository.""" if not branch: diff --git a/.trellis/scripts/common/io.py b/.trellis/scripts/common/io.py index c918c1f0..f0491094 100755 --- a/.trellis/scripts/common/io.py +++ b/.trellis/scripts/common/io.py @@ -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 -for JSON file operations across all Trellis scripts. +Provides read_json / write_json as the single source of truth for JSON file +operations, plus write_text_atomic for the Markdown state files (journal, +index.md) that carry durable session state. """ from __future__ import annotations @@ -13,17 +14,96 @@ import tempfile 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: """Read and parse a JSON file. 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: 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 +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: """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. """ - 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: fd, tmp = tempfile.mkstemp( 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) raise with f: - f.write(payload) + f.write(text) os.replace(tmp, path) return True except OSError: @@ -59,3 +151,10 @@ def write_json(path: Path, data: dict) -> bool: except OSError: pass 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 diff --git a/.trellis/scripts/common/paths.py b/.trellis/scripts/common/paths.py index 1c5a58e4..64d21686 100755 --- a/.trellis/scripts/common/paths.py +++ b/.trellis/scripts/common/paths.py @@ -12,10 +12,13 @@ Provides: from __future__ import annotations +import os import re from datetime import datetime from pathlib import Path +from .git import main_worktree_root + # ============================================================================= # Path Constants (change here to rename directories) @@ -35,6 +38,17 @@ FILE_CURRENT_TASK = ".current-task" FILE_TASK_JSON = "task.json" 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 @@ -66,8 +80,40 @@ def get_repo_root(start_path: Path | None = None) -> Path: # 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: - """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: repo_root: Repository root path. Defaults to auto-detected. @@ -75,23 +121,22 @@ def get_developer(repo_root: Path | None = None) -> str | None: Returns: 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: 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 - try: - content = dev_file.read_text(encoding="utf-8") - for line in content.splitlines(): - if line.startswith("name="): - return line.split("=", 1)[1].strip() - except (OSError, IOError): - pass - - return None + return _read_developer_file(main_root / DIR_WORKFLOW / FILE_DEVELOPER) 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: - """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: 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: return None + try: + root = repo_root.resolve() + except OSError: + return None + path_obj = Path(normalized) 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}/"): - return repo_root / path_obj + # resolve() collapses `..` and follows symlinks, so a task directory that + # 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( diff --git a/.trellis/scripts/common/safe_commit.py b/.trellis/scripts/common/safe_commit.py index 1a1cba28..19afa4d4 100755 --- a/.trellis/scripts/common/safe_commit.py +++ b/.trellis/scripts/common/safe_commit.py @@ -35,7 +35,7 @@ from __future__ import annotations import sys from pathlib import Path -from .git import run_git +from .git import run_git, run_git_retry_index_lock from .paths import ( DIR_ARCHIVE, DIR_TASKS, @@ -208,7 +208,7 @@ def _stderr_indicates_ignored(stderr: str) -> bool: 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]: """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 the stderr. Callers should inspect the stderr (see :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: 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: return True, False, "" return False, False, err diff --git a/.trellis/scripts/common/session_context.py b/.trellis/scripts/common/session_context.py index 8f1fd1ce..1e3fe1fb 100755 --- a/.trellis/scripts/common/session_context.py +++ b/.trellis/scripts/common/session_context.py @@ -9,6 +9,7 @@ Provides: get_context_text_record - Text for record mode output_json - Print JSON output_text - Print text + get_update_hint - Once-per-session "update available" line """ from __future__ import annotations @@ -417,8 +418,16 @@ def _compare_versions(left: str, right: str) -> int | None: return _compare_prerelease(left_prerelease, right_prerelease) -def _update_marker_path(repo_root: Path) -> Path: - context_key = resolve_context_key() +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() if not context_key: terminal_key = os.environ.get("TERM_SESSION_ID", "").strip() context_key = terminal_key or f"ppid-{os.getppid()}" @@ -433,8 +442,11 @@ def _update_marker_path(repo_root: Path) -> Path: ) -def _mark_update_check_attempted(repo_root: Path) -> bool: - marker_path = _update_marker_path(repo_root) +def _mark_update_check_attempted( + repo_root: Path, + context_key: str | None = None, +) -> bool: + marker_path = _update_marker_path(repo_root, context_key) if marker_path.exists(): return False try: @@ -445,8 +457,14 @@ def _mark_update_check_attempted(repo_root: Path) -> bool: return True -def _get_update_hint(repo_root: Path) -> str | None: - marker_path = _update_marker_path(repo_root) +def get_update_hint(repo_root: Path, context_key: str | None = None) -> str | None: + """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(): return None @@ -458,7 +476,7 @@ def _get_update_hint(repo_root: Path) -> str | None: if not latest_version: return None - _mark_update_check_attempted(repo_root) + _mark_update_check_attempted(repo_root, context_key) comparison = _compare_versions(current_version, latest_version) if comparison is None or comparison >= 0: return None @@ -867,7 +885,7 @@ def output_text(repo_root: Path | None = None) -> None: """ if repo_root is None: repo_root = get_repo_root() - update_hint = _get_update_hint(repo_root) + update_hint = get_update_hint(repo_root) if update_hint: print(update_hint) print("") diff --git a/.trellis/scripts/common/task_context.py b/.trellis/scripts/common/task_context.py index 1c7a126f..9625c057 100755 --- a/.trellis/scripts/common/task_context.py +++ b/.trellis/scripts/common/task_context.py @@ -9,10 +9,13 @@ Provides: Note: ``cmd_init_context`` was removed in v0.5.0-beta.12. JSONL context files - are now seeded at ``task.py create`` time with a self-describing - ``_example`` line; the AI agent curates real entries during planning when - the task needs sub-agent/spec context. See ``.trellis/workflow.md`` for the - current planning artifact contract. + are created empty at ``task.py create`` time; the AI agent curates real + entries during planning when the task needs sub-agent/spec context. See + ``.trellis/workflow.md`` for the current planning artifact contract. + + 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 @@ -21,11 +24,11 @@ import argparse import json 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 .io import read_json 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 # 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.""" repo_root = get_repo_root() target_dir = resolve_task_dir(args.dir, repo_root) + if target_dir is None: + return 1 jsonl_name = args.file path = args.path 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)) 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 if not jsonl_name.endswith(".jsonl"): jsonl_name = f"{jsonl_name}.jsonl" @@ -110,12 +124,41 @@ def cmd_add_context(args: argparse.Namespace) -> int: # 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: """Validate JSONL context files.""" repo_root = get_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)) return 1 @@ -170,11 +213,69 @@ def _is_exempt_from_code_file_warning(file_path: str, task_rel: str) -> bool: 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: """Validate a single JSONL file. - Seed rows (no ``file`` field — typically ``{"_example": "..."}``) are - skipped silently; they are self-describing comments, not real entries. + Historical seed-only rows are accepted in explicit inline mode. Sub-agent + mode rejects scaffolding and empty manifests. Real entries are validated + in both modes. Beyond hard errors (missing file/dir, invalid JSON), this also prints 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 errors = 0 + inline = get_codex_dispatch_mode(repo_root) == "inline" if not jsonl_file.is_file(): 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 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") entry_type = data.get("type", "file") 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 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 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)}") errors += 1 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)}") errors += 1 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)}") if max_file_bytes: - size = full_path.stat().st_size - if size > max_file_bytes: + # Advisory hygiene warning, so it must never be what fails + # `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 = ( f"{file_name}:{line_num}: Warning: {file_path} is {size} 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)}") + 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: print(f" {colored(f'{file_name}: ✓ ({real_entries} entries)', Colors.GREEN)}") else: @@ -270,7 +431,7 @@ def cmd_list_context(args: argparse.Namespace) -> int: repo_root = get_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)) return 1 @@ -285,7 +446,7 @@ def cmd_list_context(args: argparse.Namespace) -> int: print(colored(f"[{jsonl_name}]", Colors.CYAN)) count = 0 - seed_only = True + curated = False for line in jsonl_file.read_text(encoding="utf-8").splitlines(): if not line.strip(): continue @@ -295,11 +456,14 @@ def cmd_list_context(args: argparse.Namespace) -> int: except json.JSONDecodeError: continue + if not isinstance(data, dict): + continue + file_path = data.get("file") 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 - seed_only = False + curated = True count += 1 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('→', Colors.YELLOW)} {reason}") - if seed_only: - print(f" {colored('(no curated entries yet — only seed row)', Colors.YELLOW)}") + if not curated: + print(f" {colored('(no curated entries yet)', Colors.YELLOW)}") print() diff --git a/.trellis/scripts/common/task_store.py b/.trellis/scripts/common/task_store.py index 2cd97899..6ded01c0 100755 --- a/.trellis/scripts/common/task_store.py +++ b/.trellis/scripts/common/task_store.py @@ -5,6 +5,7 @@ Task CRUD operations. Provides: ensure_tasks_dir - Ensure tasks directory exists cmd_create - Create a new task + cmd_rename - Rename a task and every reference to it cmd_archive - Archive completed task cmd_set_branch - Set git branch for task cmd_set_base_branch - Set PR target branch @@ -17,9 +18,9 @@ Provides: from __future__ import annotations import argparse -import json import re import sys +from dataclasses import dataclass, field from datetime import datetime from pathlib import Path @@ -31,10 +32,20 @@ from .config import ( resolve_package, validate_package, ) -from .git import branch_exists_locally, resolve_default_branch, run_git -from .io import read_json, write_json +from .git import ( + INDEX_LOCK_RETRY_ATTEMPTS, + branch_exists_locally, + has_git_remote, + index_lock_path, + resolve_default_branch, + run_git, + run_git_retry_index_lock, + stderr_indicates_index_lock, +) +from .io import describe_json_read_failure, read_json_checked, write_json from .log import Colors, colored from .paths import ( + DEVELOPER_HINT, DIR_ARCHIVE, DIR_TASKS, DIR_WORKFLOW, @@ -50,6 +61,7 @@ from .safe_commit import ( safe_git_add, ) from .task_utils import ( + archive_destination_for, archive_task_complete, find_task_by_name, is_within_tasks_dir, @@ -62,6 +74,33 @@ from .task_utils import ( # Helper Functions # ============================================================================= +# Characters stripped before deciding whether a title / description is empty. +# This is deliberately the *union* of Python's str.strip() default set and +# ECMAScript's String.trim() set, because the two disagree: Python strips +# U+0085 (NEL) and U+001C-U+001F, which JS keeps; JS strips U+FEFF (BOM), +# which Python keeps. Stripping the union means anything create accepts is +# still non-empty after either side trims it, so a record cannot pass here and +# then be refused as empty by a JS pre-archive validator. +BLANK_CHARS = ( + "\t\n\v\f\r" # U+0009-U+000D + "\x1c\x1d\x1e\x1f" # U+001C-U+001F separators (Python only) + " " # U+0020 space + "\x85" # U+0085 next line (Python only) + "\xa0" # U+00A0 no-break space + "\u1680" # ogham space mark + "\u2000\u2001\u2002\u2003\u2004\u2005" # en/em quad, en/em/three/four-per-em + "\u2006\u2007\u2008\u2009\u200a" # six-per-em .. hair space + "\u2028\u2029" # line / paragraph separator + "\u202f\u205f\u3000" # narrow nbsp, math space, ideographic space + "\ufeff" # zero-width no-break space / BOM (JS only) +) + + +def strip_blank(value: str | None) -> str: + """Return ``value`` with :data:`BLANK_CHARS` trimmed from both ends.""" + return (value or "").strip(BLANK_CHARS) + + def _slugify(title: str) -> str: """Convert title to slug (only works with ASCII).""" result = title.lower() @@ -110,8 +149,55 @@ def _repo_relative_path(path: Path, repo_root: Path) -> str: return str(path) +def _report_read_failure(path: Path, reason: str | None) -> None: + """Print why a JSON file could not be loaded, and what to do about it. + + These commands are about to rewrite the file they just failed to read, so + this message is the last warning the user gets. Exiting non-zero with no + output at all leaves a parse error, a permissions error and an empty file + indistinguishable. + """ + problem, hint = describe_json_read_failure(path, reason) + print(colored(f"Error: {problem}", Colors.RED), file=sys.stderr) + print(hint, file=sys.stderr) + + +def _ensure_children_list(data: dict) -> list: + """The task's `children` as a list, repaired in place if it is not one. + + `.get(key, default)` returns the default only when the key is *absent*. A + task.json carrying `"children": null` — older format, or hand-edited — + yields None, and every caller below goes on to use the result as a list. + In `cmd_create` that raise lands *after* the new task.json is written, + leaving a task on disk its parent does not reference. + + The repair is written into `data` rather than only returned, because the + unlink path removes a name it may not find and would otherwise persist the + malformed value untouched — no crash, but the next caller inherits it. + + Anything that is not a list is discarded rather than coerced: a string + would iterate per character and a dict per key, each producing a plausible + child set that is not one. + """ + children = data.get("children") + if not isinstance(children, list): + children = [] + data["children"] = children + return children + + +def _report_write_failure(path: Path) -> None: + """Print that a JSON write failed. Writes are atomic, so nothing changed.""" + print(colored(f"Error: Failed to write {path}", Colors.RED), file=sys.stderr) + print( + "The existing file is unchanged (writes are atomic). " + "Check permissions and free disk space, then retry.", + file=sys.stderr, + ) + + # ============================================================================= -# Sub-agent platform detection + JSONL seeding +# Sub-agent platform detection + JSONL context files # ============================================================================= # Config directories of platforms that consume implement.jsonl / check.jsonl. @@ -139,13 +225,6 @@ _SUBAGENT_CONFIG_DIRS: tuple[str, ...] = ( ) _CODEX_CONFIG_DIR = ".codex" -_SEED_EXAMPLE = ( - "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. " - "Put spec/research files only — no code paths. " - "Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. " - "Delete this line once real entries are added." -) - def _has_subagent_platform(repo_root: Path) -> bool: """Return True if any sub-agent-capable platform is configured. @@ -163,17 +242,6 @@ def _has_subagent_platform(repo_root: Path) -> bool: return False -def _write_seed_jsonl(path: Path) -> None: - """Write a one-line seed JSONL file with a self-describing ``_example``. - - The seed row has no ``file`` field, so downstream consumers (hooks + - preludes) that iterate entries via ``item.get("file")`` naturally skip - it. The row exists purely as an in-file prompt for the AI curator. - """ - seed = {"_example": _SEED_EXAMPLE} - path.write_text(json.dumps(seed, ensure_ascii=False) + "\n", encoding="utf-8") - - def _parse_meta_pairs(pairs: list[str] | None) -> dict[str, str] | None: """Parse repeatable ``--meta key=value`` pairs into a dict. @@ -228,8 +296,27 @@ def cmd_create(args: argparse.Namespace) -> int: """Create a new task.""" repo_root = get_repo_root() - if not args.title: + # Title and description are checked first, before ensure_tasks_dir and any + # other write, so a rejected create leaves the tree untouched. Both are + # required non-empty by pre-archive validation; catching that here costs + # one retyped command instead of blocking a PR hours later. + if not strip_blank(args.title): print(colored("Error: title is required", Colors.RED), file=sys.stderr) + print( + 'Pass a non-empty <title> (whitespace only does not count): a task ' + "with an empty title is refused at archive.", + file=sys.stderr, + ) + return 1 + + description = strip_blank(getattr(args, "description", None)) + if not description: + print(colored("Error: --description is required", Colors.RED), file=sys.stderr) + print( + 'Pass --description "<what this task delivers>" (whitespace only does ' + "not count): a task with an empty description is refused at archive.", + file=sys.stderr, + ) return 1 # Validate --meta (CLI source: fail-fast, before any directory is created) @@ -260,6 +347,7 @@ def cmd_create(args: argparse.Namespace) -> int: assignee = get_developer(repo_root) if not assignee: print(colored("Error: No developer set. Run init_developer.py first or use --assignee", Colors.RED), file=sys.stderr) + print(DEVELOPER_HINT, file=sys.stderr) return 1 ensure_tasks_dir(repo_root) @@ -267,12 +355,24 @@ def cmd_create(args: argparse.Namespace) -> int: # Get current developer as creator creator = get_developer(repo_root) or assignee - # Generate slug if not provided + # Generate slug if not provided. A title-derived slug is sanitized by + # _slugify; an explicit --slug is not, so reject the characters that would + # let it escape the tasks directory once joined into the dir name. slug = args.slug or _slugify(args.title) if not slug: print(colored("Error: could not generate slug from title", Colors.RED), file=sys.stderr) return 1 + if args.slug and ("/" in slug or "\\" in slug or ".." in slug): + print( + colored( + f"Error: --slug must be a plain name without path separators or '..': {slug}", + Colors.RED, + ), + file=sys.stderr, + ) + return 1 + # Create task directory with MM-DD-slug format tasks_dir = get_tasks_dir(repo_root) date_prefix = generate_task_date_prefix() @@ -304,6 +404,28 @@ def cmd_create(args: argparse.Namespace) -> int: print(f"Pass only the slug body, e.g. --slug {m.group(3)}", file=sys.stderr) return 1 + # Resolve --parent before anything is created. An explicit --parent that + # cannot be linked is a failed request, not a warning: creating the task + # anyway leaves a half-specified relationship that reads as success. + parent_dir: Path | None = None + parent_data: dict | None = None + if args.parent: + parent_dir = resolve_task_dir(args.parent, repo_root) + if parent_dir is None: + print(colored(f"Error: Parent task not resolved: {args.parent}", Colors.RED), file=sys.stderr) + print("No task was created. Pass an existing task directory to --parent.", file=sys.stderr) + return 1 + parent_json_path = parent_dir / FILE_TASK_JSON + if not parent_json_path.is_file(): + print(colored(f"Error: Parent task.json not found: {args.parent}", Colors.RED), file=sys.stderr) + print("No task was created. Pass an existing task directory to --parent.", file=sys.stderr) + return 1 + parent_data, parent_reason = read_json_checked(parent_json_path) + if parent_data is None: + _report_read_failure(parent_json_path, parent_reason) + print("No task was created. Fix the parent task.json, then retry.", file=sys.stderr) + return 1 + dir_name = f"{date_prefix}-{slug}" task_dir = tasks_dir / dir_name task_json_path = task_dir / FILE_TASK_JSON @@ -315,10 +437,30 @@ def cmd_create(args: argparse.Namespace) -> int: print("Use a new slug if you intend to create a new task.", file=sys.stderr) return 1 + # Reusing a slug on the same day is an ordinary accident, and continuing + # would rewrite the existing task.json below — resetting status, children, + # parent, branch and meta, which nothing else can reconstruct. Fail like + # the archived-name collision above unless the caller asks for it. if task_dir.exists(): - print(colored(f"Warning: Task directory already exists: {dir_name}", Colors.YELLOW), file=sys.stderr) + if not getattr(args, "force", False): + print(colored(f"Error: Task already exists: {dir_name}", Colors.RED), file=sys.stderr) + print(f"Existing task at: {_repo_relative_path(task_dir, repo_root)}", file=sys.stderr) + print( + "Use a different --slug, or pass --force to overwrite its task.json.", + file=sys.stderr, + ) + return 1 + print( + colored( + f"Warning: --force: overwriting task.json in existing task: {dir_name}", + Colors.YELLOW, + ), + file=sys.stderr, + ) + created_dir = False else: task_dir.mkdir(parents=True) + created_dir = True today = datetime.now().strftime("%Y-%m-%d") @@ -349,16 +491,6 @@ def cmd_create(args: argparse.Namespace) -> int: file=sys.stderr, ) - description = (args.description or "").strip() - if not description.strip(): - print( - colored( - "warning: task description is empty; pass --description to improve search and later audits.", - Colors.YELLOW, - ), - file=sys.stderr, - ) - task_data = { "id": slug, "name": slug, @@ -386,7 +518,21 @@ def cmd_create(args: argparse.Namespace) -> int: "meta": meta, } - write_json(task_json_path, task_data) + # A directory without task.json is not a task: `list` hides it and every + # lifecycle command refuses it. Fail here rather than printing "Created + # task" and emitting the path for script chaining. + if not write_json(task_json_path, task_data): + _report_write_failure(task_json_path) + print(colored(f"No task was created: {dir_name}", Colors.RED), file=sys.stderr) + if created_dir: + try: + task_dir.rmdir() + except OSError: + print( + f"Leftover empty directory: {_repo_relative_path(task_dir, repo_root)}", + file=sys.stderr, + ) + return 1 prd_path = task_dir / "prd.md" if not prd_path.exists(): @@ -395,39 +541,53 @@ def cmd_create(args: argparse.Namespace) -> int: encoding="utf-8", ) - # Seed implement.jsonl / check.jsonl for sub-agent-capable platforms. - # Agent curates real entries during planning when the task needs them. - # Agent-less platforms (Kilo / Antigravity / Devin) skip this — they - # load specs via the trellis-before-dev skill instead of JSONL. - seeded_jsonl = False + # Create empty implement.jsonl / check.jsonl for sub-agent-capable + # platforms. They stay empty until the agent curates real entries during + # planning — a placeholder row would read as unresolved scaffolding to + # `task.py validate` and to PR preflight, so the curation instructions go + # to the console below instead of into the files. Agent-less platforms + # (Kilo / Antigravity / Devin) skip this — they load specs via the + # trellis-before-dev skill instead of JSONL. + created_jsonl = False if _has_subagent_platform(repo_root): for jsonl_name in ("implement.jsonl", "check.jsonl"): jsonl_path = task_dir / jsonl_name if not jsonl_path.exists(): - _write_seed_jsonl(jsonl_path) - seeded_jsonl = True + jsonl_path.write_text("", encoding="utf-8") + created_jsonl = True - # Handle --parent: establish bidirectional link - if args.parent: - parent_dir = resolve_task_dir(args.parent, repo_root) + # Establish the bidirectional link. Both sides were validated above, so a + # failure here is a write failure, not a bad argument. + if parent_dir is not None and parent_data is not None: parent_json_path = parent_dir / FILE_TASK_JSON - if not parent_json_path.is_file(): - print(colored(f"Warning: Parent task.json not found: {args.parent}", Colors.YELLOW), file=sys.stderr) - else: - parent_data = read_json(parent_json_path) - if parent_data: - # Add child to parent's children list - parent_children = parent_data.get("children", []) - if dir_name not in parent_children: - parent_children.append(dir_name) - parent_data["children"] = parent_children - write_json(parent_json_path, parent_data) - # Set parent in child's task.json - task_data["parent"] = parent_dir.name - write_json(task_json_path, task_data) + # Add child to parent's children list + parent_children = _ensure_children_list(parent_data) + if dir_name not in parent_children: + parent_children.append(dir_name) + parent_data["children"] = parent_children + if not write_json(parent_json_path, parent_data): + _report_write_failure(parent_json_path) + print( + f"The task exists at {_repo_relative_path(task_dir, repo_root)} but is NOT " + f"linked to {parent_dir.name}. Re-link with: task.py add-subtask " + f"{parent_dir.name} {dir_name}", + file=sys.stderr, + ) + return 1 - print(colored(f"Linked as child of: {parent_dir.name}", Colors.GREEN), file=sys.stderr) + # Set parent in child's task.json + task_data["parent"] = parent_dir.name + if not write_json(task_json_path, task_data): + _report_write_failure(task_json_path) + print( + f"Link is half-written: {parent_dir.name} now lists '{dir_name}' as a child, " + f"but the new task does not record its parent.", + file=sys.stderr, + ) + return 1 + + print(colored(f"Linked as child of: {parent_dir.name}", Colors.GREEN), file=sys.stderr) # Auto-activate the new task so the per-turn breadcrumb fires planning # state. Best-effort: gracefully degrade if no session identity (CLI run @@ -492,9 +652,19 @@ def cmd_create(args: argparse.Namespace) -> int: print(" - Fill prd.md with requirements and acceptance criteria", file=sys.stderr) print(" - Lightweight task: PRD-only is valid", file=sys.stderr) print(" - Complex task: add design.md and implement.md before task.py start", file=sys.stderr) - if seeded_jsonl: + if created_jsonl: print( - " - Curate implement.jsonl / check.jsonl as spec/research manifests when sub-agents need context", + " - Curate implement.jsonl / check.jsonl (created empty) as spec/research " + "manifests before task.py start when sub-agents need context:", + file=sys.stderr, + ) + print( + ' one JSON object per line — {"file": "<path>", "reason": "<why>"}; ' + "spec/research docs only, no code paths", + file=sys.stderr, + ) + print( + " list available specs: python3 .trellis/scripts/get_context.py --mode packages", file=sys.stderr, ) print(" - Use /trellis:continue or phase context to decide the next step", file=sys.stderr) @@ -507,10 +677,549 @@ def cmd_create(args: argparse.Namespace) -> int: return 0 +# ============================================================================= +# Command: rename +# ============================================================================= + +# task.json fields that carry the task's own slug. The directory name carries +# it too, prefixed with the creation date; everything else that names the task +# (parent / children / subtasks in *other* tasks, jsonl paths) stores the full +# directory name instead. +RENAME_IDENTITY_FIELDS: tuple[str, ...] = ("id", "name") + +_JSONL_NAMES: tuple[str, ...] = ("implement.jsonl", "check.jsonl") + + +@dataclass +class _RenamePlan: + """Every change a rename would make, computed before anything is written. + + ``--dry-run`` and the real run render their output from this one structure, + so the printed change set cannot drift from the applied one. + """ + + task_dir: Path + new_dir: Path + old_name: str + new_name: str + old_rel: str + new_rel: str + task_json_path: Path + task_data: dict + # (field, current value, new value) for each identity field that changes. + identity: list[tuple[str, object, str]] = field(default_factory=list) + # (jsonl path, changed line numbers, full new file text) + jsonl: list[tuple[Path, list[int], str]] = field(default_factory=list) + # (other task's task.json, rewritten data, labels of the changed refs) + backrefs: list[tuple[Path, dict, list[str]]] = field(default_factory=list) + # (file, line number) of old-name mentions elsewhere under .trellis/ + reported: list[tuple[Path, int]] = field(default_factory=list) + # Task dirs whose task.json could not be read, so back-refs in them are + # neither rewritten nor ruled out. + unreadable: list[Path] = field(default_factory=list) + + +def _split_date_prefix(dir_name: str) -> tuple[str, str]: + """Split ``MM-DD-slug`` into ``("MM-DD", "slug")``. + + Returns ``("", dir_name)`` when there is no plausible date prefix, so a + hand-made directory name without one is renamed to the bare slug. + """ + m = re.match(r"^(\d{2})-(\d{2})-(.+)$", dir_name) + if m and 1 <= int(m.group(1)) <= 12 and 1 <= int(m.group(2)) <= 31: + return f"{m.group(1)}-{m.group(2)}", m.group(3) + return "", dir_name + + +def _validate_rename_slug(slug: str, date_prefix: str) -> str | None: + """Return the slug body to rename to, or None after reporting the refusal. + + Mirrors ``create``'s ``--slug`` handling: no path separators or ``..`` (the + slug is joined into a directory name), and a pasted-in date prefix is + normalized away rather than doubled — but a rename keeps the task's + *original* creation date, not today's. + """ + if not slug: + print(colored("Error: new slug is required", Colors.RED), file=sys.stderr) + return None + + if "/" in slug or "\\" in slug or ".." in slug: + print( + colored( + f"Error: <new-slug> must be a plain name without path separators or '..': {slug}", + Colors.RED, + ), + file=sys.stderr, + ) + return None + + slug_prefix, body = _split_date_prefix(slug) + if not slug_prefix: + return slug + if slug_prefix == date_prefix: + print( + colored( + f'warning: <new-slug> should not include the MM-DD prefix; normalized to "{body}"', + Colors.YELLOW, + ), + file=sys.stderr, + ) + return body + print( + colored( + f"Error: <new-slug> starts with a date prefix ({slug_prefix}-), but rename keeps " + f"the task's own creation date ({date_prefix}).", + Colors.RED, + ), + file=sys.stderr, + ) + print(f"Pass only the slug body, e.g. {body}", file=sys.stderr) + return None + + +def _plan_jsonl_rewrites( + task_dir: Path, old_rel: str, new_rel: str +) -> list[tuple[Path, list[int], str]]: + """Plan rewrites of context entries pointing under the old task directory. + + Matching on ``<old path>/`` rather than the bare path keeps a sibling task + whose name merely starts with this one's out of the rewrite. + """ + rewrites: list[tuple[Path, list[int], str]] = [] + needle = f"{old_rel}/" + replacement = f"{new_rel}/" + + for jsonl_name in _JSONL_NAMES: + jsonl_path = task_dir / jsonl_name + if not jsonl_path.is_file(): + continue + try: + lines = jsonl_path.read_text(encoding="utf-8").splitlines(keepends=True) + except (OSError, UnicodeDecodeError): + continue + + changed: list[int] = [] + for index, line in enumerate(lines): + if needle in line: + lines[index] = line.replace(needle, replacement) + changed.append(index + 1) + if changed: + rewrites.append((jsonl_path, changed, "".join(lines))) + + return rewrites + + +def _plan_backrefs( + tasks_dir: Path, task_dir: Path, old_name: str, new_name: str +) -> tuple[list[tuple[Path, dict, list[str]]], list[Path]]: + """Plan back-reference rewrites in the other active tasks. + + Returns ``(changes, unreadable)``. ``subtasks`` is the legacy spelling of + ``children`` and is still carried in older task.json files, so both lists + are rewritten. + """ + changes: list[tuple[Path, dict, list[str]]] = [] + unreadable: list[Path] = [] + + for candidate in sorted(tasks_dir.iterdir()): + if not candidate.is_dir() or candidate.name == DIR_ARCHIVE: + continue + if candidate == task_dir: + continue + json_path = candidate / FILE_TASK_JSON + if not json_path.is_file(): + continue + + data, _reason = read_json_checked(json_path) + if data is None: + unreadable.append(json_path) + continue + + labels: list[str] = [] + if data.get("parent") == old_name: + data["parent"] = new_name + labels.append("parent") + for list_field in ("children", "subtasks"): + values = data.get(list_field) + if not isinstance(values, list): + continue + for index, value in enumerate(values): + if value == old_name: + values[index] = new_name + labels.append(f"{list_field}[{index}]") + + if labels: + changes.append((json_path, data, labels)) + + return changes, unreadable + + +def _plan_reported_refs( + repo_root: Path, task_dir: Path, rewritten: set[Path], old_name: str +) -> list[tuple[Path, int]]: + """Find remaining mentions of the old task name elsewhere under .trellis/. + + These are reported, never rewritten: journal entries and workflow prose + cite tasks in free text, and a blind substitution there is how a rename + turns into a diff nobody asked for. The boundary-anchored pattern keeps a + longer task name that merely contains this one out. + + Runtime session pointers are excluded because they are not prose and they + *are* rewritten — see ``repoint_task_in_sessions``. Listing them here as + "not rewritten" would be a false statement about the one file whose + staleness actually breaks the next command. + """ + from .active_task import _runtime_sessions_dir + + pattern = re.compile( + r"(?<![0-9A-Za-z_-])" + re.escape(old_name) + r"(?![0-9A-Za-z_-])" + ) + hits: list[tuple[Path, int]] = [] + trellis_dir = repo_root / DIR_WORKFLOW + sessions_dir = _runtime_sessions_dir(repo_root) + if not trellis_dir.is_dir(): + return hits + + for path in sorted(trellis_dir.rglob("*")): + if not path.is_file() or path in rewritten: + continue + if path == task_dir or task_dir in path.parents: + continue + if path.parent == sessions_dir: + continue + if any( + part == "__pycache__" or part.startswith(".backup-") + for part in path.relative_to(trellis_dir).parts[:-1] + ): + continue + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + for lineno, line in enumerate(text.splitlines(), 1): + if pattern.search(line): + hits.append((path, lineno)) + + return hits + + +def _render_rename_plan(plan: _RenamePlan, repo_root: Path) -> list[str]: + """Render the change set. Identical for --dry-run and the real run.""" + lines = [ + f"rename: {plan.old_name} -> {plan.new_name}", + f" dir: {plan.old_rel} -> {plan.new_rel}", + ] + for field_name, old_value, new_value in plan.identity: + lines.append(f" task.json: {field_name}: {old_value} -> {new_value}") + for json_path, _data, labels in plan.backrefs: + rel = _repo_relative_path(json_path, repo_root) + for label in labels: + lines.append( + f" backref: {rel}: {label}: {plan.old_name} -> {plan.new_name}" + ) + for jsonl_path, linenos, _text in plan.jsonl: + rel = _repo_relative_path(jsonl_path, repo_root) + for lineno in linenos: + lines.append( + f" jsonl: {rel}:{lineno}: {plan.old_rel}/ -> {plan.new_rel}/" + ) + for path, lineno in plan.reported: + lines.append( + f" reported (not rewritten): {_repo_relative_path(path, repo_root)}:{lineno}" + ) + lines.append( + f" sessions: any active-task pointer at {plan.old_rel} is repointed to {plan.new_rel}" + ) + return lines + + +def _rename_interrupted(plan: _RenamePlan) -> None: + """Explain how to finish a rename that stopped part-way through.""" + print( + f"The task is still at {plan.old_rel} and was NOT moved. Every step up to " + f"this one is idempotent: fix the write failure, then run the same rename " + f"again to finish it.", + file=sys.stderr, + ) + + +def _apply_rename(plan: _RenamePlan, repo_root: Path) -> int: + """Write the planned change set. + + The directory move goes last on purpose. Everything before it is an + in-place edit that re-running the identical command recomputes as + already-done, so an interruption leaves a tree that the same command + finishes; a move-first ordering would instead leave the old name + unresolvable and the remaining edits to be made by hand. + """ + if plan.identity: + data = dict(plan.task_data) + for field_name, _old_value, new_value in plan.identity: + data[field_name] = new_value + if not write_json(plan.task_json_path, data): + _report_write_failure(plan.task_json_path) + print("Nothing was renamed.", file=sys.stderr) + return 1 + + for jsonl_path, _linenos, text in plan.jsonl: + try: + jsonl_path.write_text(text, encoding="utf-8") + except OSError as exc: + print( + colored(f"Error: Failed to write {jsonl_path}: {exc}", Colors.RED), + file=sys.stderr, + ) + _rename_interrupted(plan) + return 1 + + for json_path, data, _labels in plan.backrefs: + if not write_json(json_path, data): + _report_write_failure(json_path) + _rename_interrupted(plan) + return 1 + + try: + plan.task_dir.rename(plan.new_dir) + except OSError as exc: + print( + colored(f"Error: Failed to move {plan.old_rel} -> {plan.new_rel}: {exc}", Colors.RED), + file=sys.stderr, + ) + _rename_interrupted(plan) + return 1 + + # Last, and after the move: a session pointing at the old path would now + # resolve to a missing directory, so `current` would report the task stale + # and the context hook would inject nothing until the user ran `start` + # again. Archive clears these pointers because the task is leaving; rename + # moves them, because the task is still the one being worked on. + from .active_task import repoint_task_in_sessions + + repoint_task_in_sessions(str(plan.task_dir), str(plan.new_dir), repo_root) + + return 0 + + +def cmd_rename(args: argparse.Namespace) -> int: + """Rename a task and every reference to it.""" + repo_root = get_repo_root() + tasks_dir = get_tasks_dir(repo_root) + + task_dir = resolve_task_dir(args.name, repo_root) + if task_dir is None or not task_dir.is_dir(): + if task_dir is not None: + print(colored(f"Error: Task not found: {args.name}", Colors.RED), file=sys.stderr) + return 1 + + # Narrower than resolve_task_dir's containment: an archived task has left + # the active set, so its back-references are no longer maintained here. + if not is_within_tasks_dir(task_dir, repo_root): + print( + colored( + f"Error: refusing to rename '{args.name}': " + f"{task_dir} is not an active task under {tasks_dir}", + Colors.RED, + ), + file=sys.stderr, + ) + return 1 + + old_name = task_dir.name + date_prefix, _old_slug = _split_date_prefix(old_name) + + slug = _validate_rename_slug(args.new_slug, date_prefix) + if slug is None: + return 1 + + new_name = f"{date_prefix}-{slug}" if date_prefix else slug + if new_name == old_name: + print( + colored(f"Error: '{new_name}' is the task's current name", Colors.RED), + file=sys.stderr, + ) + return 1 + + new_dir = task_dir.parent / new_name + if new_dir.exists(): + print( + colored(f"Error: Task already exists: {new_name}", Colors.RED), + file=sys.stderr, + ) + print(f"Existing task at: {_repo_relative_path(new_dir, repo_root)}", file=sys.stderr) + print("Pick a different slug.", file=sys.stderr) + return 1 + + archived_task_dir = _find_archived_task_by_dir_name(tasks_dir, new_name) + if archived_task_dir: + print( + colored(f"Error: Task already archived: {new_name}", Colors.RED), + file=sys.stderr, + ) + print(f"Archived at: {_repo_relative_path(archived_task_dir, repo_root)}", file=sys.stderr) + print("Pick a different slug.", file=sys.stderr) + return 1 + + task_json_path = task_dir / FILE_TASK_JSON + if not task_json_path.is_file(): + print( + colored(f"Error: task.json not found at {task_dir}", Colors.RED), + file=sys.stderr, + ) + return 1 + + task_data, reason = read_json_checked(task_json_path) + if task_data is None: + _report_read_failure(task_json_path, reason) + print("Nothing was renamed.", file=sys.stderr) + return 1 + + plan = _RenamePlan( + task_dir=task_dir, + new_dir=new_dir, + old_name=old_name, + new_name=new_name, + old_rel=_repo_relative_path(task_dir, repo_root), + new_rel=_repo_relative_path(new_dir, repo_root), + task_json_path=task_json_path, + task_data=task_data, + ) + plan.identity = [ + (name, task_data.get(name), slug) + for name in RENAME_IDENTITY_FIELDS + if task_data.get(name) != slug + ] + plan.jsonl = _plan_jsonl_rewrites(task_dir, plan.old_rel, plan.new_rel) + plan.backrefs, plan.unreadable = _plan_backrefs( + tasks_dir, task_dir, old_name, new_name + ) + rewritten = {path for path, _linenos, _text in plan.jsonl} + rewritten.update(path for path, _data, _labels in plan.backrefs) + plan.reported = _plan_reported_refs(repo_root, task_dir, rewritten, old_name) + + for line in _render_rename_plan(plan, repo_root): + print(line) + + for json_path in plan.unreadable: + print( + colored( + f"Warning: {_repo_relative_path(json_path, repo_root)} could not be read; " + "any back-reference in it is left as-is.", + Colors.YELLOW, + ), + file=sys.stderr, + ) + + if getattr(args, "dry_run", False): + print(colored("Dry run: nothing was written.", Colors.YELLOW), file=sys.stderr) + return 0 + + rc = _apply_rename(plan, repo_root) + if rc != 0: + return rc + + print(colored(f"Renamed: {old_name} -> {new_name}", Colors.GREEN), file=sys.stderr) + if plan.reported: + print( + colored( + f"{len(plan.reported)} reference(s) elsewhere under {DIR_WORKFLOW}/ " + "still name the old task; they are listed above and were not rewritten.", + Colors.YELLOW, + ), + file=sys.stderr, + ) + return 0 + + # ============================================================================= # Command: archive # ============================================================================= +def _task_branch_field(data: dict, key: str) -> str: + """Read a branch field as a trimmed string ("" when unset or not a string).""" + value = data.get(key) + return strip_blank(value) if isinstance(value, str) else "" + + +def _validate_branch_metadata( + data: dict, + task_name: str, + repo_root: Path, + skip: bool, +) -> bool: + """Check branch metadata before the task leaves the active tree. + + Returns False when archiving must stop. Archive is the last gate that sees + a task, so metadata nobody can reconstruct afterwards is refused here + rather than repaired by hand later (#399 follow-up). + + "PR-backed" is deliberately pragmatic: a task carrying a base_branch in a + repo that has a remote was created expecting a PR, so a missing `branch` + means the metadata was never recorded — not that the work had no branch. + Local-only repos and tasks without a base_branch are left alone. + + A recorded branch that no longer exists locally stays a warning: after a + merge the feature branch is normally deleted, and refusing to archive then + would be backwards. + """ + branch = _task_branch_field(data, "branch") + base_branch = _task_branch_field(data, "base_branch") + task_py = f"python3 {DIR_WORKFLOW}/scripts/task.py" + + if branch and not branch_exists_locally(branch, repo_root): + print( + colored( + f"Warning: recorded branch '{branch}' no longer exists locally " + "(likely merged and deleted).", + Colors.YELLOW, + ), + file=sys.stderr, + ) + + if skip: + return True + + if branch and base_branch and branch == base_branch: + print( + colored( + f"Error: refusing to archive '{task_name}': branch and base_branch " + f"are both '{branch}'. A PR cannot target its own branch, so this " + "metadata cannot describe the work that was merged.", + Colors.RED, + ), + file=sys.stderr, + ) + print("Repair whichever field is wrong:", file=sys.stderr) + print(f" {task_py} set-branch {task_name} <feature-branch>", file=sys.stderr) + print(f" {task_py} set-base-branch {task_name} <target-branch>", file=sys.stderr) + print( + f" {task_py} archive {task_name} --skip-branch-validation" + " # only if this task was never PR-backed", + file=sys.stderr, + ) + return False + + if not branch and base_branch and has_git_remote(repo_root): + print( + colored( + f"Error: refusing to archive '{task_name}': no branch is recorded, " + f"but the task targets base_branch '{base_branch}' in a repo with a " + "remote — the branch it was built on was never written down.", + Colors.RED, + ), + file=sys.stderr, + ) + print("Repair with:", file=sys.stderr) + print(f" {task_py} set-branch {task_name} <branch>", file=sys.stderr) + print( + f" {task_py} archive {task_name} --skip-branch-validation" + " # only if the work landed without a branch of its own", + file=sys.stderr, + ) + return False + + return True + + def cmd_archive(args: argparse.Namespace) -> int: """Archive completed task.""" repo_root = get_repo_root() @@ -525,8 +1234,16 @@ def cmd_archive(args: argparse.Namespace) -> int: # Resolve task directory (supports task name, relative path, or absolute path) task_dir = resolve_task_dir(task_name, repo_root) - if not task_dir or not task_dir.is_dir(): - print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr) + if task_dir is None or not task_dir.is_dir(): + if task_dir is None: + # resolve_task_dir already reported why; keep the archive-specific + # refusal so it reads the same as the is_within_tasks_dir guard. + print(colored( + f"Error: refusing to archive '{task_name}': " + f"it does not resolve to a task under {tasks_dir}", + Colors.RED), file=sys.stderr) + else: + print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr) print("Active tasks:", file=sys.stderr) # Import lazily to avoid circular dependency from .tasks import iter_active_tasks @@ -535,9 +1252,9 @@ def cmd_archive(args: argparse.Namespace) -> int: return 1 # Refuse to archive anything that isn't a real task directly under - # .trellis/tasks/. A mistyped name (e.g. "src") resolves to repo_root/src, - # which is a dir but not a task — without this guard archive would move the - # user's source directory out of the repo. + # .trellis/tasks/. resolve_task_dir keeps the target inside the tasks dir; + # this narrows it further to a direct child, so an already-archived task + # (.trellis/tasks/archive/<month>/<task>) is not archived a second time. if not is_within_tasks_dir(task_dir, repo_root): print(colored( f"Error: refusing to archive '{task_name}': " @@ -545,6 +1262,21 @@ def cmd_archive(args: argparse.Namespace) -> int: Colors.RED), file=sys.stderr) return 1 + # Check the destination before anything below mutates task state. The + # mover refuses a collision too, but by then this command has already + # marked the task completed, re-parented its children and cleared the + # sessions pointing at it — all of which would have to be undone by hand. + archive_dest_check = archive_destination_for(task_dir) + if archive_dest_check.exists(): + print(colored( + f"Error: refusing to archive '{task_name}': " + f"archive destination already exists: " + f"{_repo_relative_path(archive_dest_check, repo_root)}", + Colors.RED), file=sys.stderr) + print(f"Task remains at: {_repo_relative_path(task_dir, repo_root)}", file=sys.stderr) + print("Move or rename the existing archived task, then retry.", file=sys.stderr) + return 1 + dir_name = task_dir.name task_json_path = task_dir / FILE_TASK_JSON @@ -554,24 +1286,45 @@ def cmd_archive(args: argparse.Namespace) -> int: # into safe_archive_paths_to_add so they're staged in this commit. modified_children: list[str] = [] if task_json_path.is_file(): - data = read_json(task_json_path) - if data: - # Warn (don't block) when the recorded branch is stale — it was - # likely already merged and deleted (#399 item 2). - stored_branch = data.get("branch") - if stored_branch and not branch_exists_locally(stored_branch, repo_root): + data, read_reason = read_json_checked(task_json_path) + if data is None: + # Archiving is still the right outcome for a task whose task.json + # is broken — but say so, or the missing "completed" status looks + # like the archive silently did half its job. + problem, _ = describe_json_read_failure(task_json_path, read_reason) + print( + colored( + f"Warning: {problem}; archiving without updating status/children.", + Colors.YELLOW, + ), + file=sys.stderr, + ) + else: + # Before any mutation: branch metadata is unrecoverable once the + # task leaves the active tree. Stale branches only warn. + if not _validate_branch_metadata( + data, + task_name, + repo_root, + getattr(args, "skip_branch_validation", False), + ): print( - colored( - f"Warning: recorded branch '{stored_branch}' no longer exists locally " - "(likely merged and deleted).", - Colors.YELLOW, - ), + f"Not archived: {_repo_relative_path(task_dir, repo_root)} is unchanged.", file=sys.stderr, ) + return 1 data["status"] = "completed" data["completedAt"] = today - write_json(task_json_path, data) + if not write_json(task_json_path, data): + _report_write_failure(task_json_path) + print( + f"Not archived: {_repo_relative_path(task_dir, repo_root)} is unchanged. " + "Archiving a task still marked in progress would hide it from `list` " + "with the wrong status.", + file=sys.stderr, + ) + return 1 # Handle subtask relationships on archive. # Keep this task in its parent's children list so progress @@ -586,11 +1339,35 @@ def cmd_archive(args: argparse.Namespace) -> int: if child_dir_path: child_json = child_dir_path / FILE_TASK_JSON if child_json.is_file(): - child_data = read_json(child_json) - if child_data: - child_data["parent"] = None - write_json(child_json, child_data) - modified_children.append(child_dir_path.name) + child_data, child_reason = read_json_checked(child_json) + if child_data is None: + problem, _ = describe_json_read_failure(child_json, child_reason) + print( + colored( + f"Warning: {problem}; child '{child_dir_path.name}' " + "keeps its parent reference.", + Colors.YELLOW, + ), + file=sys.stderr, + ) + continue + child_data["parent"] = None + if not write_json(child_json, child_data): + # Stop before the move: a child pointing at a + # parent that has left .trellis/tasks/ is a + # dangling reference nothing repairs later. + # Retrying is safe — every step so far is + # idempotent. + _report_write_failure(child_json) + print( + f"Not archived: {_repo_relative_path(task_dir, repo_root)} is " + f"marked completed but stays in place because child " + f"'{child_dir_path.name}' could not be unlinked. " + "Fix the child, then run archive again.", + file=sys.stderr, + ) + return 1 + modified_children.append(child_dir_path.name) # Clear any session that still points at this task before the path moves. from .active_task import clear_task_from_sessions @@ -669,10 +1446,14 @@ def _auto_commit_archive( print("[OK] No task changes to commit.", file=sys.stderr) return True - success, _, err = safe_git_add(paths, repo_root) + success, _, err = safe_git_add(paths, repo_root, retry_on_index_lock=True) if not success: if err and "ignored by" in err.lower(): print_gitignore_warning(paths) + elif stderr_indicates_index_lock(err): + _print_index_lock_warning( + "git add", task_name, repo_root, [*paths, source_rel] + ) else: print( f"[WARN] git add failed: {err.strip() if err else 'unknown error'}", @@ -689,10 +1470,17 @@ def _auto_commit_archive( # # `--ignore-unmatch` makes this a no-op when the task was never tracked # (e.g. archiving a task that lived only in working tree). - run_git( + rc, _, err = run_git_retry_index_lock( ["rm", "-r", "--cached", "--ignore-unmatch", "--", source_rel], cwd=repo_root, ) + if rc != 0 and stderr_indicates_index_lock(err): + # Committing now would record the archived copy without the + # source-side deletes — a half-archived tree in history. + _print_index_lock_warning( + "git rm --cached", task_name, repo_root, [*paths, source_rel] + ) + return not source_was_tracked rc, _, _ = run_git( ["diff", "--cached", "--quiet", "--", *paths, source_rel], @@ -703,15 +1491,65 @@ def _auto_commit_archive( return True commit_msg = f"chore(task): archive {task_name}" - rc, _, err = run_git(["commit", "-m", commit_msg], cwd=repo_root) + # Commit with an explicit pathspec: a bare `git commit` would sweep any + # unrelated entries the developer had staged before archiving into the + # chore commit (#579). `source_rel` is included so the source-side + # deletions staged above land in the same commit. + rc, _, err = run_git_retry_index_lock( + ["commit", "-m", commit_msg, "--", *paths, source_rel], cwd=repo_root + ) if rc == 0: print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr) return True + elif stderr_indicates_index_lock(err): + _print_index_lock_warning( + "git commit", task_name, repo_root, [*paths, source_rel] + ) + return not source_was_tracked else: print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr) return not source_was_tracked +def _print_index_lock_warning( + action: str, task_name: str, repo_root: Path, paths: list[str] +) -> None: + """Report an archive auto-commit that gave up on a held index.lock. + + The move itself already succeeded, so the state is consistent — the task + lives in archive/ and its changes are staged-or-not but never half of + both in a commit. Only the commit is outstanding, which the user (or an + agent reading the log) has to finish by hand. + + ``paths`` are the paths the auto-commit would have staged, so the manual + command keeps the same narrow scope — a blanket ``git add -A -- .trellis/`` + would sweep dirty changes from other active tasks into the archive commit. + """ + lock = index_lock_path(repo_root) + print( + f"[WARN] {action} gave up after {INDEX_LOCK_RETRY_ATTEMPTS} attempts: " + f"another process is holding {lock}", + file=sys.stderr, + ) + print( + "[WARN] The task was moved into archive/ on disk; only the commit is pending.", + file=sys.stderr, + ) + print( + "[WARN] Close whatever holds the lock (an IDE git integration, a status", + file=sys.stderr, + ) + print( + f"[WARN] daemon, another session), or delete {lock} if it is stale, then", + file=sys.stderr, + ) + print( + f'[WARN] commit manually: git add -A -- {" ".join(paths)} && ' + f'git commit -m "chore(task): archive {task_name}"', + file=sys.stderr, + ) + + # ============================================================================= # Command: add-subtask # ============================================================================= @@ -722,6 +1560,16 @@ def cmd_add_subtask(args: argparse.Namespace) -> int: parent_dir = resolve_task_dir(args.parent_dir, repo_root) child_dir = resolve_task_dir(args.child_dir, repo_root) + if parent_dir is None or child_dir is None: + return 1 + + if not parent_dir: + print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr) + return 1 + + if not child_dir: + print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) + return 1 parent_json_path = parent_dir / FILE_TASK_JSON child_json_path = child_dir / FILE_TASK_JSON @@ -734,11 +1582,13 @@ def cmd_add_subtask(args: argparse.Namespace) -> int: print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) return 1 - parent_data = read_json(parent_json_path) - child_data = read_json(child_json_path) - - if not parent_data or not child_data: - print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr) + parent_data, parent_reason = read_json_checked(parent_json_path) + if parent_data is None: + _report_read_failure(parent_json_path, parent_reason) + return 1 + child_data, child_reason = read_json_checked(child_json_path) + if child_data is None: + _report_read_failure(child_json_path, child_reason) return 1 # Check if child already has a parent @@ -748,7 +1598,7 @@ def cmd_add_subtask(args: argparse.Namespace) -> int: return 1 # Add child to parent's children list - parent_children = parent_data.get("children", []) + parent_children = _ensure_children_list(parent_data) child_dir_name = child_dir.name if child_dir_name not in parent_children: parent_children.append(child_dir_name) @@ -757,9 +1607,21 @@ def cmd_add_subtask(args: argparse.Namespace) -> int: # Set parent in child's task.json child_data["parent"] = parent_dir.name - # Write both - write_json(parent_json_path, parent_data) - write_json(child_json_path, child_data) + # Write both. A link is two files; if the second write fails silently the + # two sides disagree with no error, so check each and name the side that + # did land so the user knows what to undo. + if not write_json(parent_json_path, parent_data): + print(colored(f"Error: Failed to write parent task.json: {parent_json_path}", Colors.RED), file=sys.stderr) + print("No link was written.", file=sys.stderr) + return 1 + if not write_json(child_json_path, child_data): + print(colored(f"Error: Failed to write child task.json: {child_json_path}", Colors.RED), file=sys.stderr) + print( + f"Link is half-written: {parent_json_path} now lists " + f"'{child_dir.name}' as a child, but the child does not record the parent.", + file=sys.stderr, + ) + return 1 print(colored(f"Linked: {child_dir.name} -> {parent_dir.name}", Colors.GREEN), file=sys.stderr) return 0 @@ -775,6 +1637,16 @@ def cmd_remove_subtask(args: argparse.Namespace) -> int: parent_dir = resolve_task_dir(args.parent_dir, repo_root) child_dir = resolve_task_dir(args.child_dir, repo_root) + if parent_dir is None or child_dir is None: + return 1 + + if not parent_dir: + print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr) + return 1 + + if not child_dir: + print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) + return 1 parent_json_path = parent_dir / FILE_TASK_JSON child_json_path = child_dir / FILE_TASK_JSON @@ -787,15 +1659,17 @@ def cmd_remove_subtask(args: argparse.Namespace) -> int: print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) return 1 - parent_data = read_json(parent_json_path) - child_data = read_json(child_json_path) - - if not parent_data or not child_data: - print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr) + parent_data, parent_reason = read_json_checked(parent_json_path) + if parent_data is None: + _report_read_failure(parent_json_path, parent_reason) + return 1 + child_data, child_reason = read_json_checked(child_json_path) + if child_data is None: + _report_read_failure(child_json_path, child_reason) return 1 # Remove child from parent's children list - parent_children = parent_data.get("children", []) + parent_children = _ensure_children_list(parent_data) child_dir_name = child_dir.name if child_dir_name in parent_children: parent_children.remove(child_dir_name) @@ -804,9 +1678,20 @@ def cmd_remove_subtask(args: argparse.Namespace) -> int: # Clear parent in child's task.json child_data["parent"] = None - # Write both - write_json(parent_json_path, parent_data) - write_json(child_json_path, child_data) + # Write both — see cmd_add_subtask: an unchecked second write leaves the + # two sides disagreeing with no error. + if not write_json(parent_json_path, parent_data): + print(colored(f"Error: Failed to write parent task.json: {parent_json_path}", Colors.RED), file=sys.stderr) + print("Nothing was unlinked.", file=sys.stderr) + return 1 + if not write_json(child_json_path, child_data): + print(colored(f"Error: Failed to write child task.json: {child_json_path}", Colors.RED), file=sys.stderr) + print( + f"Unlink is half-written: {parent_json_path} no longer lists " + f"'{child_dir.name}', but the child still records the parent.", + file=sys.stderr, + ) + return 1 print(colored(f"Unlinked: {child_dir.name} from {parent_dir.name}", Colors.GREEN), file=sys.stderr) return 0 @@ -820,6 +1705,8 @@ def cmd_set_branch(args: argparse.Namespace) -> int: """Set git branch for task.""" repo_root = get_repo_root() target_dir = resolve_task_dir(args.dir, repo_root) + if target_dir is None: + return 1 branch = args.branch if not branch: @@ -827,17 +1714,26 @@ def cmd_set_branch(args: argparse.Namespace) -> int: print("Usage: python3 task.py set-branch <task-dir> <branch-name>") return 1 + if not target_dir: + # target_dir is None here, so it must not appear in the message. This + # is also the branch a ref pointing outside the repo lands in. + print(colored(f"Error: Task not found: {args.dir}", Colors.RED)) + return 1 + task_json = target_dir / FILE_TASK_JSON if not task_json.is_file(): print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) return 1 - data = read_json(task_json) - if not data: + data, reason = read_json_checked(task_json) + if data is None: + _report_read_failure(task_json, reason) return 1 data["branch"] = branch - write_json(task_json, data) + if not write_json(task_json, data): + _report_write_failure(task_json) + return 1 print(colored(f"✓ Branch set to: {branch}", Colors.GREEN)) return 0 @@ -851,6 +1747,8 @@ def cmd_set_base_branch(args: argparse.Namespace) -> int: """Set the base branch (PR target) for task.""" repo_root = get_repo_root() target_dir = resolve_task_dir(args.dir, repo_root) + if target_dir is None: + return 1 base_branch = args.base_branch if not base_branch: @@ -861,17 +1759,26 @@ def cmd_set_base_branch(args: argparse.Namespace) -> int: print("This sets the target branch for PR (the branch your feature will merge into).") return 1 + if not target_dir: + # target_dir is None here, so it must not appear in the message. This + # is also the branch a ref pointing outside the repo lands in. + print(colored(f"Error: Task not found: {args.dir}", Colors.RED)) + return 1 + task_json = target_dir / FILE_TASK_JSON if not task_json.is_file(): print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) return 1 - data = read_json(task_json) - if not data: + data, reason = read_json_checked(task_json) + if data is None: + _report_read_failure(task_json, reason) return 1 data["base_branch"] = base_branch - write_json(task_json, data) + if not write_json(task_json, data): + _report_write_failure(task_json) + return 1 print(colored(f"✓ Base branch set to: {base_branch}", Colors.GREEN)) print(f" PR will target: {base_branch}") @@ -886,6 +1793,8 @@ def cmd_set_scope(args: argparse.Namespace) -> int: """Set scope for PR title.""" repo_root = get_repo_root() target_dir = resolve_task_dir(args.dir, repo_root) + if target_dir is None: + return 1 scope = args.scope if not scope: @@ -893,17 +1802,26 @@ def cmd_set_scope(args: argparse.Namespace) -> int: print("Usage: python3 task.py set-scope <task-dir> <scope>") return 1 + if not target_dir: + # target_dir is None here, so it must not appear in the message. This + # is also the branch a ref pointing outside the repo lands in. + print(colored(f"Error: Task not found: {args.dir}", Colors.RED)) + return 1 + task_json = target_dir / FILE_TASK_JSON if not task_json.is_file(): print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) return 1 - data = read_json(task_json) - if not data: + data, reason = read_json_checked(task_json) + if data is None: + _report_read_failure(task_json, reason) return 1 data["scope"] = scope - write_json(task_json, data) + if not write_json(task_json, data): + _report_write_failure(task_json) + return 1 print(colored(f"✓ Scope set to: {scope}", Colors.GREEN)) return 0 @@ -917,6 +1835,8 @@ def cmd_set_meta(args: argparse.Namespace) -> int: """Set/overwrite one metadata key on an existing task.""" repo_root = get_repo_root() target_dir = resolve_task_dir(args.dir, repo_root) + if target_dir is None: + return 1 key = args.key value = args.value @@ -925,13 +1845,20 @@ def cmd_set_meta(args: argparse.Namespace) -> int: print("Usage: python3 task.py set-meta <task-dir> <key> <value>") return 1 + if not target_dir: + # target_dir is None here, so it must not appear in the message. This + # is also the branch a ref pointing outside the repo lands in. + print(colored(f"Error: Task not found: {args.dir}", Colors.RED)) + return 1 + task_json = target_dir / FILE_TASK_JSON if not task_json.is_file(): print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) return 1 - data = read_json(task_json) - if not data: + data, reason = read_json_checked(task_json) + if data is None: + _report_read_failure(task_json, reason) return 1 meta = data.get("meta") @@ -939,7 +1866,9 @@ def cmd_set_meta(args: argparse.Namespace) -> int: meta = {} meta[key] = value data["meta"] = meta - write_json(task_json, data) + if not write_json(task_json, data): + _report_write_failure(task_json) + return 1 print(colored(f"✓ Meta set: {key} = {value}", Colors.GREEN)) return 0 diff --git a/.trellis/scripts/common/task_utils.py b/.trellis/scripts/common/task_utils.py index 62368db0..a6e34704 100755 --- a/.trellis/scripts/common/task_utils.py +++ b/.trellis/scripts/common/task_utils.py @@ -3,9 +3,10 @@ Task utility functions. 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 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 run_task_hooks - Run lifecycle hooks for task events """ @@ -16,70 +17,28 @@ import shutil import sys from datetime import datetime from pathlib import Path +from typing import TYPE_CHECKING from .paths import get_repo_root, get_tasks_dir +if TYPE_CHECKING: + import subprocess + # ============================================================================= # Path Safety # ============================================================================= -def is_safe_task_path(task_path: str, repo_root: Path | None = None) -> bool: - """Check if a relative task path is safe to operate on. - - Args: - task_path: Task path (relative to repo_root). - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - True if safe, False if dangerous. - """ - if repo_root is None: - repo_root = get_repo_root() - - normalized = task_path.replace("\\", "/") - - # Check empty or null - if not normalized or normalized == "null": - print("Error: empty or null task path", file=sys.stderr) - return False - - # Reject absolute paths - if Path(task_path).is_absolute(): - print(f"Error: absolute path not allowed: {task_path}", file=sys.stderr) - return False - - # Reject ".", "..", paths starting with "./" or "../", or containing ".." - if normalized in (".", "..") or normalized.startswith("./") or normalized.startswith("../") or ".." in normalized: - print(f"Error: path traversal not allowed: {task_path}", file=sys.stderr) - return False - - # Final check: ensure resolved path is not the repo root - abs_path = repo_root / Path(normalized) - if abs_path.exists(): - try: - resolved = abs_path.resolve() - root_resolved = repo_root.resolve() - if resolved == root_resolved: - print(f"Error: path resolves to repo root: {task_path}", file=sys.stderr) - return False - except (OSError, IOError): - pass - - return True - - def is_within_tasks_dir(task_dir_abs: Path, repo_root: Path | None = None) -> bool: """Check that a resolved task directory really is a task under the tasks dir. A real task lives directly at ``.trellis/tasks/<name>``. This returns True only when ``task_dir_abs`` is an immediate child of the tasks directory. - Guards archive: ``resolve_task_dir`` falls back to ``repo_root/<name>`` for - an unknown name, so a mistyped ``task.py archive src`` resolves to the real - ``src/`` source directory. Without this check archive would ``shutil.move`` - it out of the repo. Also rejects the tasks dir itself and anything nested - under ``archive/`` (already-archived tasks). + Narrows ``resolve_task_dir``'s containment for archive: that chokepoint + accepts anything under the tasks dir, including a task already in + ``archive/<YYYY-MM>/``. This rejects those, plus the tasks dir itself, so + ``shutil.move`` never re-archives an archived task into a nested copy. """ if repo_root is None: 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: """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: task_name: Task name to find. tasks_dir: Tasks directory path. 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(): 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 exact_match = tasks_dir / task_name if exact_match.is_dir(): return exact_match # Try suffix match (e.g., "my-task" matches "01-21-my-task") - for d in tasks_dir.iterdir(): - if d.is_dir() and d.name.endswith(f"-{task_name}"): - return d + matches = sorted( + d for d in tasks_dir.iterdir() + 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 @@ -127,6 +107,13 @@ def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None: # 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: """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) return None - # Get tasks directory (parent of the task) - tasks_dir = task_dir_abs.parent - archive_dir = tasks_dir / "archive" - year_month = datetime.now().strftime("%Y-%m") - month_dir = archive_dir / year_month + dest = archive_destination_for(task_dir_abs) + month_dir = dest.parent # Create archive directory 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) return None - # Move task to archive - task_name = task_dir_abs.name - dest = month_dir / task_name + # shutil.move into an existing directory moves the source *inside* it, + # producing archive/<month>/<task>/<task>/ and returning a path that is not + # 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: shutil.move(str(task_dir_abs), str(dest)) @@ -195,53 +192,210 @@ def archive_task_complete( # Task Directory Resolution # ============================================================================= -def resolve_task_dir(target_dir: str, repo_root: Path) -> Path: - """Resolve task directory to absolute path. +def resolve_task_dir(target_dir: str, repo_root: Path) -> Path | None: + """Resolve task directory to an absolute path inside the tasks directory. Supports: - Absolute path: /path/to/task - Relative path: .trellis/tasks/01-31-my-task - Task name: my-task (uses find_task_by_name for lookup) + 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: target_dir: Task directory specification. repo_root: Repository root path. 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: - return Path() + print("Error: task directory is required", file=sys.stderr) + return None normalized = target_dir.replace("\\", "/") while normalized.startswith("./"): normalized = normalized[2:] - # Absolute path - if Path(target_dir).is_absolute(): - return Path(target_dir) - - # Relative path (contains path separator or starts with .trellis) - if "/" in normalized or normalized.startswith(".trellis"): - return repo_root / Path(normalized) - - # Task name - try to find in tasks directory tasks_dir = get_tasks_dir(repo_root) - found = find_task_by_name(target_dir, tasks_dir) - if found: - return found - # Fallback to treating as relative path - return repo_root / Path(normalized) + if Path(target_dir).is_absolute(): + 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 # ============================================================================= +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: """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: event: Event name (e.g. "after_create"). 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)} + # 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: + proc = None try: - result = subprocess.run( + proc = subprocess.Popen( cmd, shell=True, cwd=repo_root, env=env, - capture_output=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, encoding="utf-8", 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( - 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, ) - if result.stderr.strip(): - print(f" {result.stderr.strip()}", file=sys.stderr) + print(f" cwd: {repo_root}", 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: 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, ) @@ -294,5 +494,5 @@ if __name__ == "__main__": tasks = get_tasks_dir(repo) print(f"Tasks dir: {tasks}") - print(f"is_safe_task_path('.trellis/tasks/test'): {is_safe_task_path('.trellis/tasks/test', repo)}") - print(f"is_safe_task_path('../test'): {is_safe_task_path('../test', repo)}") + print(f"resolve_task_dir('.trellis/tasks/test'): {resolve_task_dir('.trellis/tasks/test', repo)}") + print(f"resolve_task_dir('../test'): {resolve_task_dir('../test', repo)}") diff --git a/.trellis/scripts/common/tasks.py b/.trellis/scripts/common/tasks.py index 7b44094c..ae7f46f4 100755 --- a/.trellis/scripts/common/tasks.py +++ b/.trellis/scripts/common/tasks.py @@ -12,10 +12,11 @@ Provides: from __future__ import annotations +import sys from collections.abc import Iterator 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 .types import TaskInfo @@ -28,13 +29,22 @@ def load_task(task_dir: Path) -> TaskInfo | None: Returns: 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 if not task_json.is_file(): return None - data = read_json(task_json) - if not data: + data, reason = read_json_checked(task_json) + 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 TaskInfo( diff --git a/.trellis/scripts/common/trellis_config.py b/.trellis/scripts/common/trellis_config.py index a4a6d1e8..800a5910 100755 --- a/.trellis/scripts/common/trellis_config.py +++ b/.trellis/scripts/common/trellis_config.py @@ -2,13 +2,23 @@ """ Standalone reader for .trellis/config.yaml. -Mirrors a minimal subset of common.config so callers (hooks, workflow_phase) -can read configuration without importing the full task/repo helpers. Returns -an empty dict on missing/malformed files so callers stay simple. +Owns the minimal YAML parser used across Trellis. ``common.config`` imports +``parse_simple_yaml`` from here rather than keeping its own copy: this module +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 +import sys from pathlib import Path from typing import Optional @@ -52,11 +62,62 @@ def _next_content_line(lines: list[str], start: int) -> tuple[int, str]: 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( - lines: list[str], start: int, min_indent: int, target: dict + lines: list[str], start: int, min_indent: int, target: dict, source: str ) -> int: i = start 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): line = lines[i] @@ -75,10 +136,33 @@ def _parse_yaml_block( current_list.append(_unquote(stripped[2:].strip())) i += 1 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 = key.strip() value = _strip_inline_comment(value).strip() 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) current_list = None @@ -92,6 +176,7 @@ def _parse_yaml_block( i = next_i elif next_line.strip().startswith("- "): current_list = [] + list_owner_indent = indent target[key] = current_list i += 1 else: @@ -99,7 +184,7 @@ def _parse_yaml_block( if next_indent > indent: nested: dict = {} 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: target[key] = {} i += 1 @@ -109,11 +194,33 @@ def _parse_yaml_block( return i -def parse_simple_yaml(content: str) -> dict: - """Parse a small subset of YAML. See common.config for full doc.""" +def parse_simple_yaml(content: str, source: str = "config.yaml") -> 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). 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() result: dict = {} - _parse_yaml_block(lines, 0, 0, result) + _parse_yaml_block(lines, 0, 0, result, source) return result @@ -126,7 +233,7 @@ def read_trellis_config(repo_root: Optional[Path] = None) -> dict: except (FileNotFoundError, OSError): return {} try: - parsed = parse_simple_yaml(content) + parsed = parse_simple_yaml(content, source=str(config_file)) except Exception: return {} return parsed if isinstance(parsed, dict) else {} diff --git a/.trellis/scripts/common/workflow_phase.py b/.trellis/scripts/common/workflow_phase.py index 9e1c619c..b2685789 100755 --- a/.trellis/scripts/common/workflow_phase.py +++ b/.trellis/scripts/common/workflow_phase.py @@ -141,6 +141,25 @@ def _platform_matches(platform: str, block_names: list[str]) -> bool: 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: """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 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": mode = "auto" codex_cfg = config.get("codex") if isinstance(config, dict) else None diff --git a/.trellis/scripts/task.py b/.trellis/scripts/task.py index 7e82eacb..c90adbee 100755 --- a/.trellis/scripts/task.py +++ b/.trellis/scripts/task.py @@ -4,18 +4,19 @@ Task Management Script. Usage: - python3 task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] [--package <pkg>] [--no-start] + python3 task.py 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 validate <dir> # Validate jsonl files python3 task.py list-context <dir> # List jsonl entries - python3 task.py start <dir> # Set active task + python3 task.py start <dir> # Set active task, record current branch python3 task.py current [--source] [--json] # Show active task python3 task.py finish # Clear active task python3 task.py set-branch <dir> <branch> # Set git branch python3 task.py set-base-branch <dir> <branch> # Set PR target branch python3 task.py set-scope <dir> <scope> # Set scope for PR title python3 task.py set-meta <dir> <key> <value> # Set a task metadata key - python3 task.py archive <task-dir> # Archive completed task + python3 task.py 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-archive [month] # List archived tasks python3 task.py add-subtask <parent-dir> <child-dir> # Link child to parent @@ -27,9 +28,12 @@ from __future__ import annotations import argparse import json import sys +from pathlib import Path from common.log import Colors, colored +from common.config import get_codex_dispatch_mode from common.paths import ( + DEVELOPER_HINT, DIR_WORKFLOW, DIR_TASKS, FILE_TASK_JSON, @@ -44,13 +48,19 @@ from common.active_task import ( resolve_context_key, 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.tasks import iter_active_tasks, children_progress # Import command handlers from split modules (also re-exports for plan.py compatibility) from common.task_store import ( cmd_create, + cmd_rename, cmd_archive, cmd_set_branch, cmd_set_base_branch, @@ -63,6 +73,7 @@ from common.task_context import ( cmd_add_context, cmd_validate, cmd_list_context, + curated_entry_count, ) @@ -70,6 +81,94 @@ from common.task_context import ( # 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: """Set active task.""" 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) 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(): print(colored(f"Error: Task not found: {task_input}", Colors.RED)) print("Hint: Use task name (e.g., 'my-task') or full path (e.g., '.trellis/tasks/01-31-my-task')") return 1 - # Convert to relative path for storage + # 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: - task_dir = full_path.relative_to(repo_root).as_posix() + task_dir = full_path.relative_to(repo_root.resolve()).as_posix() 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 @@ -113,11 +251,7 @@ def cmd_start(args: argparse.Namespace) -> int: # Still flip task.json status: planning → in_progress so downstream phases proceed. if task_json_path.is_file(): - data = read_json(task_json_path) - if data and data.get("status") == "planning": - data["status"] = "in_progress" - if write_json(task_json_path, data): - print(colored("✓ Status: planning → in_progress (degraded)", Colors.GREEN)) + _record_start_state(task_json_path, repo_root, " (degraded)") run_task_hooks("after_start", task_json_path, repo_root) return 0 @@ -127,11 +261,7 @@ def cmd_start(args: argparse.Namespace) -> int: print(f"Source: {active.source}") if task_json_path.is_file(): - data = read_json(task_json_path) - if data and data.get("status") == "planning": - data["status"] = "in_progress" - if write_json(task_json_path, data): - print(colored("✓ Status: planning → in_progress", Colors.GREEN)) + _record_start_state(task_json_path, repo_root) print() 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): task_obj = None + read_error = None 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 = { "dir": active.task_path, "id": data.get("id") or data.get("name"), @@ -183,11 +325,15 @@ def cmd_current(args: argparse.Namespace) -> int: "branch": data.get("branch"), "base_branch": data.get("base_branch"), } - print(json.dumps({ + payload = { "current_task": task_obj, "source": active.source, "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 if args.source: @@ -242,7 +388,10 @@ def cmd_list(args: argparse.Namespace) -> int: if as_json: 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 items = [] @@ -270,6 +419,7 @@ def cmd_list(args: argparse.Namespace) -> int: if filter_mine: if not developer: print(colored("Error: No developer set. Run init_developer.py first", Colors.RED), file=sys.stderr) + print(DEVELOPER_HINT, file=sys.stderr) return 1 print(colored(f"My tasks (assignee: {developer}):", Colors.BLUE)) else: @@ -378,20 +528,21 @@ def show_usage() -> None: print("""Task Management Script Usage: - python3 task.py create <title> Create new task directory - python3 task.py create <title> --package <pkg> Create task for a specific package - python3 task.py create <title> --parent <dir> Create task as child of parent - python3 task.py create <title> --no-start Create without making it active in this session + python3 task.py create <title> --description <desc> Create new task directory (both required, non-empty) + python3 task.py create <title> --description <desc> --package <pkg> Create task for a specific package + python3 task.py create <title> --description <desc> --parent <dir> Create task as child of parent + 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 validate <dir> Validate jsonl files python3 task.py list-context <dir> List jsonl entries - python3 task.py start <dir> Set active task + python3 task.py start <dir> Set active task; records the checked-out branch when unset python3 task.py current [--source] Show active task python3 task.py finish Clear active task python3 task.py set-branch <dir> <branch> Set git branch python3 task.py set-base-branch <dir> <branch> Set PR target branch python3 task.py set-scope <dir> <scope> Set scope for PR title python3 task.py set-meta <dir> <key> <value> Set/overwrite a task metadata key + python3 task.py rename <dir> <new-slug> Rename task, identity fields and references python3 task.py archive <task-dir> Archive completed task python3 task.py add-subtask <parent> <child> Link child task to parent python3 task.py remove-subtask <parent> <child> Unlink child from parent @@ -401,22 +552,38 @@ Usage: Monorepo options: --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: --mine, -m Show only tasks assigned to current developer --status, -s <s> Filter by status (planning, in_progress, review, completed) --json Output machine-readable JSON (also available on `current`) Examples: - python3 task.py create "Add login feature" --slug add-login - python3 task.py create "Add login feature" --slug add-login --package cli - python3 task.py create "Add login feature" --meta linear=ENG-123 --meta epic=auth - python3 task.py create "Child task" --slug child --parent .trellis/tasks/01-21-parent + python3 task.py create "Add login feature" --description "Email + password sign-in" --slug add-login + python3 task.py create "Add login feature" --description "Email + password sign-in" --slug add-login --package cli + 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" --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 set-branch <dir> task/add-login python3 task.py start .trellis/tasks/01-21-add-login python3 task.py current --source python3 task.py finish + python3 task.py 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 --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 remove-subtask parent-task child-task python3 task.py list # List all active tasks @@ -469,11 +636,15 @@ def main() -> int: # create 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("--assignee", "-a", help="Assignee developer") p_create.add_argument("--priority", "-p", default="P2", help="Priority (P0-P3)") - p_create.add_argument("--description", "-d", help="Task description") + p_create.add_argument( + "--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("--package", help="Package name for monorepo projects") p_create.add_argument( @@ -490,6 +661,11 @@ def main() -> int: action="store_true", 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 p_add = subparsers.add_parser("add-context", help="Add context entry") @@ -509,6 +685,11 @@ def main() -> int: # start p_start = subparsers.add_parser("start", help="Set active task") 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 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("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 p_archive = subparsers.add_parser("archive", help="Archive task") p_archive.add_argument("name", help="Task directory or name") p_archive.add_argument("--no-commit", action="store_true", help="Skip auto git commit after archive") + 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 p_list = subparsers.add_parser("list", help="List tasks") @@ -584,6 +783,7 @@ def main() -> int: "set-base-branch": cmd_set_base_branch, "set-scope": cmd_set_scope, "set-meta": cmd_set_meta, + "rename": cmd_rename, "archive": cmd_archive, "add-subtask": cmd_add_subtask, "remove-subtask": cmd_remove_subtask, diff --git a/.trellis/spec/guides/index.md b/.trellis/spec/guides/index.md index 147c79bd..97b55ab8 100644 --- a/.trellis/spec/guides/index.md +++ b/.trellis/spec/guides/index.md @@ -42,23 +42,16 @@ These guides help you **ask the right questions before coding**. - [ ] You're writing similar code to something that exists - [ ] You see the same pattern repeated 3+ times - [ ] 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! → 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!** - -```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. +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. --- diff --git a/.trellis/spec/hyb-oa/project-governance.md b/.trellis/spec/hyb-oa/project-governance.md index c1a4f1a5..1b87633f 100644 --- a/.trellis/spec/hyb-oa/project-governance.md +++ b/.trellis/spec/hyb-oa/project-governance.md @@ -16,9 +16,32 @@ tasks after TRELLIS2. - 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 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. -- 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 @@ -327,12 +350,35 @@ preview platform scope -> conservative update/migrate -> merge overlays - Sub-agent mode requires at least one real entry in each manifest. - Switching dispatch mode changes the validation policy, not historical task 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 - Inline + zero real entries -> pass with `*_inline_optional`. - 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. ### 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 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 `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 diff --git a/.trellis/tasks/09-05-trellis-upgrade-rule-cleanup/check.jsonl b/.trellis/tasks/09-05-trellis-upgrade-rule-cleanup/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/09-05-trellis-upgrade-rule-cleanup/check.jsonl @@ -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."} diff --git a/.trellis/tasks/09-05-trellis-upgrade-rule-cleanup/design.md b/.trellis/tasks/09-05-trellis-upgrade-rule-cleanup/design.md new file mode 100644 index 00000000..16064ddb --- /dev/null +++ b/.trellis/tasks/09-05-trellis-upgrade-rule-cleanup/design.md @@ -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 可理解的工具依赖,也未改动其他工具配置。 diff --git a/.trellis/tasks/09-05-trellis-upgrade-rule-cleanup/implement.jsonl b/.trellis/tasks/09-05-trellis-upgrade-rule-cleanup/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/09-05-trellis-upgrade-rule-cleanup/implement.jsonl @@ -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."} diff --git a/.trellis/tasks/09-05-trellis-upgrade-rule-cleanup/implement.md b/.trellis/tasks/09-05-trellis-upgrade-rule-cleanup/implement.md new file mode 100644 index 00000000..d29fc799 --- /dev/null +++ b/.trellis/tasks/09-05-trellis-upgrade-rule-cleanup/implement.md @@ -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/trellis@0.6.12`,随后核对 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 历史及本次交付回复为准。 diff --git a/.trellis/tasks/09-05-trellis-upgrade-rule-cleanup/prd.md b/.trellis/tasks/09-05-trellis-upgrade-rule-cleanup/prd.md new file mode 100644 index 00000000..6836de47 --- /dev/null +++ b/.trellis/tasks/09-05-trellis-upgrade-rule-cleanup/prd.md @@ -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 风险矩阵执行隔离运行时回归、技能/配置解析、工作流状态与引用检查、当前任务治理及任务外文件保护核验;不运行无关业务测试。新会话技能发现和独立只读复核需分别记录,不以静态检查替代。 diff --git a/.trellis/tasks/09-05-trellis-upgrade-rule-cleanup/task.json b/.trellis/tasks/09-05-trellis-upgrade-rule-cleanup/task.json new file mode 100644 index 00000000..e24a1eb6 --- /dev/null +++ b/.trellis/tasks/09-05-trellis-upgrade-rule-cleanup/task.json @@ -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": {} +} diff --git a/.trellis/workflow.md b/.trellis/workflow.md index fc54ac02..a43e126d 100644 --- a/.trellis/workflow.md +++ b/.trellis/workflow.md @@ -78,8 +78,10 @@ python3 ./.trellis/scripts/task.py list [--mine] [--status <s>] python3 ./.trellis/scripts/task.py list-archive # Code-spec context (injected into implement/check agents via JSONL). -# `implement.jsonl` / `check.jsonl` are seeded on `task create` for sub-agent-capable -# platforms; the AI curates real spec + research entries during planning when needed. +# `implement.jsonl` / `check.jsonl` are seeded (empty) on `task create` for sub-agent-capable +# 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 list-context <name> [action] 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. -**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 @@ -145,6 +147,7 @@ python3 ./.trellis/scripts/get_context.py --mode phase --step <X.Y> # detailed TAG ↔ PHASE scoping: [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-inline] → Codex inline variant of Phase 1 [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 -- 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. - 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] 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. [/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 - 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`) @@ -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') --> [workflow-state:planning] -Load `trellis-brainstorm`; stay in planning. -Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`. +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: `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. Sub-agent mode: curate `implement.jsonl` and `check.jsonl` as spec/research manifests before start. [/workflow-state:planning] @@ -229,8 +240,8 @@ Sub-agent mode: curate `implement.jsonl` and `check.jsonl` as spec/research mani into a sub-agent. --> [workflow-state:planning-inline] -Load `trellis-brainstorm`; stay in planning. -Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`. +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: `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. Inline mode: skip jsonl curation; Phase 2 reads artifacts/specs via `trellis-before-dev`. [/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] -[codex-inline, Kilo, Antigravity, Devin] +[codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness] - Planning or unclear requirements -> `trellis-brainstorm`. - Before editing -> `trellis-before-dev`; after editing -> `trellis-check`. - Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`. -[/codex-inline, Kilo, Antigravity, Devin] +[/codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness] ### 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. -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. @@ -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] -[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. -[/codex-inline, Kilo, Antigravity, Devin] +[/codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness] **Research artifact conventions**: - 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] -[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. -[/codex-inline, Kilo, Antigravity, Devin] +[/codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness] #### 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> ``` -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. -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 @@ -541,15 +552,15 @@ The platform prelude auto-handles the context load requirement: [/Kiro] -[codex-inline, Kilo, Antigravity, Devin] +[codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness] 1. Load the `trellis-before-dev` skill to read project guidelines 2. Read `{TASK_DIR}/prd.md`, then `design.md` if present, then `implement.md` if present 3. Consult materials under `{TASK_DIR}/research/` 4. Implement the code per reviewed artifacts -5. Run project lint and type-check +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]` @@ -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] -[codex-inline, Kilo, Antigravity, Devin] +[codex-inline, Kilo, Antigravity, Devin, DeepSeek Harness] Load the `trellis-check` skill and verify the code per its guidance: - Spec compliance - lint / type-check / tests - Cross-layer consistency (when changes span layers) -If issues are found → fix → re-check, until green. +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]` @@ -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. -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**: @@ -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 -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 | |---|---| | 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) | | 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) | diff --git a/AGENTS.md b/AGENTS.md index 9e3d35eb..60b4b85e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ ## Trellis-first 入口 -- 开始任何非微小开发、审查、测试或文档治理任务前,先读 `.trellis/workflow.md`。 +- 非微小任务先读 `.trellis/workflow.md` 的阶段索引和当前步骤,按需读取关联规约;本会话已读且未变化的资料不重复通读。纯咨询与小改不默认建 task。 - Trellis task 是后续需求拆解、执行、验证和复盘的执行合同;新任务不再强制绑定 `issues/*.csv`。 - `prd.md` 是任务需求和验收入口;复杂任务补 `design.md` 或 `implement.md`;`implement.jsonl` / `check.jsonl` 只登记稳定 spec 或 research 上下文。 - `issues/*.csv` 保留为历史审计材料或特殊外部交付导出格式,不再作为新开发的默认前置合同。 @@ -12,15 +12,15 @@ ## 不可违反约束 -- 编码前先方案:非微小改动必须先给实现方案并等待批准。 +- 编码前先方案:非微小改动先给方案并批准;已批准的同一范围自主完成实施和针对性验证,只有范围、风险或验收发生实质变化才重新确认。 - 先有 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/` 组织上下文。 - 中文沟通;commit message 使用中文并引用 Trellis task 名称或任务 ID。 - 不泄露密码、token、Secret、真实票据、合同正文、银行资料、完整账号、完整本地路径或原始 payload。 - 不轻易新建文档;优先更新现有事实源、模块规约、操作手册、证据文档或当前 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。 - 涉及权限模型、状态机、workflow、数据迁移、审批规则时,实施后必须启动只读 review,不让 reviewer 改代码。 - 公网部署、生产配置、真实数据写入、数据库重置、企业微信写入和真实付款必须另行获得明确批准。 +- 独立复核受工具或 inline 模式限制时,完成可执行的验证并明确标记待独立复核,不虚报通过或反复索取同一授权。提交、推送、归档必须有明确授权;阶段完成不等于整个任务完成。 - 若 Trellis 规则与业务文档冲突:当前 Trellis task PRD/设计 > 当前模块规约 > 业务规约和 `docs/产品设计规则.md` 中的体验事实 > Trellis spec > 历史证据;前端视觉裁决优先使用 `frontend-design` skill,历史 Issue CSV 只用于追溯。 <!-- TRELLIS:START --> diff --git a/server/tests/unit/scripts/trellis-runtime-contract.test.js b/server/tests/unit/scripts/trellis-runtime-contract.test.js new file mode 100644 index 00000000..6bd9732d --- /dev/null +++ b/server/tests/unit/scripts/trellis-runtime-contract.test.js @@ -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"); + }); +});